Skip to content

feat(coding-agents): add qwen-code as a hook harness - #3769

Open
malzzz wants to merge 1 commit into
vectorize-io:mainfrom
malzzz:harness/qwen-code
Open

feat(coding-agents): add qwen-code as a hook harness#3769
malzzz wants to merge 1 commit into
vectorize-io:mainfrom
malzzz:harness/qwen-code

Conversation

@malzzz

@malzzz malzzz commented Aug 24, 2026

Copy link
Copy Markdown

Adds qwen-code to HOOK_HARNESSES, with its transcript reader, installer, entry points, tests, docs, control-plane logo entry and E2E setup.

Qwen Code's hook protocol is a deliberate Claude Code superset — same stdin envelope (session_id / transcript_path / cwd / hook_event_name), same hookSpecificOutput.additionalContext + systemMessage output, same exit semantics, and a settings.json shape byte-for-byte what cmdHook() already emits. The spec is therefore claude-code's with three deltas. All three are same-name/different-meaning, so none is visible from the diff — that's the part worth reviewing.

1. timeout is milliseconds here, seconds everywhere else

Qwen passes a command hook's timeout straight to setTimeout"Timeout in milliseconds, default 60000", from its own bundled docs/features/hooks.md. Installed values are 30000/30000/60000.

Writing 30/60 would register 30 ms hooks — and that misconfiguration looks harmless in testing, because Qwen spawns without detached: true and kills only the direct child, so the orphaned work still completes. You cannot validate the unit by observing that retain works.

Rather than leave that to a comment, HookHarnessSpec gains timeoutUnit, and two lifecycle tests normalise through it: the prompt budget must exceed HOOK_REFLECT_CAP_MS (25 000), and the installed stop timeout must equal hostTimeoutSec * 1000. Changing 30_00030 fails the first; dropping timeoutUnit fails the second. A bare threshold cannot do this — >= 25_000 passes vacuously for the seven seconds-based harnesses, >= 25 passes vacuously for Qwen.

2. parse reads submitted_prompt, not prompt

UserPromptSubmit also fires on tool-result continuations, where prompt holds model-bound tool output — keying on it would recall roughly 20× per user turn against tool results. submitted_prompt is attached only when the turn is both the first and a genuine userQuery, and runHook's if (!prompt) return then suppresses continuations with no core change.

Accepted cost: submitted_prompt is the interactive TUI's projection, so headless (qwen -p), serve, SDK and ACP sessions seed and retain but never recall. The E2E declares injectsIntoModel: false for that reason — a different reason from grok-build's passive hook, and it is spelled out in the setup so it doesn't read as the same limitation.

3. type: "user" is not a user turn

provenance is the discriminator. Across a 22-transcript corpus, synthetic records (notification 285, cron 21, goal_runtime 1) outnumber real_user ones (69) by 4.4 : 1 — without the gate, background-task notifications and cron chatter are retained as things the user said.

isRealUser rejects a present-but-malformed provenance outright rather than falling through to a subtype heuristic, and requires a positive subagent marker (agentId / isSidechain) before accepting an absent one — subagent transcripts use a third envelope carrying neither provenance nor subtype, so a strict real_user test would read them as 100% synthetic and retain nothing. Verified against all 11 real subagent transcripts: 11/11 still accepted.

The injected-context echo

Qwen echoes injected context back into its own transcript inside <qwen:user-prompt-submit-context>, with the inner tags HTML-escaped — so the shared stripInjectedMemory, which matches raw tags, cannot see it. Measured: 55 injections in the corpus, all escaped, 0 raw.

The reader removes it using the two forms of pairing evidence Qwen's contract defines: systemPayload.hookContext present → use systemPayload.displayText (the host's own pre-hook projection, no tag matching at all); otherwise a complete tagged context in the final part after at least one other part.

It deliberately does not match the tag as a substring. The contract is explicit that the tag is "a provenance marker, not … a general trust boundary" and that consumers "must not infer that arbitrary tag-like user text is hook provenance" — so a prompt merely quoting the tag is preserved intact, which there's a test for.

Two things outside the harness itself

registry.test.ts gains the reverse parity check. The existing one is directional (installer → registry), so a harness present in the registry but missing from INSTALLERS passes it while install <name> returns unknown harness. That is exactly how this change was briefly broken during development — the README advertised a command the installer rejected, and the suite was green.

readQwenTranscript catches lazy-read faults. readJsonlTail guards statSync/openSync, but the generator reads at iteration, and runRetainHook calls the reader outside buildRetain's catch. A directory passed as transcript_path passes both guards and then throws EISDIR on the first readSync, rejecting the whole Stop hook. I've handled it at this reader to keep the change additive — the underlying gap is in the shared jsonl.ts and affects every harness, and I think it deserves its own fix rather than riding along here. Happy to open that separately.

