3 Commits
Author SHA1 Message Date
f649d65d1f feat(live): add visual input, proactive assistance and memory (#11369)
* feat(live): add visual input, proactive memory and desktop controls

* fix(live): preserve upstream checks and finalize migration validation

* feat(live): open active configuration from host settings

* fix(live): address lifecycle and proactive review findings

* feat(live): add subagent controls and diagnostics with review fixes

---------

Co-authored-by: 菲兹 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-09-11 03:13:13 +00:00
顾盼andClaude 7bd939ec9c feat(qwen-live): ACP backend adaptor and multi-backend routing — M4 (#10617)
* fix(qwen-live): harden review findings across dispatcher, injector, and permission relay

Self-review fixes bundled after the adversarial pass:

- adaptor: classify snake_case permission option ids (proceed_once) —
  the word-boundary regex never matched through "_", so an allow vote
  silently degraded to a cancelled outcome and serve dropped the turn
- adaptor: detect the pending-prompt limit by error name as well as
  status, and fall image-bearing steers back to a regular prompt
- dispatcher: a timed-out tool now answers with a pending receipt that
  tells the model not to retry (the handler keeps running)
- injector: permission items flush ahead of the size-capped context
  join; spoken lines merge whole-line greedily under the 280-char cap
- live-session: resubscribe the event pump with backoff until
  session_closed; a rejected permission chain can no longer become an
  unhandled rejection; receipts are clamped and never empty
- permission broker: a failed auto-approval falls back to the spoken
  ask instead of losing the request
- daemon/config/index: appshot readiness is declared at construction,
  numeric config ports parse, stray rejections are logged not fatal
- e2e harness: QWEN_LIVE_E2E_KEEP=1 keeps the temp dirs for post-mortem

* refactor(qwen-live): extract adaptor helpers and scope cross-backend identifiers

Prep for multiple coexisting backends (M4). Behavior-neutral with a
single backend — the full package suite passes unchanged.

- adaptor-utils.ts: the wire-shape helpers shared by every BackendAdaptor
  (permission-option classification, least-escalating vote, tool-call
  titles, clamped summaries) move out of qwen-code-adaptor.ts verbatim
- handles.ts: jobsByRef keys scope by owning adaptor — jobRefs are
  adaptor-local (serve mints prompt UUIDs, an ACP adaptor counts turns)
  and a bare key would let one backend's createJob return another's job
- permission-broker.ts: pendingByRequestId keys scope the same way, and
  the broker resolves its voting adaptor through adaptorFor(handle)
  instead of holding a single instance
- live-session.ts: adaptorFor(handle) is the one routing seam (single
  adaptor today; becomes registry routing in M4); jobFor/onResolved
  thread the backend handle through

Collision pins added for both maps: identical jobRefs and requestIds
across two backends resolve to their own records, never each other's.

* feat(qwen-live): multi-backend config, registry, and routing

Backends become a set: LiveSession now holds a BackendRegistry instead
of a single adaptor, and every tool call routes to the adaptor that
owns the target backend handle.

- config: the `serve` section is replaced by a `backends` array
  (qwen-code entries keep the REST shape; `acp` entries name a command
  to spawn). Sources resolve whole — QWEN_LIVE_BACKENDS env (JSON) >
  config.json `backends` > legacy synthesis from the old serveUrl /
  serveToken spellings, so existing configs and the e2e harness keep
  working unchanged. Names are validated (unique, no ':') because they
  scope jobRefs and permission requestIds.
- registry: default backend preflight still aborts startup before the
  discovery claim; secondary backends degrade to unavailable with the
  error stored. closeAll() lands in daemon.stop() — which also fixes
  the pre-existing leak where the serve adaptor's close() was never
  called.
- routing: session_list fans out across backends (a dead one
  contributes zero rows, never an empty list) and each row names its
  backend; session_create takes an optional `backend` arg; handoff,
  monitor, stop, the permission-note relay, and the event pump all
  route by handle.
- capability gating goes live (capabilities() had no consumers):
  steering none suppresses steer, image-incapable backends get image
  blocks stripped with a receipt note, and non-streaming backends skip
  the resubscribing pump.

* feat(qwen-live): AcpAdaptor — drive any ACP agent as a backend

One spawned ACP child (JSON-RPC over stdio via
@agentclientprotocol/sdk) multiplexes every session the adaptor
creates; qwen-code's `qwen --acp` mode is the primary target but
nothing here is qwen-specific beyond the named ext methods.

- send discipline: every prompt funnels through one sendPromptNow()
  — a second in-flight prompt would silently preempt the running
  turn. Steers queue locally and are handed over when the agent calls
  craft/drainMidTurnQueue (the handler answers synchronously so the
  agent's 2s latch can never trip); queued prompts and undrained
  steers continue at turn end under their pre-minted jobRefs, so a
  joinedActiveTurn receipt is never lost even if the turn ends before
  the next drain. Steering reports 'queued' until the agent has
  actually drained once.
- crash policy: no supervisor — the exit cascade resolves parked
  permissions cancelled, errors the busy turn, closes every session,
  and the next createSession respawns under a new generation; stale
  handles are rejected. The handshake races the 10s deadline against
  child exit so a crash-on-boot fails in milliseconds.
- permissions: session/request_permission parks its RPC until the
  user answers aloud; the reply picks the least-escalating offered
  option (kind enum classified through the shared adaptor-utils).
- normalization: agent_message_chunk accumulates into a clamped
  turnBuffer, tool_call becomes progress with sanitized titles,
  thought/plan/tool_call_update and unknown discriminators are
  dropped, stopReasons map to complete/error semantics; speak-to-user
  arrives as a speak event once live-conversation activation succeeds
  (non-qwen agents -32601 and we stay off).
- deps: @agentclientprotocol/sdk ^0.14.1 + zod ^3.25.0 (sdk peer);
  the lockfile diff is surgically limited to these entries.
- tests: in-process fake connection covering the receipt table,
  drain behavior, turn-end continuation, stopReason mapping, crash
  cascade, respawn, and speak events; plus real-child lifecycle cases
  against a self-contained fake-acp-agent.mjs fixture (spawn env,
  prompt round-trip, crash-at-boot failing the handshake race).

* test(qwen-live): M4 e2e — ACP call, permission, steering, multi-backend

Four suites against a real `qwen --acp` child (fake OpenAI-compatible
model endpoint) + the fake DashScope/Host fixtures, all collected by
the no-AK PR gate:

- m4-acp-call: voice handoff drives the ACP child; the completion
  flows back as [COMPLETE]; session_list rows carry the backend name
- m4-acp-permission: write_file raises session/request_permission on
  the child; the ask reaches the voice model as [PERMISSION] plus the
  spoken line; the allow vote resolves the parked RPC and the file
  lands. (This pinned a real gap: qwen ACP sessions default to AUTO
  approval — silent allows — so the adaptor now sets the session to
  the asking mode on creation, matching what the voice product needs.)
- m4-acp-steering: a busy-turn handoff receipts as joined or queued
  and the steered text provably reaches a model request payload
  (drained mid-turn or delivered with the next turn); the slow turn's
  conclusion still flows back
- m4-acp-multibackend: serve (default) + ACP (named secondary)
  coexist; session_list shows both backends; handoffs route to the
  named backend's session; both completions flow back

Harness: bootAcpLiveStack boots the acp backend (or the serve+acp
pair) via QWEN_LIVE_BACKENDS, spawning process.execPath against the
same CLI bundle serve uses, with the standard fake-endpoint env.

* test(qwen-live): manual smoke test against a real qodercli --acp agent

qodercli ships the same hidden ACP mode (QoderAgent over NDJSON stdio,
auth via its own login instead of an OpenAI-compatible endpoint). The
suite runs only when the binary is present (spawnSync probe; skips
cleanly in CI) and covers the README checklist: initialize captures
imageInput, steering honestly reports 'queued' (qodercli never drains),
a prompt round-trip lands its completion with the response text, and
listSessions carries the session label.

Verified locally against qodercli 1.1.26: 2/2 passed.

* chore(qwen-live): drop the unused direct zod dependency

@agentclientprotocol/sdk peers on zod but nothing in qwen-live imports
it directly; the workspace already provides it for the sdk's peer
resolution. Reviewer observation on #10617.

* fix(qwen-live): restore M4 registry wiring lost in main rebase

The squash-merge of #10367 produced text-level conflicts on every M4
commit; blind --ours resolution reverted daemon.ts and live-session.ts
to the pre-M4 single-adaptor shape. This restores the merged state:

- daemon.ts: BackendRegistry + buildAdaptor (qwen-code and acp kinds)
  plus the installer HTTP routes (GET /live/setup, POST install/launch)
  from the merged #10367 — both surfaces in one file
- live-session.ts: registry routing at all 11 call sites (session_list
  fan-out, session_create backend arg, handoff/monitor/stop/note-relay,
  pump), the R2 reconcile calls routed through adaptorFor/entry.adaptor,
  and the stale duplicate lastSentence removed
- test rigs restored to the M4 registry shape (FakeAdaptor name ctor,
  BackendRegistry wrapping, daemon.test fakeInstaller)

Verified: 271 unit / 17 e2e / lint / tsc / clean dist build all green.

* fix(qwen-live): preserve live turn and permission delivery

* fix(qwen-live): address review round 1 — child lifecycle, control-sequence injection, session reuse, queue leaks, config/routing

Five fix clusters addressing 16 Critical findings (1 already
addressed, 15 fixed), all probed and reproduced before fixing:

Child lifecycle (R1-47/R1-31/R1-5/R1-32):
- ensureChild memoizes the in-flight connect promise so two concurrent
  callers share one spawn (R1-32)
- a failed handshake kills the spawned child instead of leaking it
  (R1-47); the kill uses the same SIGTERM→SIGKILL grace as close()
- spawnChild attaches child.once('error') so an ENOENT rejects the
  handshake race instead of crashing the daemon (R1-31)
- the real exit handler is attached only after handshake success and
  scoped to the child that owns the current connection, so a stale
  orphan's late exit cannot tear down the healthy respawn (R1-5)

Control-sequence injection (R1-2/R1-3/R1-34/R1-48):
- describeToolCall strips OSC/CSI/SS2-SS3/DCS/C0-DEL-C1 sequences
  from the command before composing the permission title (R1-2),
  shared via a new stripControlSequences helper in adaptor-utils
- classifyOption fails closed to 'other' when the structured kind is
  absent — word heuristics over free-form labels can be inverted by
  negation ("Do not allow" → proceed) on exactly the generic agents
  this PR targets (R1-3)
- an unknown stopReason uses a fixed message instead of interpolating
  the raw value into spoken/model context (R1-34)
- speak-to-user messages and agent_message_chunk text are stripped at
  the adaptor boundary (R1-48)

Session reuse (R1-4/R1-53):
- trackSession replaces a stale entry when its generation differs or
  it is closed, so a respawned agent reusing a sessionId gets a fresh
  state instead of the dead one (R1-4)
- HandleRegistry.closeSession clears the reverse mapping so the same
  backend id after restart mints a new handle, not the poisoned closed
  one (R1-53)

Queue leaks (R1-6/R1-33):
- the active array is removed entirely; waiters self-remove via detach
  when resolved by push/end/subscribe-takeover (R1-6)
- each blocked next() registers a detach callback that removes the
  abort listener from the signal when the waiter settles (R1-33)

Config/routing (R1-1/R1-8/R1-7):
- a single-entry backends config without "default": true is normalized
  to isDefault:true so BackendRegistry.defaultAdaptor does not throw
  on input its own validator declared valid (R1-1)
- QwenCodeAdaptor accepts a name option so two qwen-code backends do
  not collide in the registry's byName map or scoped-id keys (R1-8)
- the injector's permission enqueue/retract uses scoped
  ${backend.adaptor}:${requestId} keys, matching the broker's
  convention, so two backends minting the same requestId cannot
  retract each other's spoken asks (R1-7)

Also fixes R1-9: the steering e2e captures the fromIndex baseline
before releasing the slow gate, matching the M2 test's ordering.

45 Suggestion findings declined (test coverage / dead code / defensive
hardening / behavior preferences) with one-line reasons on each thread.

* fix(qwen-live): address review round 2 — handle name propagation, steering fromIndex

- R1-8 (fix-induced): QwenCodeAdaptor's createSession and
  listSessions still minted handles with the hardcoded ADAPTOR_NAME
  instead of the configured this.name — two qwen-code backends with
  different names produced colliding adaptor fields in handles,
  breaking jobByRef/scopedRequestId isolation. Both sites now use
  this.name so the handle's adaptor field matches the registry key.
- R1-9: the steering e2e captured the fromIndex baseline AFTER
  slowGate.resolve(), but the slow turn's completion fires
  immediately on release — it was already below the baseline and the
  waitForMessage(fromIndex) could never match it. Moved the capture
  before the release, matching the M2 test's ordering.

R2 verdicts (5 Critical, 7 Suggestion):
- R1-5: valid fix, no further change — the connect-path onExit is
  inherently scoped (the test fake controls the lifecycle); the spawn
  path's healthyChild identity check is the correct guard
- R2-1: declined — an already_resolved outcome means the vote was a
  no-op; relaying the note to a vote that didn't count would be wrong
- R2-9: declined (re-graded to Suggestion) — /tmp in lifecycle tests
  is test quality, not a failure path this PR introduced
- 7 Suggestions: declined — R1 fix witnesses, abort-while-draining,
  closed sessions in listSessions, docblock update — all test coverage
  or cosmetic on working code

---------

Co-authored-by: Claude <[email protected]>
2026-09-02 02:50:03 +00:00
8290c81eea feat(qwen-live): standalone voice daemon package — M1 + M2 (#10367)
* feat(qwen-live): scaffold standalone live daemon package with ported host stack

New workspace package @qwen-code/qwen-live (M1+M2 of the Live split,
issue #10118). This commit adds the package skeleton and the verbatim
ports that need no behavioral changes:

- host/: Live Host protocol v6 types, LiveHostCoordinator, and the
  discovery-file writer, ported from packages/cli/src/serve/live/
  together with their test suites (51 tests green)
- realtime/socket.ts, realtime/sanitize.ts: the two helper symbols the
  realtime client depends on, extracted from cli ui utils
- registered in scripts/build.js buildOrder and root vitest projects

* feat(qwen-live): generalize the realtime client tool surface

Port qwen-realtime-session.ts and adapt it for the orchestration model:
tools and instructions come from config, every declared tool dispatches
through onFunctionCall with the raw argument payload, and outputs are
receipt-style via submitFunctionOutput. The single-handoff machinery
(multiple_handoffs error, steering acknowledgement, open-call complete)
is removed; transport, response arbitration, pendingCalls timing,
barge-in, and defensive validation are unchanged from the port.
capturesTranscript marks handoff-style tools that capture the live
transcript tail at call time.

Test suite adapted: 27 tests green.

* feat(qwen-live): add the BackendAdaptor seam and the qwen serve adaptor

BackendAdaptor is the capability-bit-driven port between the live
orchestrator and a coding-agent backend. QwenCodeAdaptor drives a
running qwen serve daemon through the @qwen-code/sdk DaemonClient:
202-admission prompts, SSE event normalization (chunks and tool calls
live under session_update.data.update), reject-if-idle mid-turn
steering with a plain-prompt fallback, nested permission votes with
voter attribution, and attachment-backed image blocks. 23 unit tests
against an injected fake client.

* feat(qwen-live): orchestrator, tools, injector, permissions, and daemon entry

The M1+M2 core:
- seven receipt-style dispatch tools plus remain_silent, with a
  dispatcher that answers every call (timeout fallback included) and
  session_N/job_N/asset_N handle registries
- LiveSession wires Host audio, the realtime connection, tool handlers
  (appshot via the Host capture channel, handoff with voice-context
  packing and image attachments, busy-session steering), backend event
  pumps, and a bounded graceful-stop drain
- Injector: safe-insertion window (VAD + response-in-flight + playback
  estimate at 48 kB/s with a quiet gap), batch aggregation, per-job
  progress throttling, barge-in drops progress, spoken/detail split
- PermissionBroker: allow_always stays a one-shot protocol vote backed
  by a local TTL rule; remote resolutions retract queued asks
- LiveDaemon: Host WS endpoint (Bearer + Origin-reject), discovery
  publish with fail-fast mutual exclusion and dead-owner handoff,
  appshot channel marked ready (the in-process capture path needs no
  separate verification), SIGINT/SIGTERM shutdown
- SessionLog: JSONL with synchronous rotation (rename-before-reopen)

116 new unit tests; package total 167 green.

* feat(qwen-live): end-to-end test suites against a real qwen serve daemon

Test stack: a real qwen serve (spawned CLI bundle, fake-openai model
endpoint), a scriptable fake DashScope realtime WebSocket server, the
qwen-live daemon as a child process, and a protocol-v6 FakeHost client
— every boundary is the real wire.

Scenarios (11 tests): discovery publish/remove and hello/welcome,
toggle brings up the realtime session (tool schema + instructions
asserted on session.update), two-way audio byte fidelity, the full
handoff loop against real serve with [COMPLETE] injection and spoken
summary, injection-window hold/batch semantics, permission ask/vote
relay, and busy-session steering tiers.

The suites caught a real bug the unit layer could not: per-session
calls must echo the daemon-issued clientId from the create/attach
response (a self-made id is rejected by the client registration guard
and broke handoff admission and vote attribution). QwenCodeAdaptor now
tracks and echoes the issued id per session.

* chore(release): keep @qwen-code/qwen-live versioned independently

Same treatment as sdk/mobile-mcp/node-repl: the incubating package
stays at its own 0.x version instead of lock-stepping with the CLI
release train. The verbatim guard in package-scripts.test.js is
updated alongside.

* fix(qwen-live): harden review findings across dispatcher, injector, and permission relay

Self-review fixes bundled after the adversarial pass:

- adaptor: classify snake_case permission option ids (proceed_once) —
  the word-boundary regex never matched through "_", so an allow vote
  silently degraded to a cancelled outcome and serve dropped the turn
- adaptor: detect the pending-prompt limit by error name as well as
  status, and fall image-bearing steers back to a regular prompt
- dispatcher: a timed-out tool now answers with a pending receipt that
  tells the model not to retry (the handler keeps running)
- injector: permission items flush ahead of the size-capped context
  join; spoken lines merge whole-line greedily under the 280-char cap
- live-session: resubscribe the event pump with backoff until
  session_closed; a rejected permission chain can no longer become an
  unhandled rejection; receipts are clamped and never empty
- permission broker: a failed auto-approval falls back to the spoken
  ask instead of losing the request
- daemon/config/index: appshot readiness is declared at construction,
  numeric config ports parse, stray rejections are logged not fatal
- e2e harness: QWEN_LIVE_E2E_KEEP=1 keeps the temp dirs for post-mortem

* fix(qwen-live): address review round 1 — permission voting, shutdown, discovery reclaim, and hardening

Permission trust boundary:
- classify options by the wire's structured kind enum (allow_once/
  allow_always/reject_*) instead of word regexes over snake_case ids,
  and vote the least-escalating match — a spoken "allow" now lands on
  proceed_once, never proceed_always_project
- respond_permission's note is no longer discarded: it is logged and
  relayed to the backend session as a follow-up user instruction
- track own votes per session so byUs attribution works on adopted
  sessions; narrow allow_always standing rules to the exact normalized
  command instead of a two-token prefix

Lifecycle:
- daemon stop() no longer hangs when SIGINT lands before listen() or
  when a Host redials during shutdown (stopping gate + client terminate)
- dead-owner discovery reclaim now follows the cli contract (no-op
  commitOwner during handoff, republish after) — previously it could
  never succeed and a crash left the daemon unable to start
- stop drain settles on the input-commit ack instead of burning the
  full 30s budget; response.created during the drain no longer flips
  the call back to speaking; a host-initiated start can no longer race
  deactivate()

Robustness:
- a late ASR final for an already-consumed input item is ignored as
  stale_input instead of dropping the call
- raise the Host frame cap to cover a conformant CJK accessibility
  dump (64KiB frame cap contradicted the 32k-char field cap)
- bin entry works through npm's bin symlink (realpath + pathToFileURL)
- steered handoffs that join a running turn reuse the existing job
  instead of orphaning it; over-long steers fall back to a queued
  prompt; session_list unions created cwds, requests a full page, and
  maps hasActivePrompt for untracked sessions; created-session labels
  are applied via session metadata
- injector: jobless throttle keys use the full context, dedup is
  symmetric, and barge-in drops clear the throttle stamp; sentence
  shapers handle CJK punctuation and mid-token periods
- config loader: ENOENT-only swallow, BOM strip, ~ expansion, typed
  port validation, source-accurate errors; session log close() is
  terminal; transcript retention is capped

Tests and docs: new config/daemon/sanitize suites, win32 guards
restored on discovery mode assertions, protocol parity test now checks
both type copies, qwen-live e2e suites join the no-AK PR gate, fake
model gating in the inject e2e matches only the current turn and is
consumed on match, FakeHost answers capture requests, lockfile diff
reduced to qwen-live-only entries, README states the installer gap.

* test(qwen-live): pin injector size caps and steered-message delivery

- injector: 6000-char combined-context cap, whole-line spoken join at
  the 280-char boundary, single-line ellipsis truncation
- steering e2e: assert the joined instruction actually reached the
  model endpoint, not just the receipt wording

* fix(sdk): raise daemon browser bundle budget

Co-authored-by: Qwen-Coder <[email protected]>

* feat(qwen-live): scaffold standalone live daemon package with ported host stack

New workspace package @qwen-code/qwen-live (M1+M2 of the Live split,
issue #10118). This commit adds the package skeleton and the verbatim
ports that need no behavioral changes:

- host/: Live Host protocol v6 types, LiveHostCoordinator, and the
  discovery-file writer, ported from packages/cli/src/serve/live/
  together with their test suites (51 tests green)
- realtime/socket.ts, realtime/sanitize.ts: the two helper symbols the
  realtime client depends on, extracted from cli ui utils
- registered in scripts/build.js buildOrder and root vitest projects

* feat(qwen-live): generalize the realtime client tool surface

Port qwen-realtime-session.ts and adapt it for the orchestration model:
tools and instructions come from config, every declared tool dispatches
through onFunctionCall with the raw argument payload, and outputs are
receipt-style via submitFunctionOutput. The single-handoff machinery
(multiple_handoffs error, steering acknowledgement, open-call complete)
is removed; transport, response arbitration, pendingCalls timing,
barge-in, and defensive validation are unchanged from the port.
capturesTranscript marks handoff-style tools that capture the live
transcript tail at call time.

Test suite adapted: 27 tests green.

* feat(qwen-live): add the BackendAdaptor seam and the qwen serve adaptor

BackendAdaptor is the capability-bit-driven port between the live
orchestrator and a coding-agent backend. QwenCodeAdaptor drives a
running qwen serve daemon through the @qwen-code/sdk DaemonClient:
202-admission prompts, SSE event normalization (chunks and tool calls
live under session_update.data.update), reject-if-idle mid-turn
steering with a plain-prompt fallback, nested permission votes with
voter attribution, and attachment-backed image blocks. 23 unit tests
against an injected fake client.

* feat(qwen-live): orchestrator, tools, injector, permissions, and daemon entry

The M1+M2 core:
- seven receipt-style dispatch tools plus remain_silent, with a
  dispatcher that answers every call (timeout fallback included) and
  session_N/job_N/asset_N handle registries
- LiveSession wires Host audio, the realtime connection, tool handlers
  (appshot via the Host capture channel, handoff with voice-context
  packing and image attachments, busy-session steering), backend event
  pumps, and a bounded graceful-stop drain
- Injector: safe-insertion window (VAD + response-in-flight + playback
  estimate at 48 kB/s with a quiet gap), batch aggregation, per-job
  progress throttling, barge-in drops progress, spoken/detail split
- PermissionBroker: allow_always stays a one-shot protocol vote backed
  by a local TTL rule; remote resolutions retract queued asks
- LiveDaemon: Host WS endpoint (Bearer + Origin-reject), discovery
  publish with fail-fast mutual exclusion and dead-owner handoff,
  appshot channel marked ready (the in-process capture path needs no
  separate verification), SIGINT/SIGTERM shutdown
- SessionLog: JSONL with synchronous rotation (rename-before-reopen)

116 new unit tests; package total 167 green.

* feat(qwen-live): end-to-end test suites against a real qwen serve daemon

Test stack: a real qwen serve (spawned CLI bundle, fake-openai model
endpoint), a scriptable fake DashScope realtime WebSocket server, the
qwen-live daemon as a child process, and a protocol-v6 FakeHost client
— every boundary is the real wire.

Scenarios (11 tests): discovery publish/remove and hello/welcome,
toggle brings up the realtime session (tool schema + instructions
asserted on session.update), two-way audio byte fidelity, the full
handoff loop against real serve with [COMPLETE] injection and spoken
summary, injection-window hold/batch semantics, permission ask/vote
relay, and busy-session steering tiers.

The suites caught a real bug the unit layer could not: per-session
calls must echo the daemon-issued clientId from the create/attach
response (a self-made id is rejected by the client registration guard
and broke handoff admission and vote attribution). QwenCodeAdaptor now
tracks and echoes the issued id per session.

* chore(release): keep @qwen-code/qwen-live versioned independently

Same treatment as sdk/mobile-mcp/node-repl: the incubating package
stays at its own 0.x version instead of lock-stepping with the CLI
release train. The verbatim guard in package-scripts.test.js is
updated alongside.

* fix(qwen-live): harden review findings across dispatcher, injector, and permission relay

Self-review fixes bundled after the adversarial pass:

- adaptor: classify snake_case permission option ids (proceed_once) —
  the word-boundary regex never matched through "_", so an allow vote
  silently degraded to a cancelled outcome and serve dropped the turn
- adaptor: detect the pending-prompt limit by error name as well as
  status, and fall image-bearing steers back to a regular prompt
- dispatcher: a timed-out tool now answers with a pending receipt that
  tells the model not to retry (the handler keeps running)
- injector: permission items flush ahead of the size-capped context
  join; spoken lines merge whole-line greedily under the 280-char cap
- live-session: resubscribe the event pump with backoff until
  session_closed; a rejected permission chain can no longer become an
  unhandled rejection; receipts are clamped and never empty
- permission broker: a failed auto-approval falls back to the spoken
  ask instead of losing the request
- daemon/config/index: appshot readiness is declared at construction,
  numeric config ports parse, stray rejections are logged not fatal
- e2e harness: QWEN_LIVE_E2E_KEEP=1 keeps the temp dirs for post-mortem

* fix(qwen-live): address review round 1 — permission voting, shutdown, discovery reclaim, and hardening

Permission trust boundary:
- classify options by the wire's structured kind enum (allow_once/
  allow_always/reject_*) instead of word regexes over snake_case ids,
  and vote the least-escalating match — a spoken "allow" now lands on
  proceed_once, never proceed_always_project
- respond_permission's note is no longer discarded: it is logged and
  relayed to the backend session as a follow-up user instruction
- track own votes per session so byUs attribution works on adopted
  sessions; narrow allow_always standing rules to the exact normalized
  command instead of a two-token prefix

Lifecycle:
- daemon stop() no longer hangs when SIGINT lands before listen() or
  when a Host redials during shutdown (stopping gate + client terminate)
- dead-owner discovery reclaim now follows the cli contract (no-op
  commitOwner during handoff, republish after) — previously it could
  never succeed and a crash left the daemon unable to start
- stop drain settles on the input-commit ack instead of burning the
  full 30s budget; response.created during the drain no longer flips
  the call back to speaking; a host-initiated start can no longer race
  deactivate()

Robustness:
- a late ASR final for an already-consumed input item is ignored as
  stale_input instead of dropping the call
- raise the Host frame cap to cover a conformant CJK accessibility
  dump (64KiB frame cap contradicted the 32k-char field cap)
- bin entry works through npm's bin symlink (realpath + pathToFileURL)
- steered handoffs that join a running turn reuse the existing job
  instead of orphaning it; over-long steers fall back to a queued
  prompt; session_list unions created cwds, requests a full page, and
  maps hasActivePrompt for untracked sessions; created-session labels
  are applied via session metadata
- injector: jobless throttle keys use the full context, dedup is
  symmetric, and barge-in drops clear the throttle stamp; sentence
  shapers handle CJK punctuation and mid-token periods
- config loader: ENOENT-only swallow, BOM strip, ~ expansion, typed
  port validation, source-accurate errors; session log close() is
  terminal; transcript retention is capped

Tests and docs: new config/daemon/sanitize suites, win32 guards
restored on discovery mode assertions, protocol parity test now checks
both type copies, qwen-live e2e suites join the no-AK PR gate, fake
model gating in the inject e2e matches only the current turn and is
consumed on match, FakeHost answers capture requests, lockfile diff
reduced to qwen-live-only entries, README states the installer gap.

* test(qwen-live): pin injector size caps and steered-message delivery

- injector: 6000-char combined-context cap, whole-line spoken join at
  the 280-char boundary, single-line ellipsis truncation
- steering e2e: assert the joined instruction actually reached the
  model endpoint, not just the receipt wording

* feat(qwen-live): port the Live Host installer — M1 gap closed

`live-host-installer.ts` moves verbatim from
packages/cli/src/serve/live/ (same OSS/GitHub dual-source manifest
fetch, sha256 + codesign + team-identifier verification, staging +
backup install with rollback, and the retryable status machine), with
its 11-test suite. The daemon gains the matching HTTP surface:

- GET /live/setup — installed Host state
- POST /live/setup/install — download + verify + install the latest
  POST /live/setup/launch — open the installed app

All three sit behind the daemon's existing Bearer wall (a browser
context sending Origin is always refused, wrong token 401) — pinned by
a new daemon test; unknown paths stay 404 and GET /healthz answers
plain 200. Bootstrap no longer depends on the built-in qwen-code Live
integration having installed the Host first.

* fix(qwen-live): attach the appshot-timeout rejection handler before advancing fake timers

The timeout rejects synchronously inside advanceTimersByTimeAsync; a
handler attached only afterwards leaves an unhandled rejection for an
instant, which vitest counts as a run-level error (CI fails the
package with Errors: 1 even though all 250 tests pass). Attach the
expectation first, await it after the clock advances.

* test(qwen-live): sync the no-AK gate pin with the qwen-live suites

The no-ak-integration-ci pin (added upstream after our branch point)
asserts the gate's file list verbatim; add the four qwen-live suites
the gate has been running since they joined it.

* fix(qwen-live): address review round 2 — trust-boundary keys, backpressure, session lifecycle

Criticals (all probed and reproduced before fixing):

- broker titleKeyOf: split on the FIRST colon and keep the entire
  remainder (commands routinely contain colons — a truncated key
  auto-approved a different -m message), and stop case-folding
  (/a vs /A are different paths on Linux). The whitespace-collapse
  pin is preserved
- broker deny: an explicit spoken refusal now revokes the standing
  rule that matched the same request — the silent auto-answer's
  fallback path could otherwise be overridden by the invisible rule
  on the next identical request
- adaptor activeJobRef: a busy-session fallback (image steer, over-
  long text 400) no longer overwrites the running turn's ref with the
  queued prompt's id — later steer receipts report the right job
- adaptor byUs attribution: the daemon's originator stamp is now
  authoritative when present; ownVotes only covers anonymous
  resolutions. Another client resolving during our vote round-trip no
  longer reads as our own answer (R1-2 regression, fixed test-first)
- adaptor permission titles: control sequences stripped before the
  title reaches speech or keys a standing rule (whole command kept —
  no first-line cut, unlike the progress path)
- live-session pushAudio: the provider's backpressure signal is
  propagated (the port source fails the call) and onAudioDropped is
  wired to failCall — a socket over its buffer cap no longer silently
  garbles the utterance; the deliberate true for stale/connecting
  frames is untouched
- handles: closed sessions stop resolving entirely (registry-level),
  and session_closed clears the call's default handle so a session-
  less handoff rebuilds instead of retrying a dead target forever
- handles: read-site reconciliation — session_list and session_monitor
  transition a session's stale non-terminal jobs to done when the
  backend itself reports idle (a turn_complete emitted while no pump
  was subscribed no longer reports a running active_job forever)

Suggestions on this PR's new surfaces:

- live-host.yml: packages/qwen-live/src/host/** joins the path filter
  so the parity-test lane runs on qwen-live-only PRs instead of first
  failing in the merge queue
- installer: the install route passes force=true (the recovery path
  for a corrupted-but-present app, matching the CLI mirror), and
  launch() gains the non-darwin guard in BOTH copies so the byte
  parity pin stays green
- index: directory-form invocation (node packages/qwen-live) now
  runs main() instead of exiting 0 silently
- realtime: the dead lastHandoffEntryCount machinery is removed
  (truncate-and-reset replaced the cursor it pretended to track)

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: 易良 <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-09-01 06:37:23 +00:00