basant307/AI_Governance_Project
048
1# Daemon UI SDK — Developer Guide2 3The `@qwen-code/sdk/daemon` subpath ships shared UI primitives for daemon4clients. The current adoption target is web chat and web terminal; native local5TUI, channel, and IDE integrations keep their existing default paths while the6daemon UI contract stabilizes. This guide covers the API surface introduced by7PR #4353 (the unified follow-up to PR #4328's shared UI transcript layer).8 9## Three-layer model10 11```12Daemon SSE wire (NDJSON envelopes)13 │14 ▼15normalizeDaemonEvent(envelope) → DaemonUiEvent[]16 │17 ▼18reduceDaemonTranscriptEvents(state, events) → DaemonTranscriptState19 │ { blocks, currentToolCallId,20 │ approvalMode, toolProgress, ... }21 ▼22daemonBlockToMarkdown(block) / ToHtml / ToPlainText ← your renderer plugs here23```24 25- **Normalizer**: takes raw daemon SSE envelopes, returns typed UI events26- **Reducer**: accumulates events into a transcript state machine27- **Render helpers**: project state blocks to renderable strings28 29## Quick start30 31```ts32import {33 DaemonSessionClient,34 createDaemonTranscriptStore,35 normalizeDaemonEvent,36 daemonBlockToMarkdown,37 selectCurrentTool,38 selectApprovalMode,39} from '@qwen-code/sdk/daemon';40 41const session = await DaemonSessionClient.createOrAttach(client, {42 workspaceCwd,43});44const store = createDaemonTranscriptStore();45 46for await (const envelope of session.events({ signal })) {47 const events = normalizeDaemonEvent(envelope, {48 clientId: session.clientId,49 suppressOwnUserEcho: true,50 });51 store.dispatch(events);52}53 54// Read state from any subscriber55store.subscribe(() => {56 const state = store.getSnapshot();57 const currentTool = selectCurrentTool(state);58 const mode = selectApprovalMode(state);59 const markdown = state.blocks.map(daemonBlockToMarkdown).join('\n\n');60 myRenderer.render({ markdown, currentTool, mode });61});62```63 64## Event taxonomy (28+ types)65 66`DaemonUiEvent` is a discriminated union of all UI-facing events:67 68### Chat-stream events69 70| Event | When |71| ---------------------------- | ----------------------------------------------------- |72| `user.text.delta` | User message chunk arrives from daemon |73| `assistant.text.delta` | Assistant streaming chunk |74| `assistant.done` | Prompt completion (from sendPrompt resolve) |75| `thought.text.delta` | Agent reasoning chunk |76| `tool.update` | Tool call lifecycle (running / completed / cancelled) |77| `shell.output` | Shell tool stdout/stderr chunk |78| `permission.request` | Tool needs user authorization |79| `permission.resolved` | Permission decision arrived |80| `model.changed` | Session model switched |81| `status` / `debug` / `error` | Status / debug / error blocks |82 83### Session-meta events (PR-A)84 85| Event | When |86| ------------------------------- | ------------------------------------------------ |87| `session.metadata.changed` | Session title / display name updated |88| `session.approval_mode.changed` | Mode toggled (plan / default / yolo / auto-edit) |89| `session.available_commands` | Slash command list refreshed |90 91### Workspace events (PR-A, Wave 3-4)92 93| Event | When |94| -------------------------------------- | ------------------------------------- |95| `workspace.memory.changed` | QWEN.md / memory file modified |96| `workspace.agent.changed` | Sub-agent created / updated / deleted |97| `workspace.tool.toggled` | Builtin tool enabled / disabled |98| `workspace.initialized` | `qwen init` completed |99| `workspace.mcp.budget_warning` | MCP child count approaching cap |100| `workspace.mcp.child_refused` | MCP server refused due to budget |101| `workspace.mcp.server_restarted` | Manual MCP restart succeeded |102| `workspace.mcp.server_restart_refused` | Manual restart blocked |103 104### Auth device-flow events (PR-A, Wave 4 OAuth)105 106`auth.device_flow.{started,throttled,authorized,failed,cancelled}`107 108Each carries the daemon's `deviceFlowId`. Failed events carry a closed-enum109`errorKind` (closed enum — see `KNOWN_DEVICE_FLOW_ERROR_KINDS` exported from `@qwen-code/sdk/daemon` for the canonical list, currently: `expired_token` / `access_denied` / `invalid_grant` / `upstream_error` / `persist_failed` / `not_found_or_evicted`).110 111## Render contract (PR-D)112 113Three projection helpers, one preview helper. All discriminate on `block.kind`114or `preview.kind`:115 116```ts117daemonBlockToMarkdown(block, { sanitizeUrls?, maxFieldLength?, locale? })118daemonBlockToHtml(block, { sanitizer?, ...renderOpts })119daemonBlockToPlainText(block, renderOpts)120daemonToolPreviewToMarkdown(preview, renderOpts)121```122 123### Cookbook: render a transcript to markdown124 125```ts126const markdown = state.blocks127 .map((b) => daemonBlockToMarkdown(b, { sanitizeUrls: true }))128 .join('\n\n');129```130 131### Cookbook: render to sanitized HTML for SSR132 133```ts134import DOMPurify from 'dompurify';135import MarkdownIt from 'markdown-it';136const md = new MarkdownIt();137 138const html = state.blocks139 .map((b) => {140 // Two-stage pipeline: markdown → HTML → DOMPurify141 const rawHtml = md.render(daemonBlockToMarkdown(b));142 return DOMPurify.sanitize(rawHtml);143 })144 .join('\n');145```146 147Or use the built-in conservative HTML renderer (no markdown parsing, just148HTML escape):149 150```ts151const html = state.blocks152 .map((b) => daemonBlockToHtml(b, { sanitizer: DOMPurify.sanitize }))153 .join('\n');154```155 156### Cookbook: copy-paste plain text157 158```ts159const plain = state.blocks.map(daemonBlockToPlainText).join('\n');160navigator.clipboard.writeText(plain);161```162 163## Tool preview taxonomy (13 kinds)164 165| Kind | Surface |166| --------------------- | ------------------------------------------------- |167| `ask_user_question` | Multi-choice question with options |168| `command` | Bash-style command + cwd |169| `file_diff` | File edit with oldText/newText or patch |170| `file_read` | Path + optional line range |171| `web_fetch` | URL + HTTP method |172| `mcp_invocation` | MCP server + tool + args summary |173| `code_block` | Language-tagged code snippet |174| `search` | Query + result count + top results |175| `tabular` | Columns + rows (capped at 50, truncation flagged) |176| `image_generation` | Prompt + optional thumbnail URL |177| `subagent_delegation` | Agent name + task |178| `key_value` | Generic label/value rows |179| `generic` | Fallback summary |180 181Each has a `daemonToolPreviewToMarkdown` projection. Custom renderers can182dispatch on `preview.kind` for rich per-type display (file diff with183syntax highlighting, MCP server badge, image thumbnail, etc.).184 185## State selectors (PR-E)186 187```ts188selectCurrentTool(state); // → DaemonToolTranscriptBlock | undefined189selectApprovalMode(state); // → 'plan' | 'default' | 'auto-edit' | 'yolo' | undefined190selectToolProgress(state, toolCallId); // → { ratio?, step? } | undefined191selectPendingPermissionBlocks(state); // → ReadonlyArray<DaemonPermissionTranscriptBlock>192selectTranscriptBlocks(state); // → ReadonlyArray<DaemonTranscriptBlock>193selectTranscriptBlocksOrderedByEventId(state); // sorted by daemon-monotonic id194 195// PR-K — sub-agent nesting196selectSubagentChildBlocks(state, parentToolCallId); // direct children only197isSubagentChildBlock(block); // type guard: was this tool invoked inside a sub-agent?198```199 200`currentToolCallId` is automatically maintained by the reducer:201 202- Set when a tool enters in-flight status (`running` / `in_progress` / `pending` / `confirming`)203- Cleared when tool enters terminal status (`completed` / `failed` / `cancelled` / etc.)204- Unknown statuses leave it untouched (forward-compat)205 206## Cancellation propagation (PR-E)207 208When `assistant.done.reason === 'cancelled'`, the reducer walks every209in-flight tool block and force-sets its status to `'cancelled'`. Daemon210does not guarantee a terminal `tool_call_update` for every in-flight211tool when the parent prompt is cancelled — this propagation prevents UI212spinners from spinning forever.213 214Sub-agent children are cancelled together with their parent because215cancellation iterates every in-flight tool block in `toolBlockByCallId`,216not just the current pointer.217 218## Sub-agent nesting (PR-K)219 220When the main agent delegates to a sub-agent (the `Task` tool, or221equivalent), the daemon stamps `parentToolCallId` and `subagentType` on222the **child** tool calls via `tool_call._meta`. The reducer reads both223and:224 225- Mirrors `parentToolCallId` + `subagentType` onto226 `DaemonToolTranscriptBlock`227- Resolves `parentBlockId` (the parent's transcript block `id`) when the228 parent block is already in state; otherwise leaves it `undefined` and229 back-fills when the parent block later appears230 231Out-of-order arrival (child before parent) is handled transparently. A232child whose parent gets trimmed by `maxBlocks` keeps `parentToolCallId`233for selector queries, but `parentBlockId` is nulled (the dangling id234would no longer resolve via `blockIndexById`).235 236```ts237import {238 selectSubagentChildBlocks,239 isSubagentChildBlock,240} from '@qwen-code/sdk/daemon';241 242// Render a parent tool block, then walk children:243function renderToolBlock(state, block) {244 if (block.kind !== 'tool') return renderOther(block);245 const children = selectSubagentChildBlocks(state, block.toolCallId);246 return (247 <ToolBlock block={block}>248 {children.length > 0 && (249 <Indent>250 {children.map((c) => renderToolBlock(state, c))}251 </Indent>252 )}253 </ToolBlock>254 );255}256 257// Or filter top-level vs. nested at render time:258const topLevel = state.blocks.filter((b) => !isSubagentChildBlock(b));259```260 261`selectSubagentChildBlocks` returns **direct** children only. Walk262recursively to render nested sub-agents (a sub-agent inside a263sub-agent). Daemon does not emit cycles, but renderers walking up via264`parentBlockId` should still detect them defensively (e.g., depth cap or265visited set).266 267Self-references (`parentToolCallId === toolCallId`) are dropped by the268normalizer before reaching the reducer.269 270## Time semantics (PR-B)271 272```ts273interface DaemonTranscriptBlockBase {274 eventId?: number; // PRIMARY sort key — daemon-monotonic275 serverTimestamp?: number; // PREFERRED display — daemon-authoritative276 clientReceivedAt: number; // FALLBACK — local clock277 createdAt: number; // @deprecated alias for clientReceivedAt278}279```280 281**Always sort by `eventId`** (use `selectTranscriptBlocksOrderedByEventId`)282when displaying long sessions. The daemon-monotonic cursor is preserved283across SSE replay-after-reconnect; client clocks are not.284 285**Always format display timestamps from `serverTimestamp`** (with286fallback to `clientReceivedAt`). Multiple clients viewing the same session287see the same "5 minutes ago" only when both read from the daemon clock.288 289```ts290import { formatBlockTimestamp } from '@qwen-code/sdk/daemon';291 292const label = formatBlockTimestamp(block, {293 locale: 'zh-CN',294 timeZone: 'Asia/Shanghai',295 timeStyle: 'short',296});297```298 299## Adapter conformance (PR-G)300 301Validate your adapter projects the SDK's reference corpus to semantically302equivalent output:303 304```ts305import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon';306 307it('my adapter conforms to daemon UI corpus', () => {308 const result = runAdapterConformanceSuite({309 reduce: (events) => myReducer(events),310 renderToText: (state) => myRenderer(state),311 });312 expect(result.failed).toEqual([]);313});314```315 316The fixture corpus (`DAEMON_UI_CONFORMANCE_FIXTURES`) covers chat, tool317lifecycle, file edits, MCP, permissions, MCP budget warning, cancellation,318malformed payload redaction, OAuth, command updates, and sub-agent319nesting. (Count is derivable at runtime — read320`DAEMON_UI_CONFORMANCE_FIXTURES.length`.)321 322**Format-agnostic** — your adapter can render to ANSI / HTML / markdown /323JSX; the framework only checks semantic content via `expectedContains` and324`expectedAbsent`.325 326## Error categorization (PR-A)327 328`DaemonUiErrorEvent.errorKind` is a closed-enum propagated from the329daemon's typed-error taxonomy (when the daemon stamps it):330 331```ts332import type { DaemonErrorKind } from '@qwen-code/sdk/daemon';333// 'missing_binary' | 'blocked_egress' | 'auth_env_error' | 'init_timeout'334// | 'protocol_error' | 'missing_file' | 'parse_error' | 'budget_exhausted'335```336 337Renderers should branch on `errorKind` for actionable affordances:338 339```ts340function errorAffordance(errorKind?: DaemonErrorKind): React.ReactNode {341 switch (errorKind) {342 case 'auth_env_error': return <button>Re-authenticate</button>;343 case 'missing_file': return <button>Choose file</button>;344 case 'blocked_egress': return <span>Network blocked — check proxy</span>;345 default: return null;346 }347}348```349 350## Tool provenance dispatch (PR-A)351 352`DaemonUiToolUpdateEvent.provenance` is a closed-enum (`builtin` / `mcp` /353`subagent` / `unknown`). With `serverId?: string` when `mcp`. Use it for354icon dispatch and badging:355 356```ts357function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode {358 switch (event.provenance) {359 case 'mcp': return <McpIcon server={event.serverId} />;360 case 'subagent': return <SubagentIcon />;361 case 'builtin': return <BuiltinIcon name={event.toolName} />;362 default: return <GenericIcon />;363 }364}365```366 367The SDK has a `mcp__<server>__<tool>` naming heuristic fallback — even368when daemon doesn't explicitly stamp provenance, MCP tools are detectable.369 370## Forward-compat principles371 372Every layer in the daemon UI SDK follows the **forward-compat principle**:373unknown values do NOT throw; they degrade gracefully.374 375- Unknown daemon event types → `debug` event with the raw type name376- Unknown tool status → `currentToolCallId` left untouched (no clear)377- Unknown error kind → `errorKind` undefined (renderer falls back to text)378- Missing serverTimestamp → falls back to `clientReceivedAt`379- Unrecognized preview shape → `generic` kind with `summary`380 381This means **SDK can ship ahead of daemon emission**. PR-A's tool382provenance heuristic, PR-B's three-location timestamp extraction, and383PR-E's unknown-status preservation are all examples of "ready when daemon384sends; safe when it doesn't."385 386## Cross-references387 388- [PR #4328](https://github.com/QwenLM/qwen-code/pull/4328) — base PR with the shared UI transcript layer389- [PR #4353](https://github.com/QwenLM/qwen-code/pull/4353) — this PR (unified completeness follow-up)390- [Issue #3803](https://github.com/QwenLM/qwen-code/issues/3803) — daemon mode proposal391- [Issue #4175](https://github.com/QwenLM/qwen-code/issues/4175) — Mode B v0.16 implementation tracker392 