Verification

  • npm test — 675 pass
  • tsc --noEmit clean, npm run build clean, build-skill.mjs --check in sync
  • Live corpus regression: 22 transcripts → 80 user / 1587 assistant / 2234 action turns, 0 injected-memory leaks

One note for anyone reproducing: HINDSIGHT_BANK_ID must be unset in the environment, or config.test.ts's "missing files yield defaults" fails on the leaked value.

Logo asset is from homarr-labs/dashboard-icons (svg/qwen.svg, Apache-2.0). Happy to swap it for an official mark if you'd prefer.

Kimi Code is the only other unsupported harness I looked at; I'm deliberately not proposing it here, since its injection contract isn't in its published docs and it would need a third configStyle for TOML.

Qwen Code speaks Claude Code's hook protocol field for field -- same stdin
envelope (session_id / transcript_path / cwd / hook_event_name), same
hookSpecificOutput.additionalContext + systemMessage output, same exit
semantics, and a settings.json shape byte-for-byte what cmdHook() already
emits. So HOOK_HARNESSES["qwen-code"] is claude-code's spec with three deltas,
none of which is visible from the diff:

1. TIMEOUTS ARE MILLISECONDS. Qwen passes a command hook's `timeout` straight
   to setTimeout ("Timeout in milliseconds, default 60000" -- its own bundled
   docs/features/hooks.md). Every other harness here is seconds, so the
   installed values are 30000/30000/60000. Writing 30/60 would register 30ms
   hooks, and that misconfiguration LOOKS fine in testing: Qwen spawns without
   detached:true and kills only the direct child, so the orphaned work still
   completes. HookHarnessSpec therefore declares `timeoutUnit`, and the
   lifecycle tests normalise through it -- changing 30_000 to 30, or dropping
   the unit, now fails a test instead of shipping dead hooks. The prompt budget
   must also clear core/hook.ts's HOOK_REFLECT_CAP_MS (25_000).

2. parse reads `submitted_prompt`, not `prompt`. UserPromptSubmit also fires on
   tool-result continuations, where `prompt` holds model-bound tool output;
   keying on it would recall ~20x per user turn against tool results.
   `submitted_prompt` is attached only when the turn is both the first and a
   genuine userQuery. Accepted cost: it is the interactive TUI's projection, so
   headless (`qwen -p`), serve, SDK and ACP sessions seed and retain but never
   recall. The E2E declares injectsIntoModel: false for exactly that reason --
   a different reason from grok-build's passive hook.

3. type:"user" is NOT a user turn. `provenance` is the discriminator: across a
   22-transcript corpus, synthetic records (notification 285, cron 21,
   goal_runtime 1) outnumber real_user ones (69) by 4.4:1. transcript-qwen.ts
   gates on it, rejecting a present-but-malformed value outright rather than
   falling through to a subtype heuristic, and requiring a positive subagent
   marker (agentId/isSidechain) before accepting an absent one -- subagent
   transcripts use a third envelope with neither provenance nor subtype.

Qwen also echoes injected context back into its own transcript, wrapped in
<qwen:user-prompt-submit-context> with the inner tags HTML-escaped, so the
shared stripInjectedMemory (which matches raw tags) cannot see it. The reader
removes it using the two forms of pairing evidence Qwen's contract defines:
systemPayload.hookContext present -> use systemPayload.displayText, the host's
own pre-hook projection, with no tag matching at all; otherwise a COMPLETE
tagged context in the FINAL part after at least one other part. It deliberately
does NOT match the tag as a substring -- the contract is explicit that the tag
is "a provenance marker, not ... a general trust boundary" and that consumers
"must not infer that arbitrary tag-like user text is hook provenance", so a
prompt merely quoting the tag is preserved intact.

Also here:
- registry.test.ts gains the REVERSE parity check. The existing one is
  directional (installer -> registry), so a harness present in the registry but
  missing from INSTALLERS passes it while `install <name>` returns "unknown
  harness". That is exactly how this change was briefly broken.
- readQwenTranscript catches lazy-read faults. readJsonlTail guards
  statSync/openSync, but the generator reads at iteration, and runRetainHook
  calls the reader outside buildRetain's catch -- so a directory passed as
  transcript_path (which passes both guards, then throws EISDIR on the first
  readSync) rejected the whole Stop hook. NOTE: the underlying gap is in the
  shared jsonl.ts and affects every harness; it deserves its own fix rather
  than riding along here.
- Logo asset from homarr-labs/dashboard-icons (Apache-2.0, svg/qwen.svg).

Tests: 675 pass. Note HINDSIGHT_BANK_ID must be unset in the environment, or
config.test.ts's "missing files yield defaults" fails on the leaked value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WkVVSuv7j1FwTtVpkzzmWR
@strix-security

strix-security Bot commented Aug 24, 2026

Copy link
Copy Markdown

Strix Security Review

No security issues found.

Updated for 67d08ba.


Reviewed by Strix
Re-run review · Configure security review settings

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant