From 67b804eb89e1cfcea5eb9f784378c5f2e94ace4b Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sun, 28 Jun 2026 16:19:32 +0800 Subject: [PATCH 1/2] docs(daemon): update developer docs for recent daemon PRs - Add Last-Event-ID client reconnect guide (10-event-bus, 13-sdk-daemon-client) - Add cross-connection vote routing section (04-permission-mediation) - Add new capability tags: daemon_status, workspace_permissions, workspace_trust, workspace_github_setup, workspace_voice, workspace_voice_transcription, voice_transcribe (11-capabilities-versioning) - Add new event types: trust_change_requested, github_setup_completed, extensions_changed, mid_turn_message_injected (09-event-schema) - Fix _meta.serverTimestamp source description (09-event-schema, 10-event-bus) - Fix async function* syntax in SDK example (13-sdk-daemon-client) - Sync event/capability counts across all docs (43->47 events, 67->75 tags) --- docs/developers/daemon/00-index.md | 6 +- .../daemon/04-permission-mediation.md | 109 +++++++++++++++++- docs/developers/daemon/09-event-schema.md | 48 +++++--- docs/developers/daemon/10-event-bus.md | 69 ++++++++++- .../daemon/11-capabilities-versioning.md | 24 +++- .../developers/daemon/13-sdk-daemon-client.md | 100 +++++++++++++++- docs/developers/daemon/14-cli-tui-adapter.md | 6 +- 7 files changed, 330 insertions(+), 32 deletions(-) diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index b1500761ca7..4647cd4183c 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -41,7 +41,7 @@ Pick the path that matches your goal: - [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md) - `WorkspaceMcpBudget`, modes (`off`/`warn`/`enforce`), hysteresis, refused-batch coalescing. - [`07-workspace-filesystem.md`](./07-workspace-filesystem.md) - `WorkspaceFileSystem` sandbox, path policy, audit, `BridgeFileSystem` contract. - [`08-session-lifecycle.md`](./08-session-lifecycle.md) - create / attach / load / resume, `X-Qwen-Client-Id`, heartbeat, eviction, metadata. -- [`09-event-schema.md`](./09-event-schema.md) - typed event schema v1: all 43 known event types with payloads, reducers, forward compatibility. +- [`09-event-schema.md`](./09-event-schema.md) - typed event schema v1: all 47 known event types with payloads, reducers, forward compatibility. - [`10-event-bus.md`](./10-event-bus.md) - `EventBus`, monotonic IDs, ring replay, `Last-Event-ID`, slow-client backpressure, `client_evicted`. - [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) - capability registry, protocol version, schema version, conditional advertisement. - [`12-auth-security.md`](./12-auth-security.md) - bearer middleware, host allowlist, CORS deny, mutation gate, `--require-auth`, `/health` exemption, device flow. @@ -125,8 +125,8 @@ Use these anchors when moving from the docs into the latest `main` code: | Area | Current state | Primary docs | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | HTTP routes | The route catalog lives in `qwen-serve-protocol.md`; this daemon set only references it and explains implementation ownership. | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md), [`20`](./20-quickstart-operations.md) | -| Event schema | `EVENT_SCHEMA_VERSION = 1`; 43 known event types; id-less subscriber synthetic frames; `_meta.serverTimestamp` stamped at SSE write boundary. | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) | -| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 67 registered tags; 10 conditional tags. | [`11`](./11-capabilities-versioning.md) | +| Event schema | `EVENT_SCHEMA_VERSION = 1`; 47 known event types; id-less subscriber synthetic frames; `_meta.serverTimestamp` stamped at SSE write boundary. | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) | +| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 75 registered tags; 13 conditional tags. | [`11`](./11-capabilities-versioning.md) | | Session shell | `POST /session/:id/shell` exists behind `--enable-session-shell`, bearer auth, and session-bound `X-Qwen-Client-Id`; capability tag is conditional. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md), [`20`](./20-quickstart-operations.md) | | Rate limiting | Optional per-tier HTTP rate limit is exposed by CLI flags/env and conditional capability tag. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md) | diff --git a/docs/developers/daemon/04-permission-mediation.md b/docs/developers/daemon/04-permission-mediation.md index 9ce7fbd47ba..f07a0c96e80 100644 --- a/docs/developers/daemon/04-permission-mediation.md +++ b/docs/developers/daemon/04-permission-mediation.md @@ -256,6 +256,109 @@ A future pair-token mechanism will issue a per-session secret from `POST /session` and require it on `designated` / `consensus` votes. That mechanism does not exist in v1. +## Cross-Connection Vote Routing + +### Vote delivery paths + +Permission votes can reach the bridge mediator through two independent transport paths: + +1. **ACP transport (same-connection response)**: The `permission_request` bridge event is delivered to the owning connection's session-scoped SSE/WS stream as a `session/request_permission` JSON-RPC request. The client answers with a JSON-RPC response on the same connection. The dispatcher's `resolveClientResponse` maps the connection-local JSON-RPC id back to the bridge's `requestId` and calls `bridge.respondToSessionPermission`. + +2. **REST API (cross-connection)**: Any HTTP client — including clients on a different ACP connection or with no ACP connection at all — can vote via `POST /session/:id/permission/:requestId`. The legacy `POST /permission/:requestId` route (no session in the URL) uses `peekSessionFor(requestId)` to resolve the session before delegating to the same `respondToSessionPermission` path. + +### Connection-local permission request IDs + +The ACP transport uses a two-level ID scheme to map between the wire and the bridge: + +| Layer | ID format | Scope | Purpose | +| ------------------- | ---------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------- | +| JSON-RPC message id | `_qwen_perm_N` (string, monotonic per connection) | Connection-local | Correlates the JSON-RPC request→response pair on the session stream. | +| Bridge request id | Opaque string (UUID generated by the agent/mediator) | Daemon-global | Identifies the permission request across all routes and the mediator's pending/resolved maps. | + +The bridge request id is threaded through the `_meta` vendor extension so the client can include it when voting via the REST path: + +```json +{ + "method": "session/request_permission", + "id": "_qwen_perm_3", + "params": { + "sessionId": "", + "toolCall": { "name": "shell" }, + "options": [{ "optionId": "allow", "name": "Allow" }], + "_meta": { "qwen": { "requestId": "" } } + } +} +``` + +The connection stores the mapping in `conn.pending: Map`, where `PendingClientRequest.bridgeRequestId` is the bridge-level id. + +### Vote authorization rules + +`respondToSessionPermission(sessionId, requestId, response, context)` applies the following checks **in order**: + +1. **Session existence** — the session addressed by `sessionId` must be live (`byId.has(sessionId)`). Otherwise `SessionNotFoundError`. + +2. **Cross-session rejection** — `peekSessionFor(requestId)` resolves the session the request actually belongs to. If it belongs to a _different_ session, the vote is rejected (returns `false` / 404) without exposing session-membership information. + +3. **Unknown-request guard** — when `peekSessionFor` returns `undefined` (request timed out, LRU-evicted, or never existed), the vote is rejected (returns `false` / 404) **before** any `clientId` validation. This prevents an oracle attack: without it, a probe with a fabricated `clientId` could distinguish "session has this client" (passes validation → 404) from "client unknown" (`InvalidClientIdError` → 400). + +4. **Client identity validation** — `resolveTrustedClientId(entry, context?.clientId)` verifies the supplied `X-Qwen-Client-Id` (REST) or bridge-stamped `clientId` (ACP) is registered on the session's `clientIds` map. Anonymous votes (`clientId === undefined`) pass through — policy dispatch handles them. Unregistered ids throw `InvalidClientIdError` (mapped to 400 by route handlers). + +5. **Cancel sentinel enforcement** — a wire vote of `{ outcome: "selected", optionId: "__cancelled__" }` is rejected with `InvalidPermissionOptionError` to prevent sentinel injection. + +6. **Mediator `vote()` dispatch** — the validated vote is forwarded to `permissionMediator.vote(...)` which applies the active policy (see [Workflow → `vote()` dispatch](#vote-dispatch)). + +### Loopback evaluation + +The `fromLoopback` bit is evaluated **per request**, not per connection: + +- **ACP transport**: `reqLoopback` is stamped from the POST request's kernel-level `req.socket.remoteAddress` at the HTTP layer and passed to `dispatcher.handle(conn, msg, sessionHeader, isLoopbackReq(req))`. This means a permission-vote POST arriving from a different peer than the `initialize` request gets its own loopback assessment. +- **REST API**: `detectFromLoopback(req)` evaluates the same socket-level remote address. + +Neither path derives loopback from forgeable headers (`X-Forwarded-For`, `Forwarded`, etc.). + +### ACP transport vote response format + +A client responds to `session/request_permission` with a standard JSON-RPC response: + +**Accept (select an option)**: + +```json +{ + "jsonrpc": "2.0", + "id": "_qwen_perm_3", + "result": { + "outcome": { "outcome": "selected", "optionId": "allow" } + } +} +``` + +**Cancel**: + +```json +{ + "jsonrpc": "2.0", + "id": "_qwen_perm_3", + "result": { + "outcome": { "outcome": "cancelled" } + } +} +``` + +**Error response** (mapped to cancel by the dispatcher): + +```json +{ + "jsonrpc": "2.0", + "id": "_qwen_perm_3", + "error": { "code": -32000, "message": "user declined" } +} +``` + +### Failure recovery in `resolveClientResponse` + +When `bridge.respondToSessionPermission` throws (e.g. malformed vote body), the dispatcher falls back to an explicit cancel (`cancelAbandonedPermission`) so the mediator is never left permanently stuck. If both the vote and the cancel throw (double-failure), the `pending` entry is **retained** so the connection's eventual teardown (`abandonPendingForSession`) can retry. + ## Caveats & Known Limits - **Cancel sentinel routes BEFORE policy dispatch** by design — a `local-only` daemon and a `consensus` daemon can both be cancelled by any voter who posts `{outcome: 'cancelled'}`. This is documented at `permissionMediator.ts` and is the agent-side abort path. @@ -269,6 +372,10 @@ mechanism does not exist in v1. - `packages/acp-bridge/src/permission.ts` (frozen contract) - `packages/acp-bridge/src/permissionMediator.ts` (F3 mediator implementation) - `packages/acp-bridge/src/bridgeClient.ts` (uses structural sub-typing on `PermissionMediator`) -- `packages/acp-bridge/src/bridgeErrors.ts` (`CancelSentinelCollisionError`, `InvalidPermissionOptionError`, `PermissionForbiddenError`) +- `packages/acp-bridge/src/bridge.ts` (`respondToSessionPermission` — vote routing and authorization) +- `packages/acp-bridge/src/bridgeErrors.ts` (`CancelSentinelCollisionError`, `InvalidPermissionOptionError`, `PermissionForbiddenError`, `InvalidClientIdError`) +- `packages/cli/src/serve/acp-http/dispatch.ts` (`resolveClientResponse` — ACP transport vote path) +- `packages/cli/src/serve/acp-http/connection-registry.ts` (`AcpConnection.pending` — connection-local request mapping) +- `packages/cli/src/serve/routes/permission.ts` (REST vote routes) - `packages/cli/src/serve/permission-audit.ts` (audit ring + publisher) - Issue: [#4175](https://github.com/QwenLM/qwen-code/issues/4175) F3 series. diff --git a/docs/developers/daemon/09-event-schema.md b/docs/developers/daemon/09-event-schema.md index c9b219d4d98..c81a304929a 100644 --- a/docs/developers/daemon/09-event-schema.md +++ b/docs/developers/daemon/09-event-schema.md @@ -2,7 +2,7 @@ ## Overview -Every SSE frame emitted by the daemon on `GET /session/:id/events` has the shape `{ id, v, type, data, originatorClientId?, _meta? }`. `v: 1` is the current `EVENT_SCHEMA_VERSION`. `type` comes from the closed, version-pinned `DAEMON_KNOWN_EVENT_TYPE_VALUES` set in `packages/sdk-typescript/src/daemon/events.ts`; the current set has 43 known event types. The envelope `_meta` field is stamped at the SSE write boundary by `formatSseFrame()` in `packages/cli/src/serve/routes/sse-events.ts`; see [Envelope-level metadata](#envelope-level-metadata). +Every SSE frame emitted by the daemon on `GET /session/:id/events` has the shape `{ id, v, type, data, originatorClientId?, _meta? }`. `v: 1` is the current `EVENT_SCHEMA_VERSION`. `type` comes from the closed, version-pinned `DAEMON_KNOWN_EVENT_TYPE_VALUES` set in `packages/sdk-typescript/src/daemon/events.ts`; the current set has 47 known event types. The envelope `_meta` field is stamped at the SSE write boundary by `formatSseFrame()` in `packages/cli/src/serve/routes/sse-events.ts`; see [Envelope-level metadata](#envelope-level-metadata). The SDK exposes `asKnownDaemonEvent(evt)`. It returns a discriminated `KnownDaemonEvent` for known event types and `undefined` for other types. SDK consumers can therefore handle forward compatibility without requiring a lockstep SDK upgrade when a newer daemon adds an event type; the session reducer records those as `unrecognizedKnownEventCount`. @@ -15,7 +15,7 @@ The wire format lives in [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md - Provide pure reducers (`reduceDaemonSessionEvent`, `reduceDaemonAuthEvent`) that project an event stream into SDK view state. - Broadcast the `typed_event_schema` capability tag as an informational signal. If the tag is absent, `asKnownDaemonEvent` still falls back to `unknown`. -## Event vocabulary (43 known types) +## Event vocabulary (47 known types) Grouped by domain. @@ -67,15 +67,17 @@ Grouped by domain. ### Mutation control (Wave 4 PR 16+17) -| Type | Direction | Payload | -| ----------------------- | --------- | ---------------------------------------------------------------------------------------------------- | -| `memory_changed` | S->C | `scope: 'workspace' \| 'global', filePath, mode: 'append' \| 'replace', bytesWritten` | -| `agent_changed` | S->C | `change: 'created' \| 'updated' \| 'deleted', name, level: 'project' \| 'user'` | -| `approval_mode_changed` | S->C | `sessionId, previous, next, persisted: boolean` | -| `tool_toggled` | S->C | `toolName, enabled`; affects the next ACP child spawn and does not mutate already-running sessions. | -| `settings_changed` | S->C | Workspace settings write completed. Payload is open; consumers should refresh with read-after-write. | -| `settings_reloaded` | S->C | Daemon workspace service reread settings. Payload is open. | -| `workspace_initialized` | S->C | `path, action: 'created' \| 'overwrote' \| 'noop', originatorClientId?` | +| Type | Direction | Payload | +| ------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `memory_changed` | S->C | `scope: 'workspace' \| 'global', filePath, mode: 'append' \| 'replace', bytesWritten` | +| `agent_changed` | S->C | `change: 'created' \| 'updated' \| 'deleted', name, level: 'project' \| 'user'` | +| `approval_mode_changed` | S->C | `sessionId, previous, next, persisted: boolean` | +| `tool_toggled` | S->C | `toolName, enabled`; affects the next ACP child spawn and does not mutate already-running sessions. | +| `settings_changed` | S->C | Workspace settings write completed. Payload is open; consumers should refresh with read-after-write. | +| `settings_reloaded` | S->C | Daemon workspace service reread settings. Payload is open. | +| `trust_change_requested` | S->C | `workspaceCwd, desiredState: 'trusted' \| 'untrusted', reason?` | +| `workspace_initialized` | S->C | `path, action: 'created' \| 'overwrote' \| 'noop', originatorClientId?` | +| `github_setup_completed` | S->C | `releaseTag, readmeUrl, secretsUrl?, workflows: [{path, status, sizeBytes?, error?}], gitignore: {path, status, added?, error?}` | ### Auth device flow (PR 21) @@ -96,6 +98,18 @@ These events are workspace-keyed, not session-keyed. The session reducer treats | `mcp_server_added` | S->C | Server added at runtime through `POST /workspace/mcp/servers` | `name, transport, replaced, shadowedSettings, toolCount, originatorClientId` | | `mcp_server_removed` | S->C | Server removed at runtime | `name, wasShadowingSettings, originatorClientId` | +### Extensions lifecycle + +| Type | Direction | Trigger | Key payload fields | +| -------------------- | --------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `extensions_changed` | S->C | Background extension install/refresh work completed or status change | `refreshed, failed, status?: 'installed' \| 'enabled' \| 'disabled' \| 'updated' \| 'uninstalled' \| 'failed', source?, name?, version?, error?` | + +### Mid-turn message injection + +| Type | Direction | Trigger | Key payload fields | +| --------------------------- | --------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `mid_turn_message_injected` | S->C | Web-shell or remote client injected messages into a running turn via `POST /session/:id/inject` | `sessionId, messages: string[], originatorClientId?`; consumers MUST compare `originatorClientId` to their own id before deduping. | + ### Turn lifecycle / assistant pushes | Type | Direction | Trigger | Key payload fields | @@ -114,7 +128,7 @@ These events are workspace-keyed, not session-keyed. The session reducer treats | Concern | Source | Notes | | -------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `EVENT_SCHEMA_VERSION = 1` | `packages/acp-bridge/src/eventBus.ts` | Sent on every frame. | -| `DAEMON_KNOWN_EVENT_TYPE_VALUES` | `packages/sdk-typescript/src/daemon/events.ts` | Closed list with 43 types. | +| `DAEMON_KNOWN_EVENT_TYPE_VALUES` | `packages/sdk-typescript/src/daemon/events.ts` | Closed list with 47 types. | | `DaemonEventEnvelope` | `events.ts` | Generic envelope. | | `DaemonKnownEventType` | `events.ts` | `typeof DAEMON_KNOWN_EVENT_TYPE_VALUES[number]`. | | Per-event payload types | `events.ts` | Most event types have a `DaemonXxxData` interface; `user_shell_*` is currently parsed ad hoc by the UI normalizer. | @@ -192,7 +206,7 @@ Beyond each event's `data` payload, the daemon stamps two envelope-level fields. ### `_meta.serverTimestamp` - daemon clock -`formatSseFrame()` in `packages/cli/src/serve/routes/sse-events.ts` stamps this at the SSE write boundary, **not** inside `EventBus.publish`. The in-memory `BridgeEvent` type stays unchanged; internal daemon consumers do not see `_meta`, while wire SSE frames do. +`EventBus.publish()` in `packages/acp-bridge/src/eventBus.ts` stamps `_meta.serverTimestamp` when the event enters the bus. The `BridgeEvent` type includes `_meta?: Record`, so internal daemon consumers **do** see `_meta` on every bus-published event. `formatSseFrame()` in `packages/cli/src/serve/routes/sse-events.ts` provides a fallback timestamp only for synthetic frames (e.g. `stream_error`) that bypass `EventBus.publish`. ```jsonc { @@ -204,10 +218,10 @@ Beyond each event's `data` payload, the daemon stamps two envelope-level fields. } ``` -The merge preserves any existing `_meta` keys -(`{...existingMeta, serverTimestamp: Date.now()}`). **No current daemon producer -writes envelope-level `_meta`**. The top-level merge is a forward-compatibility -escape hatch. +The merge preserves any existing `_meta` keys from the input event +(`{...input._meta, serverTimestamp: Date.now()}`). Producers may attach +additional envelope-level `_meta` keys; `EventBus.publish` merges them with the +timestamp rather than overwriting. Why it matters: multi-client UIs that render relative time or sort transcript blocks should use server time instead of each browser/tab/phone local clock. Server stamping keeps ordering consistent across clients. diff --git a/docs/developers/daemon/10-event-bus.md b/docs/developers/daemon/10-event-bus.md index c90c070c3fe..2156000fed3 100644 --- a/docs/developers/daemon/10-event-bus.md +++ b/docs/developers/daemon/10-event-bus.md @@ -35,8 +35,9 @@ interface BridgeEvent { id?: number; // monotonic per session; absent on synthetic terminal frames v: 1; // EVENT_SCHEMA_VERSION - type: string; // one of the 43 known types or future-extensible + type: string; // one of the 47 known types or future-extensible data: unknown; // payload (typed per-type by the SDK; see 09-event-schema.md) + _meta?: { serverTimestamp?: number; [key: string]: unknown }; // stamped by EventBus.publish originatorClientId?: string; // set when the event derives from a clientId-stamped request } ``` @@ -193,6 +194,72 @@ Already-aborted signals at subscribe time call `onAbort()` synchronously before - `BridgeOptions.eventRingSize` (overrides daemon default for embedded usage). - Capability tags: `session_events`, `slow_client_warning`, `typed_event_schema`. +## Client Integration: `Last-Event-ID` Reconnect + +### Wire Format + +Every id-bearing SSE frame emitted by `GET /session/:id/events` includes an `id:` line: + +``` +id: 42 +event: session_update +data: {"id":42,"v":1,"type":"session_update","data":{...},"_meta":{"serverTimestamp":1719000000000}} + +``` + +Synthetic/terminal frames (`state_resync_required`, `replay_complete`, `client_evicted`, `slow_client_warning`, `stream_error`) are emitted **without** an `id:` line — they do not advance the per-session monotonic sequence. + +### Reconnect Protocol + +When a client reconnects after a disconnect, it sends the last successfully received event id as the `Last-Event-ID` HTTP header: + +``` +GET /session/:id/events HTTP/1.1 +Last-Event-ID: 42 +Accept: text/event-stream +``` + +The daemon's `EventBus` replays all events from the ring buffer whose `id > Last-Event-ID`, then transitions to live delivery. A `replay_complete` synthetic frame marks the boundary between replay and live: + +```jsonc +// no id: line — synthetic +{ + "v": 1, + "type": "replay_complete", + "data": { "replayedCount": 7, "lastReplayedEventId": 49 }, +} +``` + +### Replay Behavior + +| Scenario | Behavior | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Last-Event-ID` absent | Live-only stream; no replay. Backward-compatible with pre-resume clients. | +| `Last-Event-ID: 0` | Replay entire ring buffer from the beginning (bounded by `--event-ring-size`, default 8000). | +| `Last-Event-ID: N` where `ring[0].id <= N+1` | Contiguous replay of events `id > N`, then live. | +| `Last-Event-ID: N` where `ring[0].id > N+1` | Gap detected — `state_resync_required` (`reason: 'ring_evicted'`) emitted before replay of surviving suffix. SDK must call `loadSession` to recover full state. | +| `Last-Event-ID: N` where `N >= nextId` | Epoch reset (daemon restart) — `state_resync_required` (`reason: 'epoch_reset'`) emitted, then full ring replay. | + +### Validation Rules + +The daemon parses `Last-Event-ID` strictly: + +- Only pure decimal digit strings are accepted (e.g. `"42"`). +- Non-numeric, negative, fractional, or overflow values (beyond `Number.MAX_SAFE_INTEGER`) are silently rejected — the stream starts live-only and the daemon logs a breadcrumb. +- The `retry: 3000` directive tells conformant `EventSource` implementations to wait 3 seconds before reconnecting. + +### Backward Compatibility + +The `Last-Event-ID` mechanism is fully opt-in: + +- Clients that never send the header receive a live-only stream identical to pre-resume behavior. +- Older SDK versions that do not track event ids continue to work. +- The `replay_complete` frame is synthetic (no `id:`), so it does not confuse id-unaware consumers. + +### Browser `EventSource` Limitation + +The native browser `EventSource` API automatically tracks the last `id:` field and sends it on reconnect. However, it **cannot** set custom headers (e.g. `Authorization: Bearer`). Clients that require authentication must use raw `fetch()` + manual SSE parsing (as the TypeScript SDK does via `parseSseStream`) rather than `EventSource`. The SDK's `RestSseTransport` demonstrates this pattern — it sets `Last-Event-ID` as an explicit HTTP header on the `fetch()` call. + ## Caveats & Known Limits - **Synthetic frames have no `id`.** SDK consumers using `Last-Event-ID` resume only record frames with ids; `slow_client_warning`, `client_evicted`, `state_resync_required`, and `replay_complete` do not advance the cursor and do not consume per-session sequence numbers. If two id-bearing live frames have a real gap, handle it through the ring-eviction / epoch-reset resync path rather than treating it as a private synthetic frame. diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index e626f696d7e..cbcfce17696 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -6,7 +6,7 @@ - **There is one protocol version: `v1`.** `SERVE_PROTOCOL_VERSION = 'v1'` and `SUPPORTED_SERVE_PROTOCOL_VERSIONS = ['v1']`. v1 is additive internally; breaking frame-shape changes are reserved for v2. - **Each tag has a `since` version.** Future v2 daemons can advertise both v1 and v2 tags. -- **Some tags are conditional.** Ten tags (`require_auth`, `mcp_workspace_pool`, `mcp_pool_restart`, `allow_origin`, `prompt_absolute_deadline`, `writer_idle_timeout`, `workspace_settings`, `session_shell_command`, `rate_limit`, `workspace_reload`) are advertised only when the corresponding deployment toggle is enabled. Tag presence means the behavior exists. +- **Some tags are conditional.** Thirteen tags (`require_auth`, `mcp_workspace_pool`, `mcp_pool_restart`, `allow_origin`, `prompt_absolute_deadline`, `writer_idle_timeout`, `workspace_settings`, `workspace_voice`, `workspace_voice_transcription`, `session_shell_command`, `rate_limit`, `workspace_reload`, `voice_transcribe`) are advertised only when the corresponding deployment toggle is enabled. Tag presence means the behavior exists. - **Capability tag = behavior contract.** Adding new behavior under an existing tag can silently break clients that preflighted the old tag. New behavior needs a new tag. The complete registry lives in `packages/cli/src/serve/capabilities.ts`. @@ -46,10 +46,12 @@ interface ServeCapabilityDescriptor { } ``` -Two v1 tags use `modes`: +Four v1 tags use `modes`: - `mcp_guardrails: { since: 'v1', modes: ['warn', 'enforce'] }` - clients should preflight `'enforce'` before relying on refusal behavior. - `permission_mediation: { since: 'v1', modes: ['first-responder', 'designated', 'consensus', 'local-only'] }` - this is the build-time supported set; the active policy is in `policy.permission`. +- `workspace_voice_transcription: { since: 'v1', modes: ['batch'] }` - the transcription path the daemon offers. +- `voice_transcribe: { since: 'v1', modes: ['streaming', 'batch'] }` - the two transcription paths available on the `/voice/stream` WebSocket. ### Conditional tags @@ -72,9 +74,15 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< typeof t.writerIdleTimeoutMs === 'number' && t.writerIdleTimeoutMs > 0, ], ['workspace_settings', (t) => t.persistSettingAvailable === true], + ['workspace_voice', (t) => t.persistSettingAvailable === true], + [ + 'workspace_voice_transcription', + (t) => t.voiceTranscriptionAvailable === true, + ], ['session_shell_command', (t) => t.sessionShellCommandEnabled === true], ['rate_limit', (t) => t.rateLimit === true], ['workspace_reload', (t) => t.reloadAvailable === true], + ['voice_transcribe', (t) => t.voiceWsAvailable !== false], ]); ``` @@ -85,9 +93,9 @@ The `Map` stores membership and predicate together. Adding a new conditional tag Baseline tags are not present in the `Map` and are advertised unconditionally. This is intentionally represented by absence rather than by a separate Set. -### 67 tags (v1, grouped by domain) +### 75 tags (v1, grouped by domain) -Foundation: `health`, `capabilities`. +Foundation: `health`, `daemon_status`, `capabilities`. Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`. @@ -99,7 +107,7 @@ Permissions: `session_permission_vote`, `permission_vote`, **`permission_mediati Workspace read-only snapshots: `workspace_mcp`, `workspace_skills`, `workspace_providers`, `workspace_env`, `workspace_preflight`, `workspace_hooks`, `workspace_extensions`. -Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_init`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write`, **`workspace_reload`** (conditional). +Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write`, **`workspace_reload`** (conditional). MCP guardrails: **`mcp_guardrails`** (`modes: ['warn', 'enforce']`), `mcp_guardrail_events`, `mcp_server_runtime_mutation`, **`mcp_workspace_pool`** (conditional), **`mcp_pool_restart`** (conditional). @@ -107,6 +115,8 @@ Prompt control: **`prompt_absolute_deadline`** (conditional), **`writer_idle_tim Auth: `auth_provider_install`, `auth_device_flow`, **`require_auth`** (conditional), **`allow_origin`** (conditional). +Voice: **`workspace_voice`** (conditional), **`workspace_voice_transcription`** (conditional, `modes: ['batch']`), **`voice_transcribe`** (conditional, `modes: ['streaming', 'batch']`). + Rate limiting: **`rate_limit`** (conditional). Bold tags have `modes` or are conditional. @@ -167,9 +177,11 @@ sequenceDiagram | Env | `QWEN_SERVE_NO_MCP_POOL=1` | Stops advertising `mcp_workspace_pool` and `mcp_pool_restart`; MCP events no longer stamp `scope: 'workspace'`. | | CLI flag | `--mcp-client-budget=N`, `--mcp-budget-mode={off,warn,enforce}` | Does not change the tag set (`mcp_guardrails` is always advertised), but changes per-server reservation and refusal behavior. | | CLI flag / env | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` | Advertises `rate_limit`. | -| Embedded option | `persistSettingAvailable` | Advertises `workspace_settings`. | +| Embedded option | `persistSettingAvailable` | Advertises `workspace_settings` and `workspace_voice`. | +| Embedded option | `voiceTranscriptionAvailable` | Advertises `workspace_voice_transcription`. | | CLI flag / embedded option | `--enable-session-shell` / `sessionShellCommandEnabled` | Advertises `session_shell_command`. | | Embedded option | `reloadAvailable` | Advertises `workspace_reload`. | +| Embedded option | `voiceWsAvailable` | Advertises `voice_transcribe`. | | `settings.json` | `policy.permissionStrategy` | Sets envelope `policy.permission`. | ## Caveats and known limits diff --git a/docs/developers/daemon/13-sdk-daemon-client.md b/docs/developers/daemon/13-sdk-daemon-client.md index 8343c13f417..b491c2c6a8a 100644 --- a/docs/developers/daemon/13-sdk-daemon-client.md +++ b/docs/developers/daemon/13-sdk-daemon-client.md @@ -223,7 +223,7 @@ auth provider when one is available. The SDK also exports `packages/sdk-typescript/src/daemon/ui/`, a host-neutral set of primitives that turn daemon events into transcript blocks: -- `normalizeDaemonEvent(evt)` maps the 43 known daemon wire events into 37 UI-friendly `DaemonUiEventType` values; unmodeled or malformed events normalize to `debug`. +- `normalizeDaemonEvent(evt)` maps the 47 known daemon wire events into 37 UI-friendly `DaemonUiEventType` values; unmodeled or malformed events normalize to `debug`. - `createDaemonTranscriptState()` plus `reduceDaemonTranscriptEvents(state, events)` projects UI events into `DaemonTranscriptBlock[]`. - `createDaemonTranscriptStore()` wraps subscribe / dispatch. - `render.ts` / `terminal.ts` provide HTML and terminal baseline renderers, while `toolPreview.ts` produces tool-call summaries. @@ -239,6 +239,104 @@ the legacy `DaemonTuiAdapter`. The subpackage is exported from the `@qwen-code/sdk/daemon` subpath. Existing code that does `import { DaemonClient }` is unaffected. +## `Last-Event-ID` Reconnect with the SDK + +### Automatic Tracking via `DaemonSessionClient` + +`DaemonSessionClient` tracks `lastSeenEventId` internally. Each yielded event with a numeric `id` bumps the cursor. Subsequent `events()` calls automatically pass the tracked id as `Last-Event-ID`, so reconnect-with-replay works without extra caller state: + +```ts +import { DaemonClient, DaemonSessionClient } from '@qwen-code/sdk/daemon'; + +const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170', token }); +const session = await DaemonSessionClient.createOrAttach(client); + +// First subscription — starts live (or from ring start for new sessions). +for await (const event of session.events()) { + console.log(event.type, event.id); + // session.lastEventId is bumped on each id-bearing frame. + if (shouldStop(event)) break; +} + +// Reconnect — automatically sends Last-Event-ID: . +// The daemon replays missed events from the ring, then goes live. +for await (const event of session.events()) { + // Replay frames arrive first, then a synthetic `replay_complete`, + // then live events. + handleEvent(event); +} +``` + +### Manual Reconnect with `DaemonClient` + +For lower-level control, use `DaemonClient.subscribeEvents` directly and manage the cursor yourself: + +```ts +const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170', token }); + +let cursor: number | undefined; // undefined = live-only on first connect + +async function* subscribe(sessionId: string, signal: AbortSignal) { + for await (const event of client.subscribeEvents(sessionId, { + lastEventId: cursor, + signal, + })) { + // Only id-bearing frames advance the cursor. + if (event.id !== undefined) { + cursor = event.id; + } + // Handle ring-eviction gap. + if (event.type === 'state_resync_required') { + // State is stale — reload full session state. + await client.loadSession(sessionId); + continue; + } + yield event; + } +} +``` + +### Reconnect with Retry Loop + +The SDK does **not** auto-retry on network failure. Implement a retry loop around `events()`: + +```ts +async function resilientSubscribe(session: DaemonSessionClient) { + const MAX_RETRIES = 10; + const BASE_DELAY_MS = 1000; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + // `resume: true` (default) passes the tracked lastSeenEventId. + for await (const event of session.events()) { + attempt = 0; // reset on successful event + handleEvent(event); + } + break; // clean stream end + } catch (err) { + const delay = BASE_DELAY_MS * 2 ** Math.min(attempt, 5); + await new Promise((r) => setTimeout(r, delay)); + } + } +} +``` + +On reconnect the daemon replays events with `id > lastSeenEventId` from its bounded ring (default 8000 events). If the gap exceeds the ring, a `state_resync_required` frame signals the client to call `loadSession` for a full state rebuild. + +### Seeding `lastEventId` at Construction + +Callers that persist the cursor across process restarts can seed it: + +```ts +const session = new DaemonSessionClient({ + client, + session: { sessionId, workspaceCwd, attached: true }, + lastEventId: persistedCursor, // resume from persisted position +}); +``` + +The value must be a finite, non-negative integer (validated at construction). Invalid values throw. + ## Configuration | Knob | Where | Effect | diff --git a/docs/developers/daemon/14-cli-tui-adapter.md b/docs/developers/daemon/14-cli-tui-adapter.md index 8479aa77574..9d5e05aa69b 100644 --- a/docs/developers/daemon/14-cli-tui-adapter.md +++ b/docs/developers/daemon/14-cli-tui-adapter.md @@ -6,7 +6,7 @@ `packages/sdk-typescript/src/daemon/ui/` adds a `ui/*` subpackage to the SDK. It turns the daemon SSE event stream into UI-renderable transcript blocks through reusable primitives: -- **Normalization** (`normalizer.ts`): maps the daemon wire schema's 43 known event types (see [`09-event-schema.md`](./09-event-schema.md)) into 37 UI-friendly `DaemonUiEventType` semantic events such as `assistant.text.delta`, `tool.update`, and `session.metadata.changed`. +- **Normalization** (`normalizer.ts`): maps the daemon wire schema's 47 known event types (see [`09-event-schema.md`](./09-event-schema.md)) into 37 UI-friendly `DaemonUiEventType` semantic events such as `assistant.text.delta`, `tool.update`, and `session.metadata.changed`. - **State machine** (`transcript.ts`, `store.ts`): pure reducer plus subscribable store that projects UI events into an ordered `DaemonTranscriptBlock[]`. - **Renderers** (`render.ts`, `terminal.ts`, `toolPreview.ts`): transcript blocks to HTML, terminal text, and tool preview strings. Hosts can use or replace them. - **Conformance** (`conformance.ts`): cross-host consistency tests used when channel, TUI, and IDE surfaces migrate to these primitives. @@ -15,7 +15,7 @@ The first production consumer is **`packages/webui/src/daemon/`** ([#4328](https ## Responsibilities -- Normalize the 43 daemon wire events into a stable UI vocabulary (`DaemonUiEventType`) so renderers do not inspect `rawEvent.data`. +- Normalize the 47 daemon wire events into a stable UI vocabulary (`DaemonUiEventType`) so renderers do not inspect `rawEvent.data`. - Keep daemon-monotonic SSE `eventId` as the **primary ordering key** so different clients render transcripts in the same order. - Use a pure reducer to produce transcript blocks, with selectors for pending permissions, current tool, approval mode, tool progress, and subagent children. - Provide baseline HTML and terminal renderers while allowing host-specific rendering. @@ -71,7 +71,7 @@ The first production consumer is **`packages/webui/src/daemon/`** ([#4328](https - `auth.device_flow.started`, `auth.device_flow.throttled`, `auth.device_flow.authorized` - `auth.device_flow.failed`, `auth.device_flow.cancelled` -`normalizeDaemonEvent` maps the 43 daemon known wire events into this vocabulary. Unknown, unmodeled, or malformed event types normalize to `debug` and preserve `rawEvent` for host diagnostics. +`normalizeDaemonEvent` maps the 47 daemon known wire events into this vocabulary. Unknown, unmodeled, or malformed event types normalize to `debug` and preserve `rawEvent` for host diagnostics. ### Reducer and selectors From fa36d09ebd78d568fc7b2cd971272b62018cad59 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sun, 28 Jun 2026 16:19:40 +0800 Subject: [PATCH 2/2] docs(daemon): add workspace remember design doc (PR #5884) Design document for the sessionless workspace remember API proposed in PR #5884. Covers API endpoints, task lifecycle, implementation details, events, error handling, and SDK integration. Status: Proposed (not yet merged). --- docs/design/daemon-workspace-remember.md | 429 +++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 docs/design/daemon-workspace-remember.md diff --git a/docs/design/daemon-workspace-remember.md b/docs/design/daemon-workspace-remember.md new file mode 100644 index 00000000000..28b78616a91 --- /dev/null +++ b/docs/design/daemon-workspace-remember.md @@ -0,0 +1,429 @@ +# Daemon Workspace Remember — Sessionless Memory Ingestion + +> **Status**: Proposed — implementation in [PR #5884](https://github.com/QwenLM/qwen-code/pull/5884) (branch `codex/sessionless-daemon-remember`), not yet merged. + +--- + +## 1. Problem Statement + +The daemon's managed-memory system (auto-extraction, dream agent) previously +required an active chat session to write memories. This created two problems: + +1. **Settings UI cannot write memories** — the web-shell settings panel needs to + save user-provided facts (e.g. "always use TypeScript strict mode") without + creating or polluting a visible chat session. +2. **Session list pollution** — creating a throwaway session just to run a + `/remember` command adds noise to the session list and confuses users who see + ghost sessions they never opened. + +The solution is a **sessionless workspace-level remember endpoint** that queues +memory-write tasks, executes them via a hidden `AgentHeadless` fork (no session +created), and exposes status via polling. + +--- + +## 2. Design Overview + +``` +┌──────────────┐ POST /workspace/memory/remember ┌─────────────────────────┐ +│ SDK / UI │ ─────────────────────────────────► │ workspace-remember.ts │ +│ client │ │ (WorkspaceRemember- │ +│ │ GET /workspace/memory/remember/:id │ TaskLane) │ +│ │ ─────────────────────────────────► │ │ +└──────────────┘ └────────────┬────────────┘ + │ bridge.runWorkspaceMemoryRemember() + ┌────────────▼────────────┐ + │ HttpAcpBridge │ + │ extMethod( │ + │ 'qwen/control/ │ + │ workspace/memory/ │ + │ remember') │ + └────────────┬────────────┘ + │ ACP stdio (JSON-RPC) + ┌────────────▼────────────┐ + │ qwen --acp child │ + │ (QwenAgent.extMethod) │ + │ → runManagedRemember- │ + │ ByAgent (forked) │ + └─────────────────────────┘ +``` + +Key properties: + +- **No session required** — the bridge ensures the ACP child is spawned but does + not create/load/resume any ACP session. +- **Serial execution** — tasks execute one at a time via a promise-chain lane, + preventing concurrent writes to the managed memory filesystem. +- **Hidden** — the forked agent runs with `name: 'managed-auto-memory-remember'` + and is invisible to the session list. +- **Capability-advertised** — `workspace_memory_remember` in the daemon's + `/capabilities` response, with supported `modes: ['workspace', 'clean']`. + +--- + +## 3. API Endpoints + +### 3.1 `POST /workspace/memory/remember` + +Queue a new remember task. + +**Request:** + +```json +{ + "content": "The user prefers dark mode in all editors", + "contextMode": "workspace" +} +``` + +| Field | Type | Required | Description | +| ------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------- | +| `content` | `string` | yes | The fact to remember. Max 64 KiB (UTF-8 byte length). | +| `contextMode` | `string` | no | `"workspace"` (default) — agent sees workspace memory context. `"clean"` — agent sees no prior user memory. | + +**Headers:** + +- `Authorization: Bearer ` (required) +- `X-Qwen-Client-Id: ` (optional — scopes task visibility) + +**Response `202 Accepted`:** + +```json +{ + "taskId": "remember-a1b2c3d4-...", + "status": "queued", + "contextMode": "workspace", + "createdAt": "2026-06-01T12:00:00.000Z", + "updatedAt": "2026-06-01T12:00:00.000Z" +} +``` + +**Error responses:** + +| Status | Code | Condition | +| ------ | ---------------------------- | ----------------------------------------------- | +| 400 | `invalid_content` | Missing, empty, or oversized content | +| 400 | `invalid_context_mode` | Unrecognized contextMode value | +| 400 | `invalid_client_id` | X-Qwen-Client-Id not registered with the bridge | +| 409 | `managed_memory_unavailable` | Managed memory not configured for workspace | +| 429 | `remember_queue_full` | 16 pending tasks already queued | +| 500 | `remember_failed` | Availability check threw unexpectedly | + +### 3.2 `GET /workspace/memory/remember/:taskId` + +Poll task status. + +**Headers:** + +- `Authorization: Bearer ` (required) +- `X-Qwen-Client-Id: ` (optional — must match originator to see task) + +**Response `200 OK` (queued/running):** + +```json +{ + "taskId": "remember-a1b2c3d4-...", + "status": "queued", + "contextMode": "workspace", + "createdAt": "2026-06-01T12:00:00.000Z", + "updatedAt": "2026-06-01T12:00:00.000Z", + "result": null, + "error": null +} +``` + +- `status` will be `"queued"` or `"running"` depending on whether the task has + started execution. +- `result`: only present (non-null) when `status === "completed"`. +- `error`: only present (non-null) when `status === "failed"`. + +**Response `200 OK` (completed):** + +```json +{ + "taskId": "remember-a1b2c3d4-...", + "status": "completed", + "contextMode": "workspace", + "createdAt": "2026-06-01T12:00:00.000Z", + "updatedAt": "2026-06-01T12:00:05.000Z", + "result": { + "summary": "Saved dark-mode preference to user memory.", + "filesTouched": ["~/.qwen/memories/user/user.md"], + "touchedScopes": ["user"] + } +} +``` + +**Response `200 OK` (failed):** + +```json +{ + "taskId": "remember-a1b2c3d4-...", + "status": "failed", + "contextMode": "workspace", + "createdAt": "2026-06-01T12:00:00.000Z", + "updatedAt": "2026-06-01T12:00:03.000Z", + "error": { + "code": "remember_path_escape", + "message": "Remember agent touched a path outside managed memory." + } +} +``` + +**Error responses:** + +| Status | Code | Condition | +| ------ | ------------------------- | ---------------------------------------------------- | +| 400 | `invalid_client_id` | X-Qwen-Client-Id not registered | +| 404 | `remember_task_not_found` | Task does not exist or belongs to a different client | + +--- + +## 4. Task Lifecycle + +``` + enqueue() + │ + ▼ + ┌─────────────────────┐ + │ queued │ (awaiting serial lane slot) + └──────────┬──────────┘ + │ lane picks up + ▼ + ┌─────────────────────┐ + │ running │ (bridge.runWorkspaceMemoryRemember in progress) + └──────────┬──────────┘ + │ + ┌───────┴────────┐ + ▼ ▼ +┌──────────┐ ┌──────────┐ +│ completed│ │ failed │ +└──────────┘ └──────────┘ +``` + +- **queued** — task is created and waiting in the serial lane. +- **running** — the bridge call is in flight; the forked agent is executing. +- **completed** — agent finished successfully; `result` is populated. +- **failed** — agent threw or timed out; `error` is populated. + +The lane stores up to **1000 tasks** total (terminal tasks evicted FIFO when the +cap is reached). At most **16 tasks** may be pending (queued + running) at any +time. + +--- + +## 5. Implementation Details + +### 5.1 Serial Task Lane (`WorkspaceRememberTaskLane`) + +Located in `packages/cli/src/serve/workspace-remember.ts`. Maintains a +`Map` and a single promise chain (`this.tail`). Each +`enqueue()` appends a `run` function that: + +1. Sets status to `running`. +2. Calls `bridge.runWorkspaceMemoryRemember({ content, contextMode })`. +3. On success: sets status to `completed`, populates `result`, publishes + `memory_changed` event. +4. On failure: sets status to `failed`, populates `error` with a stable public + error code. + +The lane guarantees strict serialization — only one remember task executes at +a time, preventing concurrent filesystem writes to managed memory. + +### 5.2 Bridge Layer (`HttpAcpBridge`) + +Two methods added to `BridgeInterface` (`packages/acp-bridge/src/bridgeTypes.ts`): + +- `isWorkspaceMemoryRememberAvailable()` — calls + `qwen/control/workspace/memory/remember/availability` ext-method on the child. + Returns `boolean`. Used for fast-fail `409` before queuing. +- `runWorkspaceMemoryRemember(request)` — calls + `qwen/control/workspace/memory/remember` ext-method. Times out at **300 s** + (`WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS`). Does NOT create or load a session. + +Both methods call `ensureChannel()` (spawning the ACP child if needed) and +restart the idle timer afterwards if no sessions are active. + +### 5.3 ACP Child Execution (`QwenAgent.extMethod`) + +In `packages/cli/src/acp-integration/acpAgent.ts`, the handler for +`workspaceMemoryRemember`: + +1. Validates `content` (non-empty string, ≤64 KiB) and `contextMode`. +2. Checks `config.isManagedMemoryAvailable()`. +3. Calls `runManagedRememberByAgent()` with a **295 s** abort signal + (`WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS` — slightly less than the bridge + timeout to ensure the child aborts before the bridge backstop). + +### 5.4 Core Remember Logic (`packages/core/src/memory/remember.ts`) + +`runManagedRememberByAgent()`: + +1. Builds a clean memory system prompt from the project's managed memory index. +2. Optionally strips prior user memory (if `contextMode === 'clean'`). +3. Creates a `memoryScopedAgentConfig` that restricts file I/O to memory + directories only. +4. Runs a **forked headless agent** (`runForkedAgent`) with: + - Name: `managed-auto-memory-remember` + - Tools: `read_file`, `grep`, `ls`, `write_file`, `edit` + - Max turns: 6 + - Max time: 5 minutes +5. Validates that all touched files are within allowed memory paths + (`classifyTouchedScopes`). Throws `remember_path_escape` if the agent wrote + outside memory directories. +6. Rebuilds memory indexes for any touched scopes. +7. Returns `{ summary, filesTouched, touchedScopes }`. + +### 5.5 Memory-Scoped Agent Config (`packages/core/src/memory/memory-scoped-agent-config.ts`) + +`createMemoryScopedAgentConfig()` creates a permission-restricted `Config` +wrapper that: + +- **Write tools** (`write_file`, `edit`): only allowed within the project + auto-memory root or user memory root (`~/.qwen/memories`). +- **Read tools** (`read_file`, `grep`, `ls`): when `restrictReadsToMemoryPaths` + is true, only allowed within memory directories. +- **Shell**: disabled by default; if enabled, only read-only commands allowed. +- Resolves symlinks to prevent path-traversal escapes. + +--- + +## 6. Events + +### `memory_changed` (scope: `managed`) + +Published on the daemon SSE event stream (`GET /session/:id/events`) as a +`memory_changed` event with `scope: 'managed'` when a remember task completes +successfully. Clients subscribed to the per-session event stream receive this +notification. + +**Payload:** + +```json +{ + "type": "memory_changed", + "data": { + "scope": "managed", + "source": "workspace_memory_remember", + "taskId": "remember-a1b2c3d4-...", + "touchedScopes": ["user", "project"] + } +} +``` + +| Field | Type | Description | +| --------------- | ----------- | ------------------------------------------------------- | +| `scope` | `"managed"` | Discriminates from file-based `memory_changed` events | +| `source` | `string` | Always `"workspace_memory_remember"` for this feature | +| `taskId` | `string` | Correlates with the task returned by POST | +| `touchedScopes` | `string[]` | Which memory scopes were written: `"user"`, `"project"` | + +The `originatorClientId` (if provided at POST time) is attached to the event +envelope so the event bus can route it to the originating client. + +--- + +## 7. Error Handling + +### Error Codes + +| Code | Origin | Meaning | +| ---------------------------- | ------------------- | ------------------------------------------------------ | +| `invalid_content` | HTTP route | Content missing, empty, or exceeds 64 KiB | +| `invalid_context_mode` | HTTP route | contextMode not `"workspace"` or `"clean"` | +| `invalid_client_id` | HTTP route | Client-Id header not in bridge's known set | +| `managed_memory_unavailable` | Bridge / ACP child | Workspace not configured for managed memory | +| `remember_queue_full` | Task lane | 16 pending tasks limit reached | +| `remember_path_escape` | Core remember logic | Agent wrote to a path outside managed memory dirs | +| `remember_failed` | Catch-all | Unclassified agent failure, timeout, or internal error | +| `remember_task_not_found` | HTTP route | GET for unknown or unauthorized task ID | + +### Timeout Chain + +``` +Agent forked runner: 5 min maxTimeMinutes +Child abort signal: 295 s (WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS) +Bridge timeout: 300 s (WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS) +``` + +The child aborts before the bridge times out, ensuring a clean error propagates +rather than a transport-level timeout. + +--- + +## 8. SDK Integration + +### TypeScript SDK (`@qwen-code/sdk-typescript`) + +Two new methods on `DaemonClient`: + +```typescript +// Queue a remember task +const task = await client.rememberWorkspaceMemory( + 'The project uses pnpm workspaces', + { contextMode: 'workspace' }, +); +// task.taskId, task.status === 'queued' + +// Poll until terminal +const result = await client.getWorkspaceMemoryRememberTask(task.taskId); +// result.status === 'completed' | 'failed' +``` + +### UI Event Normalization + +The SDK normalizer maps the raw `memory_changed` SSE event (with +`scope: 'managed'`) to a `DaemonUiWorkspaceMemoryChangedEvent`: + +```typescript +{ + type: 'workspace.memory.changed', + scope: 'managed', + source: 'workspace_memory_remember', + taskId: 'remember-...', + touchedScopes: ['user', 'project'] +} +``` + +This extends the existing `workspace.memory.changed` event type, which +previously only carried `scope: 'workspace' | 'global'` for file-based QWEN.md +writes. + +--- + +## 9. Design Rationale + +### Why sessionless? + +The `/remember` slash command in the CLI already works within a session. But the +Settings UI and programmatic SDK callers should not need to create a session just +to persist a fact. A session implies conversation history, turn tracking, and +visibility in the session list — none of which apply to a fire-and-forget memory +write. + +### Why serial execution? + +The managed memory system stores facts in markdown files with indexes. Concurrent +writes from multiple remember tasks could corrupt indexes or produce merge +conflicts. A single-threaded lane is the simplest correct solution. + +### Why a task queue (not synchronous)? + +Memory writes involve an LLM agent deciding _where_ and _how_ to store the fact +(choosing between user vs. project scope, picking the right file, formatting). +This takes 2–30 seconds. A synchronous HTTP request would either time out or +block the client. The async queue + poll pattern keeps the HTTP contract simple +and lets clients show progress UI. + +### Why `contextMode`? + +- `"workspace"` (default) — the remember agent sees existing memories as + context, enabling it to deduplicate or update existing entries. +- `"clean"` — the agent sees no prior user memory, useful when the caller wants + to force a fresh write without dedup logic (e.g. bulk import). + +### Why restrict reads to memory paths? + +The remember agent should only read/write within managed memory directories. This +prevents a prompt-injection scenario where crafted `content` tricks the agent +into reading sensitive project files and leaking them into memory entries.