feat(coding-agents): add qwen-code as a hook harness - #3769
Open
malzzz wants to merge 1 commit into
Open
Conversation
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 ReviewNo security issues found. Updated for Reviewed by Strix |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
qwen-codetoHOOK_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), samehookSpecificOutput.additionalContext+systemMessageoutput, same exit semantics, and asettings.jsonshape byte-for-byte whatcmdHook()already emits. The spec is thereforeclaude-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.
timeoutis milliseconds here, seconds everywhere elseQwen passes a command hook's
timeoutstraight tosetTimeout— "Timeout in milliseconds, default 60000", from its own bundleddocs/features/hooks.md. Installed values are30000/30000/60000.Writing
30/60would register 30 ms hooks — and that misconfiguration looks harmless in testing, because Qwen spawns withoutdetached: trueand 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,
HookHarnessSpecgainstimeoutUnit, and two lifecycle tests normalise through it: the prompt budget must exceedHOOK_REFLECT_CAP_MS(25 000), and the installed stop timeout must equalhostTimeoutSec * 1000. Changing30_000→30fails the first; droppingtimeoutUnitfails the second. A bare threshold cannot do this —>= 25_000passes vacuously for the seven seconds-based harnesses,>= 25passes vacuously for Qwen.2.
parsereadssubmitted_prompt, notpromptUserPromptSubmitalso fires on tool-result continuations, wherepromptholds model-bound tool output — keying on it would recall roughly 20× per user turn against tool results.submitted_promptis attached only when the turn is both the first and a genuineuserQuery, andrunHook'sif (!prompt) returnthen suppresses continuations with no core change.Accepted cost:
submitted_promptis the interactive TUI's projection, so headless (qwen -p),serve, SDK and ACP sessions seed and retain but never recall. The E2E declaresinjectsIntoModel: falsefor that reason — a different reason fromgrok-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 turnprovenanceis the discriminator. Across a 22-transcript corpus, synthetic records (notification285,cron21,goal_runtime1) outnumberreal_userones (69) by 4.4 : 1 — without the gate, background-task notifications and cron chatter are retained as things the user said.isRealUserrejects a present-but-malformedprovenanceoutright 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 neitherprovenancenorsubtype, so a strictreal_usertest 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 sharedstripInjectedMemory, 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.hookContextpresent → usesystemPayload.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.tsgains the reverse parity check. The existing one is directional (installer → registry), so a harness present in the registry but missing fromINSTALLERSpasses it whileinstall <name>returnsunknown 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.readQwenTranscriptcatches lazy-read faults.readJsonlTailguardsstatSync/openSync, but the generator reads at iteration, andrunRetainHookcalls the reader outsidebuildRetain's catch. A directory passed astranscript_pathpasses both guards and then throwsEISDIRon the firstreadSync, rejecting the whole Stop hook. I've handled it at this reader to keep the change additive — the underlying gap is in the sharedjsonl.tsand 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 passtsc --noEmitclean,npm run buildclean,build-skill.mjs --checkin syncOne note for anyone reproducing:
HINDSIGHT_BANK_IDmust be unset in the environment, orconfig.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
configStylefor TOML.