qwen-code/docs/developers
jinye 6f7a48936f
feat(serve): approval / tools / init / MCP-restart mutation routes (#4175 Wave 4 PR 17) (#4282)
* feat(core): introduce TrustGateError for setApprovalMode (#4175 Wave 4 PR 17)

Adds a named subclass `TrustGateError` thrown by `Config.setApprovalMode`
when the requested mode would grant privileged tool autonomy in a folder
the user has not marked as trusted. Daemon mutation routes can now
recognize this rejection class without depending on message text.

Extends `mapDomainErrorToErrorKind` in `packages/cli/src/serve/status.ts`
to map `TrustGateError → 'auth_env_error'`. Matches by `err.name` rather
than `instanceof` because cross-package bundling can produce duplicate
class instances where `instanceof` returns false. Test covers both the
real class and a name-synthesized instance.

Foundation for the `POST /session/:id/approval-mode` route landing in a
follow-up commit in this PR.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* feat(core): add disabledTools workspace setting (#4175 Wave 4 PR 17)

Introduces a per-workspace skip-registration mechanism for tool names,
distinct from `permissions.deny` (which keeps the tool registered and
blocks invocation). Tools listed in `disabledTools` are not registered
at all and never appear in `/tools`, `getAllTools()`, or function-call
discovery — both built-ins and MCP-discovered tools flow through
`ToolRegistry.registerTool` / `registerFactory`, so gating there covers
every registration path.

- `ConfigParameters.disabledTools?: string[]` (frozen into a `ReadonlySet`
  at Config construction; queried via `Config.getDisabledTools()`)
- `ToolRegistry.registerTool` and `ToolRegistry.registerFactory` skip
  when the tool name is in the disabled set, with a debug log line
- New `settings.tools.disabled: string[]` (UNION merge across scopes),
  wired from `loadCliConfig` into ConfigParameters
- Tests pin the contract: skip at register, lazy factory skip, and the
  "next refresh" semantic (already-registered tools are unaffected by a
  subsequent toggle — the disabled set is consulted at register time,
  not at lookup time)

Foundation for the `POST /workspace/tools/:name/enable` route in a
follow-up commit; the bridge will write the settings file directly,
and the next ACP child spawn will pick up the change.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* feat(serve): add session approval-mode mutation route (#4175 Wave 4 PR 17)

Adds POST /session/:id/approval-mode — the first strict-gated session
mutation surface introduced in Wave 4 alongside PR 16 / PR 21. Remote
clients can switch a live session's approval mode (plan / default /
auto-edit / yolo) without touching the user's host CLI.

Routing:
- Route handler validates `mode` against the closed `APPROVAL_MODES`
  enum and an optional `persist: boolean` flag (400 on either)
- Bridge `setSessionApprovalMode` forwards through the new
  `qwen/control/session/approval_mode` ACP extMethod (introduced in a
  new `SERVE_CONTROL_EXT_METHODS` namespace) so the change lands inside
  the ACP child's per-session `Config`
- `persist: true` writes `tools.approvalMode` to workspace settings via
  a new `BridgeOptions.persistApprovalMode` callback wired in
  `runQwenServe`. Default is ephemeral so a remote caller does not
  pollute the user's host settings unless asked

Trust gate translation:
- ACP child catches `TrustGateError` from `Config.setApprovalMode` and
  re-raises as a JSON-RPC error with `data.errorKind: 'trust_gate'`
- Bridge detects the structured payload and re-instantiates the typed
  `TrustGateError` (since the class name does not survive the wire)
- `sendBridgeError` translates to HTTP 403 with the closed PR-13
  `errorKind: 'auth_env_error'` taxonomy

SDK additions:
- `DaemonClient.setSessionApprovalMode(sessionId, mode, opts?, clientId?)`
  mirrors the route shape and forwards `X-Qwen-Client-Id`
- New `DaemonApprovalMode` literal union and `DAEMON_APPROVAL_MODES`
  const tuple; `DaemonApprovalModeResult` for the route response
- New `approval_mode_changed` typed event on `DaemonControlEvent`,
  reducer integration on `DaemonSessionViewState`
  (`approvalMode` / `approvalModeChangedCount` / `lastApprovalModeChange`)
- Drift detector `approvalMode.test.ts` walks core's `ApprovalMode`
  enum and fails CI if `APPROVAL_MODES` or `DAEMON_APPROVAL_MODES`
  drift in either direction

New capability tag `session_approval_mode_control` (always-on, since v1).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* feat(serve): add workspace tool toggle route (#4175 Wave 4 PR 17)

Adds POST /workspace/tools/:name/enable — strict-gated mutation route
that toggles a tool name in the workspace's `tools.disabled` settings
list. Pure file IO + workspace-scoped event fan-out; no ACP roundtrip.

- Bridge `setWorkspaceToolEnabled(toolName, enabled, originatorClientId)`
  invokes the new `BridgeOptions.persistDisabledTools` callback. The
  default `runQwenServe` wires it to `loadSettings(workspace).setValue(
  'tools.disabled', merged)` with a fresh load on each call so concurrent
  edits from other writers stay safe across the read/modify/write window
- New private `broadcastWorkspaceEvent` helper fan-outs to every live
  session SSE bus, swallowing per-bus errors so a single torn-down
  session can't block its peers. Naming mirrors PR 21 #4255 (the post-
  PR-16 fold-in will collapse the two helpers)
- Unknown tool names are accepted: the daemon has no authoritative tool
  registry to validate against (built-ins live inside the ACP child,
  MCP tools are discovered post-spawn). Pre-disabling a not-yet-installed
  MCP tool is a legitimate use case
- Live ACP children retain already-registered tools — the toggle takes
  effect on the next ACP child spawn (`tools.disabled` is consulted at
  Config construction time, gated in ToolRegistry.registerTool by PR 17
  commit 2)

SDK additions:
- `DaemonClient.setWorkspaceToolEnabled(toolName, enabled, clientId?)`
  with URL-encoded tool name
- `DaemonToolToggleResult` + `DaemonToolToggledEvent` typed event,
  reducer integration on `DaemonSessionViewState` (`toolToggleCount` /
  `lastToolToggle`)
- `asKnownDaemonEvent` runtime guard for `tool_toggled` AND
  `approval_mode_changed` (the latter was missed in commit 3 — without
  this entry the events were silently filed as `unrecognizedKnownEvent`
  by `reduceDaemonSessionEvent`, never reaching the typed reducer cases)

New capability tag `workspace_tool_toggle` (always-on, since v1).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* feat(serve): add workspace init route (#4175 Wave 4 PR 17)

Adds POST /workspace/init — strict-gated mutation route that scaffolds
an empty `QWEN.md` (or whatever `getCurrentGeminiMdFilename()` returns
under `--memory-file-name` overrides) at the daemon's bound workspace
root. Mechanical only — does NOT invoke the LLM. Clients that want
AI-driven content fill should follow up with POST /session/:id/prompt.

Behavior:
- Default refuses to overwrite when the target file exists with non-
  whitespace content; the bridge throws `WorkspaceInitConflictError`
  which the route translates to HTTP 409 `workspace_init_conflict`
  with the resolved path + size in the body
- `body: {force: true}` overwrites unconditionally; response carries
  `action: 'overwrote'` vs `'created'` so SDK consumers can render
  the difference
- Whitespace-only existing content is treated as absent (no 409),
  matching the local `/init` slash command's behavior so a half-
  broken init left with an empty file doesn't trap the user
- Pure file IO + workspace-scoped event fan-out — no ACP roundtrip;
  works regardless of whether an ACP child is alive
- Fan-outs `workspace_initialized` event with `{path, action}` to
  every live session SSE bus via the `broadcastWorkspaceEvent`
  helper introduced in commit 4

SDK additions:
- `DaemonClient.initWorkspace(opts?, clientId?)` with conditional
  body emission (omits `force` unless explicitly true so older
  daemons that reject unknown body fields stay compatible)
- `DaemonInitWorkspaceResult` + `DaemonWorkspaceInitializedEvent`
  typed event with runtime guard (`isWorkspaceInitializedData`),
  reducer integration on `DaemonSessionViewState`
  (`workspaceInitCount` / `lastWorkspaceInit`)

New typed error class `WorkspaceInitConflictError` exported from
`packages/cli/src/serve/index.ts` so direct embeds can match it via
`instanceof`.

New capability tag `workspace_init` (always-on, since v1).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* feat(serve): add MCP server restart route with budget guard (#4175 Wave 4 PR 17)

Adds POST /workspace/mcp/:server/restart — strict-gated mutation
route that performs a single-server MCP restart through the ACP
child's `McpClientManager.discoverMcpToolsForServer`. Pre-checks the
live budget snapshot from PR 14 v1 (#4247) so a restart on a
budget-saturated workspace returns a soft refusal rather than
triggering a `BudgetExhaustedError` cascade through the discovery
loop.

Decision logic (ACP-side, in `qwen/control/workspace/mcp/restart`
extMethod):
- Server not in `getMcpServers()` → JSON-RPC `resourceNotFound` →
  HTTP 404
- Server in `excludedMcpServers` → 200 with `{skipped:true,
  reason:'disabled'}`
- `manager.isServerDiscovering(name)` → 200 with `{reason:'in_flight'}`
- Mode is `enforce`, server not in `reservedSlots`, total ≥ budget →
  200 with `{reason:'budget_would_exceed'}`
- Otherwise: `discoverMcpToolsForServer(name, config)`, return
  `{restarted:true, durationMs}`

Soft refusals still return 200 because the route understood the
request and reached a deterministic answer about why no restart
happened. Only hard "we cannot answer" cases (unknown server, no
live ACP child) escalate to non-2xx. This mirrors PR 14 v1's
discovery-time refusal contract: refusals don't throw, they get
recorded.

Bridge:
- New `restartMcpServer(serverName, originatorClientId)` forwards
  through the new `SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart`
  extMethod against the live `liveChannelInfo()` channel
- Throws `SessionNotFoundError` (mapped to HTTP 404) when no ACP
  child is alive — restart inherently requires a live
  `McpClientManager` instance
- Fan-outs `mcp_server_restarted` (success) or
  `mcp_server_restart_refused` (skip) to every live session SSE bus

Core:
- New public `McpClientManager.isServerDiscovering(serverName):
  boolean` — reads `serverDiscoveryPromises.has(name)` so the
  daemon can short-circuit a redundant restart with
  `skipped:in_flight` instead of awaiting the original discovery
  promise (HTTP latency stays bounded)

SDK additions:
- `DaemonClient.restartMcpServer(serverName, clientId?)` with
  URL-encoded server name
- `DaemonMcpRestartResult` discriminated union, two new typed
  events (`DaemonMcpServerRestartedEvent`,
  `DaemonMcpServerRestartRefusedEvent`) with runtime guards,
  reducer integration on `DaemonSessionViewState`
  (`mcpRestartCount` / `lastMcpRestart` /
  `mcpRestartRefusedCount` / `lastMcpRestartRefused`)

New capability tag `workspace_mcp_restart` (always-on, since v1).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* docs(serve): mutation control routes protocol section (#4175 Wave 4 PR 17)

Adds a "Mutation: approval, tools, init, MCP restart" section to the
developer protocol doc covering all four PR 17 routes:

- POST /session/:id/approval-mode — `{mode, persist?}` request, four
  closed-enum modes, trust-gate 403 with `errorKind: 'auth_env_error'`,
  `approval_mode_changed` SSE event (session-scoped)
- POST /workspace/tools/:name/enable — `{enabled}` request, unknown
  names accepted, "next-spawn semantics" call-out, `tool_toggled`
  SSE event (workspace-scoped fan-out)
- POST /workspace/init — `{force?}` request, scaffold-only contract
  (no LLM call), 409 with `path` + `existingSize` body when the
  target exists with non-whitespace content, `workspace_initialized`
  SSE event (workspace-scoped)
- POST /workspace/mcp/:server/restart — empty body, soft-skip
  decision table (in_flight / disabled / budget_would_exceed),
  `mcp_server_restarted` and `mcp_server_restart_refused` SSE events

Capability list at the top of the file updated with the four new
tags (and a missing-from-PR-13 fix for `workspace_env` /
`workspace_preflight`).

User-facing `qwen-serve.md` gains a one-line "Remote runtime control"
bullet under "What it gives you" pointing to the four routes and
clarifying that `/workspace/init` is mechanical only.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(serve): fold-in 1 — wenshao + gpt-5.5 review (#4175 Wave 4 PR 17)

Addresses 5 critical / 4 high / 2 medium items from #4282 review.

CI blocker (wenshao H1)
- Move `approvalMode.test.ts` from `packages/cli/src/acp-integration/`
  to `packages/sdk-typescript/test/unit/approval-mode-drift.test.ts`.
  The CLI package has no `@qwen-code/sdk` dep and the tsconfig has no
  path mapping for it, so `tsc --build` failed `Cannot find module
  '@qwen-code/sdk'` on Lint + Test (mac/linux/windows). The SDK
  package is the right host: it already depends on
  `@qwen-code/qwen-code-core`, and the test pins the SDK ↔ core
  contract directly. Also drop the tautological
  `APPROVAL_MODES contains every ApprovalMode enum value` check —
  `APPROVAL_MODES` is defined as `Object.values(ApprovalMode)` in
  core, so that assertion can never fire.

Critical (gpt-5.5 via wenshao /review)
- C1 (`initWorkspace` path traversal): `getCurrentGeminiMdFilename()`
  is settings-controlled. A daemon configured with
  `context.fileName: "../outside.md"` could resolve outside
  `boundWorkspace` and let this strict-gated mutation create or
  truncate a file outside the workspace boundary. Resolve and verify
  the joined path stays within `boundWorkspace`; reject otherwise.
- C2 (`X-Qwen-Client-Id` forgery): the 3 workspace mutation routes
  (`/workspace/init`, `/workspace/tools/:name/enable`,
  `/workspace/mcp/:server/restart`) accepted any syntactically valid
  client id and stamped it onto fan-out events without checking
  `bridge.knownClientIds()`. Mirrors the inline validation pattern
  PR 16 already uses for `/workspace/memory` and
  `/workspace/agents`. Add `parseAndValidateWorkspaceClientId` shared
  helper in `server.ts` (collapses with PR 16's pattern when the
  Wave-4-wide DRY refactor lands).
- C3 (MCP restart budget under-count): the pre-check used
  `accounting.total >= budget`, but enforce-mode capacity is
  reserved by `tryReserveSlot` via `reservedSlots` (which counts
  configured + in-flight + disconnected slot holders). `total` only
  counts CONNECTED, so a restart on a budget-saturated workspace
  passed the pre-check while the manager refused internally and
  the route reported `restarted: true`. Mirror the manager's policy
  by checking `reservedSlots.length`.
- C4 (false `restarted: true` on broken MCP):
  `discoverMcpToolsForServer` catches reconnect/discovery errors
  internally (logs and resolves void), so the route reported
  `restarted: true` while the server stayed disconnected. After the
  call, verify the live `getMCPServerStatus(name)` is
  `MCPServerStatus.CONNECTED`; throw a structured JSON-RPC error
  otherwise. New typed bridge error `McpServerRestartFailedError`
  → HTTP 502 with `errorKind: 'protocol_error'`.
- C5 (unknown MCP server falls through as 500): the agent-side
  `RequestError.resourceNotFound` was not specially handled by
  `sendBridgeError`, so a typo in the server name returned 500
  indistinguishable from an internal daemon failure. Re-raise with
  structured `data.errorKind: 'mcp_server_not_found'`; bridge
  re-instantiates as `McpServerNotFoundError`; route maps to a
  stable 404 with `code: 'mcp_server_not_found'` and `serverName`
  in the body.

High (wenshao)
- H2 (`persistDisabledTools` scope leak): the callback read
  `fresh.merged.tools?.disabled` (UNION across System / SystemDefaults
  / User / Workspace) and wrote the result back into
  `SettingScope.Workspace`, copying entries from higher scopes into
  the workspace file on the first toggle. Subsequent removals at the
  originating scope (e.g. User) would no longer take effect. Read
  from the WORKSPACE-scope `LoadedSettings` only via
  `fresh.forScope(SettingScope.Workspace).settings.tools?.disabled`.
- H3 (silent persist no-op): `setSessionApprovalMode` with
  `persist: true` returned HTTP 200 + `persisted: false` when no
  `persistApprovalMode` callback was wired, indistinguishable from
  "hook ran but failed" or genuine `persisted: true`. Throw
  asymmetrically with the sibling `setWorkspaceToolEnabled` (which
  already throws in the same situation).
- H4 (whitespace-only init clobber): `/workspace/init` overwrote a
  whitespace-only `QWEN.md` with `action: 'created'` despite `force`
  not being passed, destroying the user's whitespace content
  (template, half-written init, intentional newline) without a
  signal. Treat existing-and-whitespace-only as a no-op; return
  `action: 'noop'` and skip the write. Adds `'noop'` to the
  discriminator union on `DaemonInitWorkspaceResult` and the
  `workspace_initialized` event payload.

Medium
- M1 (SDK `clientId` position consistency): the four new mutation
  helpers placed `clientId` inconsistently (4th vs 3rd vs 2nd). Fold
  `clientId` into the trailing options bag for all four. Matches
  the existing `context: { clientId }` argument the bridge layer
  already uses internally; reduces caller boilerplate for callers
  that always stamp clientId for audit.
- M2 (dead `instanceof String` branch): drop the no-op
  `instanceof String` clause in `setSessionApprovalMode`'s wire-error
  reconstruction — `Error.message` is always a primitive string.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* chore(vscode): regenerate settings.schema.json for tools.disabled (#4175 PR 17 fold-in)

Picked up by `Check settings schema is up-to-date` lint step (the only
red CI step on `3f63ad435`). PR 17 commit 2 added `tools.disabled` to
`packages/cli/src/config/settingsSchema.ts` but didn't run
`npm run generate:settings-schema`, so the JSON-schema mirror used by
the VSCode IDE companion drifted. Regenerating now picks up the new
entry verbatim — no behavior change.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(serve): fold-in 2 — gpt-5.5 + deepseek review (#4175 Wave 4 PR 17)

Addresses 3 critical / 3 suggestion items from #4282 round-2 review.

Critical (gpt-5.5)
- CV1 (`initWorkspace` symlink escape): the textual `withinWorkspace`
  check on the joined path doesn't see through symlinks. A `QWEN.md`
  symlink inside the workspace pointing outside it would still get
  followed by `fs.readFile` / `writeFile`; under `force: true` the
  route would truncate the external target, and a dangling symlink
  could create outside the workspace. Add an `lstat(target)` check
  before the read/write and reject when `isSymbolicLink()`. The
  proper long-term fix routes through PR 18's `WorkspaceFileSystem`
  boundary (chain-aware resolution + audit hooks); tracked under
  the SV2 TODO comment below.
- CV2 (MCP restart timeout vs MCP discovery deadline): bridge raced
  against `initTimeoutMs` (10s) but `McpClientManager`'s per-server
  discovery deadline can be up to 5 minutes
  (`MAX_DISCOVERY_TIMEOUT_MS = 300_000`). A valid restart returned
  HTTP timeout to the client while the ACP child kept reconnecting
  in the background, leaving daemon and client state divergent. Add
  a dedicated `MCP_RESTART_TIMEOUT_MS = 300_000` constant and use it
  for the bridge race. The bridge race remains a safety net against
  a wedged ACP channel; per-server discovery deadlines stay owned
  by the manager.
- CV3 (`disabledTools` rename ordering bug): the gate ran on
  `tool.name` BEFORE the MCP collision-rename branch. An MCP tool
  that collided with a lazy factory and got renamed via
  `asFullyQualifiedTool()` (e.g. `structured_output` →
  `mcp__rogue-server__structured_output`) bypassed the disabled set
  if the operator disabled the renamed-and-exposed name. Re-check
  `isToolDisabled` after the rename, before inserting into
  `this.tools`. New regression test pins the contract.

Suggestion
- SV1 (deepseek): cap `:name` path parameter at 256 chars so an
  extremely long tool name can't bloat the workspace settings file.
  Mirrors `MAX_CLIENT_ID_LENGTH = 128` and `MAX_WORKSPACE_PATH_LENGTH
  = 4096` siblings.
- SV2 (deepseek): `initWorkspace` uses `node:fs/promises` directly
  instead of routing through `WorkspaceFileSystem`. Bridge layer
  doesn't have `fsFactory` plumbed today (PR 18 boundary is
  per-request inside `createServeApp`); a separate plumbing PR will
  hoist it into `BridgeOptions`. Added a FIXME pointing to that
  follow-up. CV1's symlink reject covers the immediate
  boundary-escape concern.
- SV3 (gpt-5.5): the daemon stamps `originatorClientId` on the SSE
  envelope, but reducer snapshots stored only `event.data`. Consumers
  of `lastApprovalModeChange` / `lastToolToggle` / `lastWorkspaceInit`
  / `lastMcpRestart{,Refused}` couldn't tell whether the mutation
  originated from themselves. New `mergeOriginator` helper copies
  the envelope's `originatorClientId` onto the stored snapshot when
  `data.originatorClientId` is unset (the daemon does not currently
  populate `data.originatorClientId`, but the field exists on the
  Data interfaces — preserve it if a future daemon version does).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(serve): fold-in 3 — gpt-5.5 round-3 review (#4175 Wave 4 PR 17)

Addresses 2 suggestion items from #4282 round-3 review (post-rebase
onto PR 21).

- C7 (`docs/developers/qwen-serve-protocol.md`): protocol doc showed
  built-in display labels (`Bash`, `Read`, `Write`) as disable-able,
  but `ToolRegistry.isToolDisabled` checks the actual registered tool
  name. The shell tool registers as `run_shell_command`, so a
  `POST /workspace/tools/Bash/enable {enabled:false}` would persist
  + emit `tool_toggled` while the next session still registers
  `run_shell_command`. Updated the doc to use the canonical registry
  name in the example body and added a ⚠️ block explaining that
  names must match the registry's exposed identifier exactly. The
  daemon route deliberately does not alias-resolve (it accepts
  unknown names for forward-looking MCP pre-disable, so any
  alias map would be incomplete).
- C8 (`packages/sdk-typescript/test/unit/daemonEvents.test.ts`): the
  5 PR 17 reducer cases (`approval_mode_changed`, `tool_toggled`,
  `workspace_initialized`, `mcp_server_restarted`,
  `mcp_server_restart_refused`) had no SDK-side coverage. Added 7
  tests covering happy-path counter + last-snapshot accumulation,
  malformed-payload rejection (rounds through
  `asKnownDaemonEvent → undefined` and increments
  `unrecognizedKnownEventCount` rather than the event-specific
  counter), all 3 refused-reason literals, the `noop` action
  literal added in fold-in 1, and the `mergeOriginator` precedence
  rule (data-level wins over envelope-level when both present).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(serve): fold-in 4 — qwen-latest review (#4175 Wave 4 PR 17)

Round-4 reviewer adoption (qwen-latest-series-invite-beta-v28):

- C1: hoist `persistApprovalMode` guard before the ACP roundtrip so a
  missing callback no longer leaves the daemon's mode shifted while the
  caller observes a 500 (httpAcpBridge.ts).
- C2: serialize `persistApprovalMode` and `persistDisabledTools` through
  a per-workspace promise chain (`withSettingsLock`) so concurrent
  toggles can't lose updates in the read-modify-write window
  (runQwenServe.ts).
- C3: trim `toolName` before persisting in `/workspace/tools/:name/enable`
  so the write path matches `loadCliConfig`'s `.trim()` on read.
  Re-validates empty-after-trim with 400 `invalid_tool_name`.
- S1: cap `serverName` at `MAX_SERVER_NAME_LENGTH=256` on
  `/workspace/mcp/:server/restart` for parity with the tool-toggle cap.
- S2: when `persist:true` succeeds, mirror `approval_mode_changed` via
  `broadcastWorkspaceEvent` so peer sessions in the same workspace
  observe the new default before their next ACP child spawn.
- S3: `'noop'` added to `FakeBridge.initWorkspaceImpl` return type.
- S5: `qwen-serve-protocol.md` action enumeration now includes
  `'noop'` and notes how the SSE event mirrors the response action.

S4 (sync IO inside async persist callbacks) is acknowledged but
deferred — `loadSettings` is the project-wide read path and the H2
fold-in already restricted us to workspace-scope-only consumption,
keeping the sync window bounded. Fully eliminating it requires
swapping `loadSettings` to async across the CLI, which is out of scope.

7 new tests:
- server.test.ts × 3: tool-name trim, whitespace-only 400, server-name
  256 cap.
- httpAcpBridge.test.ts × 4: pre-call guard ordering for persist:true
  (no callback), persist:false bypasses guard, persist:true broadcasts
  to peer sessions, persist:false stays session-scoped.

Typecheck clean across cli / sdk-typescript / core.
1599/1599 unit tests pass.
2026-05-19 00:27:39 +08:00
..
daemon-client-adapters feat(tui): add daemon adapter spike (#4202) 2026-05-18 11:22:39 +08:00
development feat(telemetry): add detailed sensitive span attributes (#4097) 2026-05-17 00:36:48 +08:00
examples feat(serve): add workspace file write/edit routes (#4175 PR20) (#4280) 2026-05-18 22:37:08 +08:00
tools feat(web-search): remove built-in web_search tool, replace with MCP-based approach (#3502) 2026-04-24 11:29:02 +08:00
_meta.ts feat(cli,sdk): qwen serve daemon (Stage 1) (#3889) 2026-05-13 14:47:47 +08:00
architecture.md docs: enhance architecture documentation and add contribution guidelines 2025-12-11 18:31:24 +08:00
channel-plugins.md feat(channels): add dispatch modes and prompt lifecycle hooks 2026-03-28 06:19:02 +00:00
contributing.md chore(deps): upgrade ink 6.2.3 → 7.0.2 + bump Node engine to 22 (#3860) 2026-05-11 17:29:50 +08:00
qwen-serve-protocol.md feat(serve): approval / tools / init / MCP-restart mutation routes (#4175 Wave 4 PR 17) (#4282) 2026-05-19 00:27:39 +08:00
roadmap.md feat(cli): improve auth dialog UX with clearer three-option layout 2026-03-01 15:22:35 +08:00
sdk-java.md Fix typo in class name (#2189) 2026-04-18 11:59:36 +08:00
sdk-python.md doc[sdk-python] Expand Python SDK usage documentation (#3995) 2026-05-12 15:27:00 +08:00
sdk-typescript.md chore(deps): upgrade ink 6.2.3 → 7.0.2 + bump Node engine to 22 (#3860) 2026-05-11 17:29:50 +08:00