From 0681ac47deee9eb23fe0de8eda73e556e7777278 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 20 Jul 2026 18:50:24 +0800
Subject: [PATCH 001/669] chore(gui): mission work logs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
chore(gui): mission work logs — cordis design finalization, tool-card wire archive, incident records
chore: missions
chore: missions
chore(gui): mission ledger — batch-2 answers, parallel dispatch state, jsdom coverage re-scope
chore(gui): ledger — night-mode standing orders (self-commit small, no push, 5-min refresh)
chore(gui): ledger — jsdom batches 2-4 landed (233 green), coverage probe next
chore(gui): ledger — web-ui coverage probe 65%, four-tier fill plan approved
chore(gui): ledger — cordis-impl B1 state after third API drop, decisions on file
chore(gui): ledger — 01:32 patrol snapshot (jsdom tier-1 landed, coverage-fixer probed)
chore(gui): ledger — 01:37 patrol (peer src trio landed, coverage-fixer still silent)
chore(gui): ledger — 01:42 patrol (jsdom tier-2 landed, peer committed x2, coverage-fixer 2nd probe)
chore(gui): ledger — coverage diagnosis complete (6-file gap list), web-ui at 91.4%
chore(gui): ledger — 01:46 patrol (B1 done, jsdom tier-3 landed, coverage fix batch running)
chore(gui): ledger — 01:51 patrol (jsdom tails x2 landed, B2 underway)
chore(gui): ledger — 01:56 patrol (gateway.ts 337 lines, checkpoint T-7min)
chore(gui): ledger — hold/pending split ruling, coverage-fixer externalize-or-restart ultimatum
chore(gui): ledger — 02:01 patrol (B2 done, jsdom final arms, coverage ultimatum pending)
chore(gui): ledger — 02:03 checkpoint executed (fixer2 respawn, four lanes released, three owners cold-started)
chore(gui): ledger — all six lanes acked, type isolation first live proof (client closure clean)
chore(gui): ledger — 02:08 patrol (all seven lanes active, wire carrier assembled)
docs(gui): respond-design task checkpoint — apiproxy wire-layer recon done
chore(gui): ledger — P0-2 contributor AGENTS.md landed (dd28a5019)
docs(gui): respond-design checkpoint 2 — host-side recon (stub respond, frame types, approval seam, ACP answerer precedent)
docs(gui): OOP debt inventory — seven territories, 2 real debts (createApiProxy, createFixtureApi), rest ruled keep-as-is
docs(gui): disambiguation note on the archived i18n design task
chore(gui): ledger — 02:12 patrol (exclude removed, mixed-knife incident under reconciliation)
chore(gui): ledger — 02:15 wave (jsdom mission closed, OOP audit done, B3 isolation proof, mixed-knife resolved)
chore(gui): ledger — 02:17 patrol (attribution reversal filed, arch-session probed)
chore(gui): ledger — 02:22 patrol (B4 done, B5+B6 merged batch, arch-session deadline set)
docs(gui): respond-design checkpoint 3 — client-side recon (pending map, PendingCard onRespond stub, AbstractApiClient.respond ready, bootHost missing approval mounts)
docs(gui): peer carrier territory review — 2 fixes (SSE cancel leak, route-reservation guard), 1 ruling ask (RPC-log visibility), compliance ledger
chore(gui): ledger — 02:27 (arch-shell respawn, territory review verdicts routed, ask-deny finding flagged)
docs(gui): P1-5 respond design page complete — pending registry, wire answerer, client state machine, first-wins arbitration
chore(gui): ledger — 02:31 patrol (respond design complete, B5 wire smoke green, shell knife 1 underway)
docs(gui): respond design — add §0 status warning (web host ask defaults to deny), mount-behavior delta, no-timeout ruling with Config discipline
chore(gui): ledger — 02:42 patrol (respond line closed pending review, B7 last piece underway)
docs(gui): respond design contract review — direction pass, 2 doc fixes (settle-order contradiction, answering-state race), A/B/C compliance ledger
docs(gui): respond design — contract review fixes (R1 verify-before-delete arbitration, R2 answering+resolved-frame transition, ask dual-source wording, rejected-is-ok-value note)
chore(gui): ledger — 02:46 patrol (respond line final, two user decisions distilled, arch-shell deadline)
docs(gui): respond review addendum — contract-gap ruling: approve plan A (ApprovalRequest.id), wire unchanged, drop plan B backscan
chore(gui): ledger — cordis B7 summit: real-browser 10/10 green, user acceptance criterion proven
docs(gui): respond design — contract gap #4 approved as plan A (ApprovalRequest.id), backscan fallback retired, blade 0 prepended
docs(gui): respond design — final polish (owner-approval vs user-go-ahead wording, implementation handoff notes)
chore(gui): ledger — 02:51 (shell-exec third respawn with operational script, respond line 4-knife final)
chore(gui): ledger — 02:53 wave (respond five-knife true final, B7 closed 12/12, client.ts green)
chore(gui): ledger — 02:56 patrol (cordis closeout bounced pending R1/R2/N1, shell-exec first sign of life)
chore(gui): ledger — 03:01 patrol (R1/R2/N1 remediation in flight across four files)
chore(gui): ledger — cordis line officially closed and archived, verified on disk (24 knives, 12/12, reviews closed)
chore(gui): ledger — 03:06 patrol (shell-exec final window, lowered first-knife bar)
chore(gui): ledger — 03:11 patrol (shell line iced-broken: three registries on disk)
chore(gui): ledger — 03:15 patrol (quiet window, both active lanes within threshold)
chore(gui): ledger — 03:20 patrol (shell five files up, api-proxy plan reported)
chore(gui): ledger — 03:25 patrol (shell migration in flight with history-preserving moves, webserver green)
chore(gui): ledger — 03:30 patrol (shell knife-1 in verification, api-proxy patching)
chore(gui): ledger — shell knife 1 accepted (694cecc53), knife 2 released
chore(gui): ledger — 03:40 patrol (knife 2 pre-move stage, cold-list spec appears)
chore(gui): ledger — 03:45 patrol (rpclog moves staged, api-proxy two specs in flight)
chore(gui): ledger — 03:54 patrol (knife-2 code done, coverage full-run final check)
docs(gui): coverage-fixer task ledger — fixer2 takeover, per-file fix log, isolated reportsDirectory pitfall
chore(gui): ledger — coverage lane closed and accepted (a19f069a5), the PR #443 CI fix knife
chore(gui): ledger — 04:04 patrol (knife-2 calibration, sole active lane)
chore(gui): ledger — shell knife 2 accepted (f8fb77b95), knife 3 released as final night task
chore(gui): ledger — 04:19 patrol (knife-3 past half: callback chain through, ToolCallDetail up)
chore(gui): ledger — night closeout summary: seven lanes closed, wake-up decision sheet
chore: missions
chore: missions
chore: missions
chore(gui): mission-local browser/probe verify scripts under missions/scripts/
The six acceptance/probe scripts move here as mission-side working
material (headers and relative imports adjusted for the new location):
carrier-errors, rpclog-panel, session, session-real,
webserver-backpressure, webserver-hardening.
chore(gui): verify-relocate mission log
chore(gui): verify-relocate mission log — R1 guard addendum
chore(gui): gates-continue mission log — CI-equivalent sequence all green
chore(gui): VS Code 扩展体系双边设计调研报告
chore(gui): 调研追加 4.5 节——git 扩展数据面与 scope 绑定
docs(gui): web plugin system RFC — walkthrough + design notes
docs(gui): RFC — restore existing SSE/POST as the v1 transport; envelope rides on it (D16)
docs(gui): RFC — envelope demoted to chan-dispatch, scope out of envelope, rpc-log cut, peer deferred, scope tree is native cordis (D17-D21)
docs(gui): RFC — hooks re-derived from component needs: useWatch/useAction only, useService removed; sessionHub cut, projections user-space, router rename, loader-only root (D22-D25)
docs(gui): RFC — drop stale fork vocabulary (vendored cordis has Fiber only; scope = mintScope pattern), hook idempotence contract (D26-D27)
docs(gui): RFC — session precision seam: plugins read scope key (host paradigm), React gets it from tree position via SlotOutlet (D28)
docs(gui): RFC — domain hooks owned by plugins over framework primitives; useConversation paradigm carried over (D29)
docs(gui): RFC — ctx services are the inter-plugin API (cordis proper); declarations are wire-only; get(id) returns scoped ctx (D30)
docs(gui): RFC — full ctx.conversation walkthrough: root-singleton scope-sensitive service, caller-ctx scope key, get(key) as scoped ctx (D31)
docs(gui): RFC — no client-side agents collection: session state machine already expresses the duality; agent resolution stays host authority (D32)
docs(gui): RFC — v1 stays session-precision, no agent-level isolation; incarnation/agent-axis designs archived in ledger (D33)
docs(gui): RFC walkthrough — full rewrite to final state (D16-D33 consolidated), end-to-end chain restored
docs(gui): RFC — apiproxy demoted to generic channel routing; domain RPCs dissolve into owner plugins (D34)
docs(gui): RFC — TS-interface-first wire contract (zod internal), conversation owns the dialogue frame with pluggable views (D35)
docs(gui): RFC — page skeleton (sidebar+conversation), projects as plugin not service, nested slots via owner registries (D36)
docs(gui): RFC — SlotMap declaration-merging slot model: single register API, inject-as-ownership, FC-typed registration, typed outlets (D37)
docs(gui): RFC — slot props whitelist: identity, display params, materialized snapshot slices, stable UI callbacks (D38)
docs(gui): RFC — end-to-end data flow: three transforms, equality protocol table, immer placement; i18n/theme kept standard (D39-D40)
docs(gui): RFC — full external-injection model: props carry values + stable injected hooks; shared/client/react example rewritten (D41-D43)
docs(gui): RFC final trio — modules.md (agent implementation spec), architecture.md (human walkthrough), plugins.md (business plugin inventory)
docs(gui): RFC — props three-source merge (scope-standard useSession auto-injected); keyed key vs list id disambiguated (D44)
docs(gui): RFC — inject comment says what it is (the React-facing props bundle); SessionHandle rename; snapshot-production story unified on buildSnapshot
docs(gui): RFC architecture — full React component tree walkthrough: props three sources, slot vs plain children, hook taxonomy per node
docs(gui): RFC — module map finalized (ui-slots/web-react/connection/runtime/ui-*/web); slots onChange replaced by cordis events; toolcall dimension; detail sidebar default-collapsed with toolName-keyed routing
docs(gui): RFC plugins — openDetail relay chain: toolcard calls chat-view injected action, chat-view relays to conversation sidebar
chore(gui): progress ledger — full archive rewrite: RFC outcome digest, open gaps, dispatch plan, cold-start entry
docs(gui): RFC grill pass 1 — SlotScope axis (root/session) on declares, Gate dependency inversion, inject handle by scope, W5 acceptance list, gantt relay chain fixed
docs(gui): figma analysis — sidebar/projects/sessions 区域交互视觉理解报告
docs(gui): figma 解析报告 — details 面板/多视图 tabs/未来功能区盘点 + slot 需求清单
docs(gui): figma 对话主区解析报告 — 消息流/tool calls 变体/审批接管输入框/Header tabs/视觉 token
docs(gui): plugins.md rewritten from figma analysis — three-column layout, full slot reservation table, selection channel, composer-takeover approvals, phased scope
docs(gui): layout dynamics ruled (drag+collapse both rails, details yields first, composer swap-panel, same-component transition); toolviews promoted to named scope-aware registry
docs(gui): P-I scope locked (details minimal, dual theme, chat-view, custom toolview sample); teammate dispatch plan — 6 owners by package, dependency-driven waves, contract arbitration
docs(gui): P-I api-contracts (full inter-package API spec) + dispatch plan (T0 skeleton knife, 7-dev roster, task briefs, milestones)
docs(gui): api-contracts v2 — scope tree in P-I, bundle loader + per-plugin CSS isolation in P-I, agent-scoped toolviews live, zustand engine, renames (SessionProvider/ObservableSnapshot/SessionBinding), router owns all shell view-state
docs(gui): services roster + progressive loading (no blocking loadAll), SlotsService as real cordis Service, renderSlot/renderSuspenseSlot duo, ui-traj teammate
docs(gui): loading-chain gaps ruled — dev=rebundle no HMR, ui-primitives package, externals on globals (no import map), host injects __DSH_BOOT__ into HTML (zero round-trip)
docs(gui): api-contracts v3 + dispatch v2 final — 12 packages, services merged in, progressive loader, global externals, __DSH_BOOT__ injection, 8-dev roster with convo split and ui-traj
docs(gui): v3 amendments — router renamed ctx.layout, ui-trajectory has no service (pure consumer sample), wait-for-settled loading (no Suspense in P-I, ledger 6b)
chore(gui): progress — pre-compact final state: v3 revision chain, 8-dev roster, T0 procedure, doc authority order
docs(gui): authority banners — modules/architecture get v3 term-mapping headers, walkthrough marked as archived process doc
docs(gui): cssdesign token set is THE theme source (--dsw-* variables, data-ds-dark-theme switch); recorded in contracts + progress
docs(gui): architecture.md full v3 rewrite — loading chain, 12-package map, service roster, slot/inject/toolviews, data flow, component tree, perf model, all current
docs(gui): contracts — UI plugins are dual-entry host plugins (node half serves client asset via ctx.webPlugins; __DSH_BOOT__ derives from it; client-closure gate back in scope)
docs(gui): contracts — closure-factory bundles with DI require (no globals), package.json dshWeb declarative discovery (no serve ritual), create-then-send empty state with project picker, ancestry() for breadcrumb, unload stubbed until HMR, props.renderSlot confirmed
docs(gui): dshClient declaration (inject/platform/immediately, exports./client), closure-DI require loading — synced across contracts/dispatch/modules/architecture/walkthrough
chore(gui): progress — record final loading-chain rulings (dshClient declaration, closure-DI require, startSession) before compact
docs(gui): architecture.md — developer-facing whole-web architecture on master baseline 6b16a67cb: what exists, what is new, no process narrative
docs(gui): architecture.md — self-contained whole-web architecture: absorbs still-valid substance from the branch RFCs (host layering, four-quadrant RPC, object layer, testing tiers) under the new plugin system as the override
chore(gui): progress — final pre-compact snapshot: contracts digest, apiproxy purity ruling, T0 procedure with first-action list
docs(gui): api-contracts v3 §3.1 — apiproxy purity principle with three-way existing-code verdicts
docs(gui): api-contracts v3 — immediately reinterpreted as static-infra group (8-package dshClient scope, boot manifest reconciliation)
docs(gui): api-contracts v3 — immediately corrected to early-load dynamic group (prod shell must not rebundle); loader shell-held; bundles register their export surface into module table
docs(gui): architecture — align with immediately=early-load dynamic group ruling; loader shell-held; module-table registration of loaded bundles
docs(gui): T0 checklist — 12-package skeleton table, 4-cut sequence, mv/attic/rewire rules (pre-drafted, awaiting go)
docs(gui): t0-checklist — pin figma-flows findings (missing font-family base vars, three alias vars behind upstream)
docs(gui): dispatch v2.1 — drop cordis-web salvage wording, two-wave staffing, loader/immediately boundary updates
docs(gui): progress + t0-checklist ledger — T0 landed, staffing status, execution accounting
docs(gui): api-contracts v3 — arbitration round 1: renderBody deps, RootBindingProvider, flush default sync, prune current, loader subpath, config-source P-I bar
docs(gui): v3 §3.2 connection 导出清单附录(rt-core 对账)+ rt-core 实现计划档案
docs(gui): progress — T1 milestone, arbitration round 1 ledger, fw-react timeout escalation
docs(gui): progress rolling update — per-line battlefield state at 00:2x, mailbox-vs-contract lesson, small-batch discipline reinforced
docs(gui): fw-react notes — v3 §2 complete, seven knives, T1/T2 follow-ups
docs(fw-slots): archive — four packages landed, open tails logged
docs(gui): progress — framework layer complete (web-react five, fw-slots four packages), T2 gated on rt-core runtime knife only
docs: api-contracts
docs: style-spec
docs
docs(gui): tsconfig convergence ruling — no host.json, root resumes host-aggregate duty, typecheck = root + client aggregates
docs(gui): missions 根三份 07-18 世代档案加「已被取代」头注——指向 web-plugin-rfc 现行权威并注明新旧对应
missions
docs(gui): progress rewritten for post-closeout state — wave ledger, architecture finale, teammate roster with handover notes, pending-user-command queue
---
missions/conventions.md | 37 +
missions/plan.md | 33 +
missions/progress.md | 58 ++
missions/scripts/verify-carrier-errors.mjs | 113 +++
missions/scripts/verify-rpclog-panel.mjs | 99 ++
missions/scripts/verify-session-real.mjs | 132 +++
missions/scripts/verify-session.mjs | 361 ++++++++
.../scripts/verify-webserver-backpressure.mjs | 45 +
.../scripts/verify-webserver-hardening.mjs | 37 +
.../README.md | 34 +
.../deepseekchat-baseline.md | 74 ++
.../design.md | 636 +++++++++++++
.../harness-boot-facts.md | 184 ++++
.../README.md | 68 ++
.../core-coverage.md | 170 ++++
.../design.md | 413 +++++++++
.../opencode-crosscheck.md | 17 +
.../findings.md | 72 ++
.../tasks/20260719-2036-step1-impl/README.md | 57 ++
.../20260719-2039-rpc-vs-jsonrpc/README.md | 18 +
.../20260719-2039-rpc-vs-jsonrpc/findings.md | 87 ++
.../tasks/20260719-2119-step2-impl/README.md | 47 +
.../README.md | 30 +
.../design.md | 539 +++++++++++
.../README.md | 44 +
.../design.md | 865 ++++++++++++++++++
.../20260719-2315-style-research/README.md | 37 +
.../shot-rpclog.mjs | 29 +
.../style-research.md | 218 +++++
.../upgrade-rpclog-v2.md | 22 +
.../20260719-2339-web-cordis-design/README.md | 7 +
.../blueprint-v2.md | 206 +++++
.../bundle-loader-design.md | 119 +++
.../20260719-2339-web-cordis-design/design.md | 170 ++++
.../README.md | 24 +
.../design.md | 323 +++++++
.../20260720-0206-rfc-consolidation/README.md | 35 +
.../outline.md | 194 ++++
.../rfc-list-proposal.md | 79 ++
.../20260720-0246-inputbar-fix/README.md | 47 +
.../tasks/20260720-0246-inputbar-fix/bugs.md | 65 ++
.../20260720-0246-inputbar-fix/compare.md | 32 +
.../20260720-0246-inputbar-fix/probe.mjs | 155 ++++
.../20260720-0246-inputbar-fix/shot-theme.mjs | 33 +
.../tasks/20260720-0246-inputbar-fix/shot.mjs | 36 +
.../20260720-0250-comment-sweep/README.md | 46 +
.../README.md | 17 +
.../audit.md | 90 ++
.../notes.md | 90 ++
.../tasks/20260720-0337-test-design/README.md | 6 +
.../tasks/20260720-0337-test-design/design.md | 279 ++++++
.../README.md | 7 +
.../multiclient-e2e.mjs | 73 ++
.../report.md | 90 ++
.../streaming-observe.mjs | 40 +
.../three-tabs-pool.mjs | 50 +
.../two-tabs-pool.mjs | 50 +
.../20260720-0920-settings-research/README.md | 15 +
.../settings-inventory.md | 84 ++
.../20260720-1030-feature-session/README.md | 70 ++
.../exports-survey.md | 83 ++
.../20260720-1238-history-rebase/README.md | 45 +
.../tasks/20260720-1315-docs-float/README.md | 39 +
.../tasks/20260720-1440-pr-gates/README.md | 65 ++
.../20260720-1545-arch-session/README.md | 121 +++
.../tasks/20260720-1545-web-test/README.md | 51 ++
.../20260720-1620-cordis-spike/README.md | 240 +++++
.../poc/app/client-apiproxy-probe.ts | 10 +
.../poc/app/client-main.ts | 22 +
.../poc/app/client-violation-main.ts | 17 +
.../poc/app/escape-dts-import-type.ts | 16 +
.../poc/app/escape-paths-override.ts | 9 +
.../poc/app/escape-src-channel.ts | 9 +
.../poc/app/negative.ts | 15 +
.../poc/app/node-main.ts | 15 +
.../poc/app/smuggle-chain.ts | 13 +
.../poc/app/v1-misuse-main.ts | 10 +
.../poc/clean-program-files.txt | 24 +
.../poc/echo-a/package.json | 14 +
.../poc/echo-a/src/client.ts | 14 +
.../poc/echo-a/src/node.ts | 16 +
.../poc/echo-a/src/shared.ts | 13 +
.../poc/echo-b/package.json | 15 +
.../poc/echo-b/src/node.ts | 23 +
.../poc/echo-c/package.json | 13 +
.../poc/echo-c/src/node.ts | 14 +
.../poc/echo-d/package.json | 10 +
.../poc/echo-d/src/node.ts | 19 +
.../poc/gate-api.mjs | 169 ++++
.../20260720-1620-cordis-spike/poc/gate.mjs | 75 ++
.../poc/ide/client/main.ts | 25 +
.../poc/ide/client/tsconfig.json | 8 +
.../poc/ide/node/main.ts | 15 +
.../poc/ide/node/tsconfig.json | 7 +
.../poc/tsconfig.client-apiproxy.json | 5 +
.../poc/tsconfig.client-violation.json | 9 +
.../poc/tsconfig.client.json | 8 +
.../poc/tsconfig.escape-dts.json | 5 +
.../poc/tsconfig.escape-paths.json | 44 +
.../poc/tsconfig.escape-src.json | 5 +
.../poc/tsconfig.node.json | 7 +
.../poc/tsconfig.shared.json | 38 +
.../poc/tsconfig.v1-misuse.json | 5 +
.../poc/types/buffer-shim.d.ts | 7 +
.../poc/types/nodejs-timeout-shim.d.ts | 8 +
.../20260720-1630-arch-carrier/README.md | 47 +
.../20260720-1640-gui-arch-research/README.md | 33 +
.../feature-list.md | 95 ++
.../20260720-1640-gui-arch-research/report.md | 233 +++++
.../README.md | 23 +
.../survey.md | 170 ++++
.../20260720-1709-dsc-dsh-rename/README.md | 67 ++
.../README.md | 22 +
.../report.md | 204 +++++
.../20260720-1900-toolcard-wire/design.md | 71 ++
.../20260720-2125-gateway-design/README.md | 154 ++++
.../20260720-2125-gateway-design/design.md | 651 +++++++++++++
.../20260720-2131-regroup-32-to-10/README.md | 32 +
.../20260720-2149-pr-gates-round2/README.md | 45 +
.../tasks/20260720-2202-translator/README.md | 3 +
.../20260720-2224-gate-finisher/README.md | 3 +
.../tasks/20260720-2325-i18n-design/README.md | 31 +
.../tasks/20260720-2325-i18n-design/design.md | 177 ++++
.../agents-md-contribution.md | 34 +
.../component.spec.tsx.template | 52 ++
.../pure-data.spec.ts.template | 31 +
.../tasks/20260721-0041-app-shell/design.md | 115 +++
.../tasks/20260721-0041-app-shell/impl-log.md | 77 ++
.../20260721-0041-web-agents-md/README.md | 51 ++
.../20260721-0054-coverage-fixer/README.md | 45 +
.../tasks/20260721-0120-jsdom-e2e/README.md | 43 +
.../20260721-0209-respond-design/README.md | 62 ++
.../20260721-0209-respond-design/design.md | 107 +++
.../tasks/20260721-0210-oop-debt/report.md | 45 +
.../review.md | 41 +
.../review.md | 64 ++
.../20260721-1140-verify-relocate/README.md | 44 +
.../20260721-1250-gates-continue/README.md | 35 +
.../report.md | 280 ++++++
.../api-contracts.md | 433 +++++++++
.../architecture.md | 334 +++++++
.../clientcontext-audit.md | 120 +++
.../cssdesign/design-platform.css | 326 +++++++
.../cssdesign/gradient-shadow-text.css | 224 +++++
.../design-notes.md | 259 ++++++
.../20260721-1520-web-plugin-rfc/dispatch.md | 100 ++
.../figma-analysis/conversation.md | 205 +++++
.../figma-analysis/flows-future.md | 278 ++++++
.../figma-analysis/sidebar.md | 139 +++
.../20260721-1520-web-plugin-rfc/modules.md | 289 ++++++
.../20260721-1520-web-plugin-rfc/plugins.md | 224 +++++
.../style-spec.md | 336 +++++++
.../t0-checklist.md | 82 ++
.../w5-visual-verdict.md | 261 ++++++
.../walkthrough.md | 424 +++++++++
missions/tasks/20260721-p1-fw-react/notes.md | 81 ++
missions/tasks/20260721-p1-fw-slots/notes.md | 184 ++++
.../history-timing-data.md | 42 +
missions/tasks/20260721-p1-rt-core/notes.md | 155 ++++
missions/tasks/20260721-p1-ui-shell/notes.md | 164 ++++
missions/tasks/20260722-p1-convo-a/plan.md | 50 +
missions/tasks/20260722-p1-ui-side/notes.md | 49 +
missions/tasks/20260722-p1-ui-traj/plan.md | 22 +
missions/ui-product.md | 221 +++++
missions/ui-tech.md | 309 +++++++
165 files changed, 17001 insertions(+)
create mode 100644 missions/conventions.md
create mode 100644 missions/plan.md
create mode 100644 missions/progress.md
create mode 100644 missions/scripts/verify-carrier-errors.mjs
create mode 100644 missions/scripts/verify-rpclog-panel.mjs
create mode 100644 missions/scripts/verify-session-real.mjs
create mode 100644 missions/scripts/verify-session.mjs
create mode 100644 missions/scripts/verify-webserver-backpressure.mjs
create mode 100644 missions/scripts/verify-webserver-hardening.mjs
create mode 100644 missions/tasks/20260719-1843-step1-skeleton-design/README.md
create mode 100644 missions/tasks/20260719-1843-step1-skeleton-design/deepseekchat-baseline.md
create mode 100644 missions/tasks/20260719-1843-step1-skeleton-design/design.md
create mode 100644 missions/tasks/20260719-1843-step1-skeleton-design/harness-boot-facts.md
create mode 100644 missions/tasks/20260719-1902-apiproxy-api-design/README.md
create mode 100644 missions/tasks/20260719-1902-apiproxy-api-design/core-coverage.md
create mode 100644 missions/tasks/20260719-1902-apiproxy-api-design/design.md
create mode 100644 missions/tasks/20260719-1902-apiproxy-api-design/opencode-crosscheck.md
create mode 100644 missions/tasks/20260719-1902-opencode-api-research/findings.md
create mode 100644 missions/tasks/20260719-2036-step1-impl/README.md
create mode 100644 missions/tasks/20260719-2039-rpc-vs-jsonrpc/README.md
create mode 100644 missions/tasks/20260719-2039-rpc-vs-jsonrpc/findings.md
create mode 100644 missions/tasks/20260719-2119-step2-impl/README.md
create mode 100644 missions/tasks/20260719-2140-ui-milestone1-design/README.md
create mode 100644 missions/tasks/20260719-2140-ui-milestone1-design/design.md
create mode 100644 missions/tasks/20260719-2247-step-session-design/README.md
create mode 100644 missions/tasks/20260719-2247-step-session-design/design.md
create mode 100644 missions/tasks/20260719-2315-style-research/README.md
create mode 100644 missions/tasks/20260719-2315-style-research/shot-rpclog.mjs
create mode 100644 missions/tasks/20260719-2315-style-research/style-research.md
create mode 100644 missions/tasks/20260719-2315-style-research/upgrade-rpclog-v2.md
create mode 100644 missions/tasks/20260719-2339-web-cordis-design/README.md
create mode 100644 missions/tasks/20260719-2339-web-cordis-design/blueprint-v2.md
create mode 100644 missions/tasks/20260719-2339-web-cordis-design/bundle-loader-design.md
create mode 100644 missions/tasks/20260719-2339-web-cordis-design/design.md
create mode 100644 missions/tasks/20260720-0101-hostruntime-split-design/README.md
create mode 100644 missions/tasks/20260720-0101-hostruntime-split-design/design.md
create mode 100644 missions/tasks/20260720-0206-rfc-consolidation/README.md
create mode 100644 missions/tasks/20260720-0206-rfc-consolidation/outline.md
create mode 100644 missions/tasks/20260720-0206-rfc-consolidation/rfc-list-proposal.md
create mode 100644 missions/tasks/20260720-0246-inputbar-fix/README.md
create mode 100644 missions/tasks/20260720-0246-inputbar-fix/bugs.md
create mode 100644 missions/tasks/20260720-0246-inputbar-fix/compare.md
create mode 100644 missions/tasks/20260720-0246-inputbar-fix/probe.mjs
create mode 100644 missions/tasks/20260720-0246-inputbar-fix/shot-theme.mjs
create mode 100644 missions/tasks/20260720-0246-inputbar-fix/shot.mjs
create mode 100644 missions/tasks/20260720-0250-comment-sweep/README.md
create mode 100644 missions/tasks/20260720-0300-web-dev-2-onboarding/README.md
create mode 100644 missions/tasks/20260720-0300-web-dev-2-onboarding/audit.md
create mode 100644 missions/tasks/20260720-0300-web-dev-2-onboarding/notes.md
create mode 100644 missions/tasks/20260720-0337-test-design/README.md
create mode 100644 missions/tasks/20260720-0337-test-design/design.md
create mode 100644 missions/tasks/20260720-0356-multiclient-research/README.md
create mode 100644 missions/tasks/20260720-0356-multiclient-research/multiclient-e2e.mjs
create mode 100644 missions/tasks/20260720-0356-multiclient-research/report.md
create mode 100644 missions/tasks/20260720-0356-multiclient-research/streaming-observe.mjs
create mode 100644 missions/tasks/20260720-0356-multiclient-research/three-tabs-pool.mjs
create mode 100644 missions/tasks/20260720-0356-multiclient-research/two-tabs-pool.mjs
create mode 100644 missions/tasks/20260720-0920-settings-research/README.md
create mode 100644 missions/tasks/20260720-0920-settings-research/settings-inventory.md
create mode 100644 missions/tasks/20260720-1030-feature-session/README.md
create mode 100644 missions/tasks/20260720-1030-feature-session/exports-survey.md
create mode 100644 missions/tasks/20260720-1238-history-rebase/README.md
create mode 100644 missions/tasks/20260720-1315-docs-float/README.md
create mode 100644 missions/tasks/20260720-1440-pr-gates/README.md
create mode 100644 missions/tasks/20260720-1545-arch-session/README.md
create mode 100644 missions/tasks/20260720-1545-web-test/README.md
create mode 100644 missions/tasks/20260720-1620-cordis-spike/README.md
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/client-apiproxy-probe.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/client-main.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/client-violation-main.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/escape-dts-import-type.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/escape-paths-override.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/escape-src-channel.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/negative.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/node-main.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/smuggle-chain.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/v1-misuse-main.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/clean-program-files.txt
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-a/package.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-a/src/client.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-a/src/node.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-a/src/shared.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-b/package.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-b/src/node.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-c/package.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-c/src/node.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-d/package.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-d/src/node.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/gate-api.mjs
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/gate.mjs
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/ide/client/main.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/ide/client/tsconfig.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/ide/node/main.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/ide/node/tsconfig.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.client-apiproxy.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.client-violation.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.client.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.escape-dts.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.escape-paths.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.escape-src.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.node.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.shared.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.v1-misuse.json
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/types/buffer-shim.d.ts
create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/types/nodejs-timeout-shim.d.ts
create mode 100644 missions/tasks/20260720-1630-arch-carrier/README.md
create mode 100644 missions/tasks/20260720-1640-gui-arch-research/README.md
create mode 100644 missions/tasks/20260720-1640-gui-arch-research/feature-list.md
create mode 100644 missions/tasks/20260720-1640-gui-arch-research/report.md
create mode 100644 missions/tasks/20260720-1652-components-research/README.md
create mode 100644 missions/tasks/20260720-1652-components-research/survey.md
create mode 100644 missions/tasks/20260720-1709-dsc-dsh-rename/README.md
create mode 100644 missions/tasks/20260720-1749-tool-render-research/README.md
create mode 100644 missions/tasks/20260720-1749-tool-render-research/report.md
create mode 100644 missions/tasks/20260720-1900-toolcard-wire/design.md
create mode 100644 missions/tasks/20260720-2125-gateway-design/README.md
create mode 100644 missions/tasks/20260720-2125-gateway-design/design.md
create mode 100644 missions/tasks/20260720-2131-regroup-32-to-10/README.md
create mode 100644 missions/tasks/20260720-2149-pr-gates-round2/README.md
create mode 100644 missions/tasks/20260720-2202-translator/README.md
create mode 100644 missions/tasks/20260720-2224-gate-finisher/README.md
create mode 100644 missions/tasks/20260720-2325-i18n-design/README.md
create mode 100644 missions/tasks/20260720-2325-i18n-design/design.md
create mode 100644 missions/tasks/20260721-0010-gate-friendly/agents-md-contribution.md
create mode 100644 missions/tasks/20260721-0010-gate-friendly/component.spec.tsx.template
create mode 100644 missions/tasks/20260721-0010-gate-friendly/pure-data.spec.ts.template
create mode 100644 missions/tasks/20260721-0041-app-shell/design.md
create mode 100644 missions/tasks/20260721-0041-app-shell/impl-log.md
create mode 100644 missions/tasks/20260721-0041-web-agents-md/README.md
create mode 100644 missions/tasks/20260721-0054-coverage-fixer/README.md
create mode 100644 missions/tasks/20260721-0120-jsdom-e2e/README.md
create mode 100644 missions/tasks/20260721-0209-respond-design/README.md
create mode 100644 missions/tasks/20260721-0209-respond-design/design.md
create mode 100644 missions/tasks/20260721-0210-oop-debt/report.md
create mode 100644 missions/tasks/20260721-0223-peer-carrier-review/review.md
create mode 100644 missions/tasks/20260721-0239-respond-contract-review/review.md
create mode 100644 missions/tasks/20260721-1140-verify-relocate/README.md
create mode 100644 missions/tasks/20260721-1250-gates-continue/README.md
create mode 100644 missions/tasks/20260721-1330-vscode-extension-research/report.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/api-contracts.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/architecture.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/clientcontext-audit.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/cssdesign/design-platform.css
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/cssdesign/gradient-shadow-text.css
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/design-notes.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/dispatch.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/figma-analysis/conversation.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/figma-analysis/flows-future.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/figma-analysis/sidebar.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/modules.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/plugins.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/style-spec.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/t0-checklist.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/w5-visual-verdict.md
create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/walkthrough.md
create mode 100644 missions/tasks/20260721-p1-fw-react/notes.md
create mode 100644 missions/tasks/20260721-p1-fw-slots/notes.md
create mode 100644 missions/tasks/20260721-p1-rt-core/history-timing-data.md
create mode 100644 missions/tasks/20260721-p1-rt-core/notes.md
create mode 100644 missions/tasks/20260721-p1-ui-shell/notes.md
create mode 100644 missions/tasks/20260722-p1-convo-a/plan.md
create mode 100644 missions/tasks/20260722-p1-ui-side/notes.md
create mode 100644 missions/tasks/20260722-p1-ui-traj/plan.md
create mode 100644 missions/ui-product.md
create mode 100644 missions/ui-tech.md
diff --git a/missions/conventions.md b/missions/conventions.md
new file mode 100644
index 0000000000..fc3139a495
--- /dev/null
+++ b/missions/conventions.md
@@ -0,0 +1,37 @@
+# GUI 项目工作约定(用户历次拍板沉淀;对所有参与者生效)
+
+> 本文件 = 本项目内的持久规矩。全局个人偏好在 Claude 记忆里;这里只放**这个项目**的要求。架构类决策不在此(见 docs/rfc/ 四篇与 docs/web-styling.md)。
+
+## 流程与协作
+
+1. **设计先行,文档给用户 review**:新领域先出设计文档(missions/tasks/ 归档)经用户过目再编码;量小或机械照抄类可直接生码,但契约/架构变更必须先改文档。
+2. **teammate 组织**:耗时任务开 background teammate;干完不 kill 保持存活当长期 owner(后续变更 SendMessage 派发);会话断了从 missions/tasks/ 归档冷启动同名 owner。设计 owner 兼任本领域实现 dispatcher;worker 反复超时时 owner 直接下场写。
+3. **小步快跑**(网络慢易超时):文件改动分批落盘(每批几分钟内)、思考外化、每批一句话回执;产出零落盘超过约 5 分钟即视为可疑。
+4. **commit 纪律**:`--no-verify` 跳过门禁(GUI 免门禁期);不同性质的改动分刀提交(注释类落盘即提不攒批,不与功能改动混);RFC 独立成刀(回刷时要整体挪位)。工作区里其他 teammate 的在途文件不许混入自己的 commit。commit message 不携带 Co-Authored-By 等 co-auth 尾注。
+ - **门禁按 PR 周期收口**(用户 2026-07-20 定):测试/门禁只在提 PR 的窗口集中修(2026-07-20 首次 PR 已修过一轮);平时快速开发不随手写测试、不盯门禁;期间弄红存量测试记台账不追修,下次 PR 窗口统一算账。分层结构与文件落点(包级 `tests/`、`.spec.ts` 命名)从第一天守全仓惯例——PR 窗口收口的只是阈值与红绿,不是搬迁。
+ - **文档住顶刀**:GUI 文档(missions/、docs/rfc/、docs/ui-*.md、docs/web-styling.md)集中在最顶部的 docs commit,底部实现历史不含文档。每次代码改动的提交顺序:先把文档改动提交完,再开新 commit 改代码;被代码刀压下去的文档 commit,找时间 rebase 重排合并回最顶(重排铁律:终树 diff 为零)。
+5. **跨属地改动**:动别人属地的代码先报告/事后备案给属地 owner;契约有误先改契约文档再让实现照抄;发现契约缺口只报告不擅改。
+6. **验收自动化**:UI 交付前 agent 自己跑 playwright(chromium headless)过验收清单,不留给用户手验;每修一个 bug 钉一条防回归断言进 verify 脚本;「fixture 全绿」不算完——真 host 级也要过(fixture 掩盖时序 bug 有两次实证)。
+7. **进度可见**:用户要求时开 5 分钟巡检(盘上核实+表格同步+催落后线);巡检探针只用安全 URL。
+7a. **未答问题不得代答**(用户 2026-07-21 定,起因:主会话在提问超时后擅自「代拍」并据此派工):向用户发出的问题若未获回答(超时/离席),该问题**保持未决**——不许以「推荐项/合理默认」自动代答,不许基于代答派发任何工作;只能执行此前已获明确授权的部分,未决项对应的工作线整体挂起等答复。「用户睡觉/全自动模式」也不例外:自动化只覆盖已拍口径内的执行,不覆盖替用户做新决策。
+
+## 代码与文档
+
+8. **代码注释一律英文且少写**:只留非显然契约/约束/防坑(如 Node 16 req 'close' 语义);不写叙述性/复述代码/评审史;中文只用于 missions/docs 文档(供用户 review)。产品 UI 文案中文,不算注释。
+9. **注释不引用工作记录**:禁止引 missions/tasks/*.md、设计稿节号、裁决时间戳——首选注释自含说清约束;确需出处才引 docs/rfc/ 正式 RFC。
+10. **产物分流**:截图进 .artifacts/(gitignored);有归档价值的验收脚本进 scripts/;一次性诊断脚本进 ignore 目录。
+11. **命名规则**:packages/host/*、packages/client/* 的包名必含目录前缀(dsh-host-*、dsh-client-*);全仓 rename 走冻结窗口一次改完。
+12. **妥协台账三段式**:设计文档的不做清单写【触发条件(具体到事件)→ 返工点 → 预埋要求】,不写模糊的「将来优化」。
+13. **RFC 是活文档**:大改动落地后主动扫时效更新,不等用户提醒;面向开发者体裁(现状+怎么开发),取舍原因短写。
+
+## 架构红线(详见 RFC,此处仅提醒高频踩点)
+
+14. store 无业务对象(sessions/connection 走 OOP 对象层+useSyncExternalStore);视图选中态等 UI 局部事实不进全局 store。
+15. rpcId 严格双向(发起方 mint、应答方回填),但业务函数签名只见 RpcRequest
封装,mint 收在载体层。
+16. 逻辑面(hooks/对象层)与展示面(纯 props 组件)分离——组件是耗材会重做。
+17. Notifier 双通道纪律:仅用户手势直接回响可用 notifyNow,帧驱动一律 markDirty 合批。
+18. **web 是纯呈现层,呈现物不进 session log**(用户 2026-07-20 定):log 只记模型真正经历的事;「怎么画」类数据(tool 卡 view、queue 排队态等控制面)一律 host 现算随帧下发或 live 帧推送,不持久化——重放时按当时能力重算,算不出就回退通用形态(documented-default)。
+
+## 终局工程(已定待执行)
+
+18. RFC 中英文提交后**回刷历史 commit**:消掉 missions 工作记录、RFC 插历史配对、历史注释转英(映射表在 tasks/20260720-0250-comment-sweep/);执行时冻结所有其他工作。web-cordis 设计归档列删除豁免(用户自改)。
diff --git a/missions/plan.md b/missions/plan.md
new file mode 100644
index 0000000000..07f6421f90
--- /dev/null
+++ b/missions/plan.md
@@ -0,0 +1,33 @@
+> **【已被取代——历史档案】** 本文是 GUI 立项期的原始任务书(07-18 前)。现行权威=`missions/tasks/20260721-1520-web-plugin-rfc/` 的 api-contracts.md v3(接口契约)+ architecture.md(架构讲解):当年的「后台 server + React 壳」已演进为 host/client 双 cordis 插件树(12 个 packages/client/* 包、bundle loader 动态装载);「设置页/Provider 配置」未进 P-I 范围。滚动进度见 missions/progress.md。
+
+DO NOT read AGENTS.md / CLAUDE.md in this project !!
+
+要做的 PLAN
+1. 需要做一个 Harness 的 UI 架构,设计模型上同时考虑 TUI / Electron / WebUI 同时对接,目前看到 opencode 的分层挺好的。
+ 1. WebUI: 基于本地 server localhost + 消息协议
+ 2. Electron: renderer 与 WebUI 相同,main 仅用于处理桌面专用功能(窗口、更新、Menu)
+2. 当前实现Web localhost (后台 server 模式).
+3. Web 整体架构基于 React + vite 可见参考项目 DeepSeek Chat
+4. Web 设计上需要引入 Cordis Context (虽然现在不用每个组件都引入,但是先保证有一个 root Context ,能初始化基础 Service 上去,作为 与 React 对等根的形式)
+ 1. 考虑到合理性诉求,需要确认 vscode 当前的插件隔离模型
+5. 首先还是得实现一个简易的对话流和设置页
+ 1. Session 选择:当前一共有多少个 sessionId ,按时间列
+ 2. 对话流:输入框、流式输入、Markdown 展示,tool 显示,发送排队,数据走 SSE/WebSocket 不确定
+ 3. 设置页:Provider 配置/APIKEY 配置、模型列表
+
+最新调研结论,在
+- missions/ui-product.md
+- missions/ui-tech.md
+
+
+前提:
+- 说中文,记录中文
+- 当前主会话任务非常繁忙,如果有各类调研和编码任务,请启动 agent team subagent (background,不阻塞主会话)。
+ - 主会话可以创建 dispatcher,dispatcher 可以创建 worker。
+ - dispatcher 负责干完整命题,worker 负责干具体耗时任务,交由dispatcher进行汇总。主会话负责表格化同步所有任务进展
+- 主会话和 subagent 的所有工作,需要在 missions/tasks/$具体任务$ 中按照时间(精确到分钟)-任务名归档,边干边记录变化,便于回溯
+- 当前 deepseep-harness 项目不需要先投入时间经历分析,先搞其他
+
+参考项目地址可以访问:
+- opencode: /weka-hg/prod/deepseek/permanent/ys/private/workspace/github/opencode
+- deepseekchat: /weka-hg/prod/deepseek/permanent/ys/private/workspace/gitlab/deepsuite-frontend
diff --git a/missions/progress.md b/missions/progress.md
new file mode 100644
index 0000000000..0d6673e862
--- /dev/null
+++ b/missions/progress.md
@@ -0,0 +1,58 @@
+# GUI 项目进度账本
+
+> 2026-07-22 13:2x 版(P-I 已收口+两轮收尾波次完成)。本版=下次冷启动唯一入口;施工期逐日流水已压缩,细节见 git log 与 missions/tasks/ 档案。
+
+## 一、当前态(2026-07-22 13:2x)
+
+- **P-I(UI 插件化系统全链路)已完成并收口**:T0-T5 全里程碑达成,W5 验收通过(真 key 真 host 真模型 8/8 动线+figma 逐屏判定高 0 中 0 低 6+回归钉全入库)。**严禁 push/merge——留用户本人**;conventions 7a 未答问题不得代答。
+- **基线**:用户已两轮 rebase/squash——远端 worktree-web2 = origin/master 合并串 + `93d6adea1`(全部产品代码一刀)+ `8b428cb14`(missions 档案一刀);其上叠本地后续刀。**missions/ 假设最终不进 PR**——一切对外文档(RFC/README/docs)必须自含,不得引用 missions。
+- **在途(唯一)**:rt-core 的 RFC①(gui-layering-and-rpc-protocol 双语对)落库后**全线暂停**(用户令)。
+- **待用户令**:①对新远端基线的 rebase 叠刀(操作同前两轮:`git rebase --onto origin/worktree-web2 <本地对应点>`,本地对应点=用户指定,上轮为 7e529a633 语义等效点);②check:pre-push 终验时机;③merge。
+
+## 二、已完成波次台账(P-I 收口后)
+
+| 波次 | 内容 | 状态 |
+|---|---|---|
+| 门禁修复 | build(tsdown 豁免→后随 apps/web 恢复撤销)/verify-cordis-config/module-graph/doc 全系列/type-equiv/export-jsdoc/knip(最小 diff 重写 69322d3bb)/README×2(118 全 conform)/constraints+invariants(client 12 包 fw-react 四批+host 三包 rt-core,118 伴生全 conform)/llm-retry timeout 提额 | ✅ 除 test/lint/publint/snapshot 终验未跑(等用户令) |
+| 工程结构调整(用户三点裁定) | ①apps/web 恢复=vite 应用(@deepseek-ai/dsh-frontend,ui-shell),packages/client/web 降回 lib(bootWebShell 库导出);②tsconfig 收敛:删 tsconfig.host.json,根恢复 host 聚合原职,仅新增 tsconfig.client.json,根 diff 压至 ±13 行(fw-react);③exports 纪律:dshClient 八包 node index=只空 apply 零类型导出,实现/类型全住 src/client/,消费走 /client 子路径,测试 import /src 直取,纯库三包豁免(rt-core 四刀) | ✅ |
+| 时效清扫 | 文档半(convo-a 五刀):missions 根三份 07-18 旧世代档案加取代头注/testing.md 残句/web-styling token 换代注记/四对 GUI Agent Note 路径更新+i18n 重录。测试半(convo-b+rt-core):死码三件退役——init/getSessionManager 单例对、**Session draft 面整删**(sendDraft/setDraft/snapshot.draft,仲裁 24d413133:真实链走 ConversationService+apply draftsStore,双账=平移残留)、WEB_EVENTS/WebEventName(web-cordis pre-provision 零消费);判留 4 组有据(对象层五件套/loader stub 契约钉/fake-api 双胞胎/connection 三 spec) | ✅ |
+| RFC 刷新(missions 不进 PR 前提) | ②web-client-architecture 已落(0033d7d8a,fw-react):自含化+新增 cordis 树/装载链/slot 体系/scope 寻址三大节,对象层去 draft 面,目录终态;①layering-and-rpc-protocol(rt-core)在途收尾 | 🔄 ①落库即全线暂停 |
+
+## 三、架构终态速记(防冷启动失忆;对外叙述见两份 RFC)
+
+- **工程结构**:host 三包(apiproxy/runtime/webserver)+ client 纯库三包(ui-slots/web-react/ui-primitives,根 index 库形态)+ dshClient 插件八包(connection/runtime/ui-theme/i18n/ui-layout/ui-sidebar/ui-conversation/ui-trajectory——node index=只空 apply,实现全在 src/client/,消费走 /client 子路径)+ apps/cli + **apps/web(@deepseek-ai/dsh-frontend,vite 应用,薄 main 调 packages/client/web 的 bootWebShell)**。
+- **tsconfig**:根=host 聚合(exclude packages/client/**)+tsconfig.client.json=client 聚合(12 包+tsx specs+apps/web+purity spec/preset);typecheck=`tsc -b tsconfig.json tsconfig.client.json` 单命令双 program——host/client 对 cordis Context merge 同名键(sessions/loader),双 program 隔离撞名;client 经 session/llm/tools/approval/interaction 的纯类型子路径(./types 等)消费 wire 词汇,不装载 host augmentation。
+- **装载链**:GET / 注 __DSH_BOOT__(HostWebPluginRegistry 订 Loader+dshClient 声明)→loader(壳静态持有)immediately 四包并行先装→其余 inject 拓扑→DSHClientProxy.loadPlugin 闭包工厂+DI require 模块表+导出面回登记→settled 一次成型。三防线:bundle 纯度门(resolveId 三分类,裸名自动改写 /client)/loader e2e 吃真产物/mount 锚 fiber-less throw。
+- **契约史料**:v3 全落款在 missions/tasks/20260721-1520-web-plugin-rfc/api-contracts.md(含 §3.1 apiproxy 纯度/§3.2 导出清单与溶解项/§4.0 双 program 终裁);style-spec.md=样式对账永久底册。missions 不进 PR,故正式权威=两份 RFC+各包 README+docs/ 生成物。
+- **draft 单账终态**:草稿归 ConversationService.drafts(persist keyed by sessionId)+apply.ts composer 编排(乐观清稿/失败回填);Session 无 draft 面。
+
+## 四、挂账(PR 窗口/P-II)
+
+- **PR 窗口**:pre-push 终验未跑段(test 全量/lint/publint/snapshot——注意 test-invariants 已随伴生齐而自愈过,最后一轮结构调整后需复跑);低 6 视觉偏差(判定报告尾表);W5 补拍两项(树展开/hover 已拍过一轮,审批琥珀条=P-II)。
+- **P-II 池**:approvals composer 换面板/slash/toast/details 三段/HMR+unload 完整链/history 纯持久化读(1.75s 案已证伪为旧 lib 测量假象——rt-core history-timing-data.md,纯读改造只剩语义论据,动 wire 需用户拍板)/agentFor+summarizeCold 下沉 host/viewFor+backscanArgs 删除刀(涉 wire view 字段)/assertServable O(n) 冷径/delegationDepth 拒收加 warn/drafts persist 回迁/二级树+长列表+暗 hover+审批条视觉复核。
+- **终局工程**:回刷历史(missions 消档+注释转英——执行时冻结全部)。
+
+## 五、teammate 名册与现状(2026-07-22 13:2x;主会话可能被 clear/compact——本表=接续依据)
+
+> 九人全部**存活常驻**(SendMessage 按名直达)。主会话重启后:先读本文件+git log 恢复盘面,再按「在途/待命」逐人接管。当前全队在「RFC①落库即全线暂停」令下。
+
+| teammate | 属地 | 当前状态 | 备注(接续要点) |
+|---|---|---|---|
+| **rt-core** | connection/runtime 两包+host 三包(apiproxy/runtime/webserver)+装载链/纯度门 | 🔄 **唯一在途**:RFC①(gui-layering-and-rpc-protocol 双语对+i18n 重录)收尾中,落库后按令静默 | 超时惯犯但产出全队最大;信箱丢失率高——催报先看 git log。档案 missions/tasks/20260721-p1-rt-core/(含 history-timing-data.md) |
+| **fw-react** | web-react 包+tsconfig 双聚合体系+clientcontext-audit 细案 | 💤 待命(RFC② 0033d7d8a 刚交付) | 早期三连超时后改极小步脱困;擅长机械大批量与文档。档案 20260721-p1-fw-react/ |
+| **fw-slots** | ui-slots/ui-primitives/ui-theme/i18n 四包+token 体系 | 💤 待命 | 全队质量标杆;图标管线(geometry 直读→实证落库)共识在档。挂账:sparkle 精确字形/wordmark svg 未提取。档案 20260721-p1-fw-slots/ |
+| **ui-shell** | ui-layout+packages/client/web(lib)+apps/web(vite 应用)+tsdown preset+W5 探针 | 💤 待命 | W5 probe/smoke-real/boot-chain e2e 全它写;apps/web 恢复刚完工。档案 20260721-p1-ui-shell/ |
+| **ui-side** | ui-sidebar | 💤 待命 | 亲验 dump 三方对账典范(纠过底册转录误差);挂账:行级…菜单锚点/树展开态样式已实装。档案 20260722-p1-ui-side/ |
+| **convo-a** | ui-conversation 包 owner(service+skeleton 半+公共类型) | 💤 待命 | 四次超时重灾户但全部完整交卷;M1a 定性/P0 双实例破案是它。与 convo-b 同包分工默契已成。档案 20260722-p1-convo-a/ |
+| **convo-b** | ui-conversation 消息流半(chat/+toolviews/+apply 接线)+README 实质化+测试清扫 | 💤 待命 | 判死判留过堂最严谨;12 包 README 两节全它写。档案 20260722-p1-convo-b/ |
+| **ui-traj** | ui-trajectory | 💤 待命 | 占位包已齐(10 测+chrome.header 第二挂点);P-III 真实现时回叫。档案 20260722-p1-ui-traj/ |
+| **figma-flows** | 视觉顾问(figma 数据/查询脚本/判定报告) | 💤 待命 | W5 两轮逐屏判定+style-spec 三批底册全它出;PIL 像素实测法;答疑走 SendMessage。无独立档案(产出在 w5-visual-verdict.md/style-spec.md) |
+
+派工惯例(重启后沿用):契约仲裁只归主会话(v3 落款后广播);跨属地改动报备制;同包双人(convo-a/b)由 a 划文件边界;视觉问 figma-flows 架构问 main;>15min 零落盘催报,超时唤醒消息要含「从盘上恢复」指引。
+
+## 六、环境与纪律
+
+- dsh web:`pnpm run demo:web`(src 模式启动 ~8.5s 是 tsx 转译;built lib 快一个量级);DEEPSEEK_API_KEY 在树根 .env;playwright chromium 已装;figma 数据 .artifacts/figma/(gitignored);W5 探针 .artifacts/w5-full-probe.mjs 可重放。
+- 编制九人常驻(fw-slots/fw-react/rt-core/ui-shell/ui-side/convo-a/convo-b/ui-traj/figma-flows),全员待命;档案在 missions/tasks/20260721-p1-*/ 与 20260722-p1-*/。
+- 纪律沉淀:pathspec 精确到文件(四起卷刀教训);共享分支零历史改写;裁决以盘上落款为准信箱只是提醒;状态疑问先 git log;编译只 pnpm exec tsc -b;dist 不入库改完重跑 tsdown;client 值 import 必须走 externals 形态(双实例坑)。
+- W5 验收形态(用户定):真跑不静态绿+截图对 figma 只比要做的+动线亲走。
diff --git a/missions/scripts/verify-carrier-errors.mjs b/missions/scripts/verify-carrier-errors.mjs
new file mode 100644
index 0000000000..ec0d94d1e6
--- /dev/null
+++ b/missions/scripts/verify-carrier-errors.mjs
@@ -0,0 +1,113 @@
+// Carrier error-channel regression probes (audit batch: A1/A2/A4/A9 + R2 half).
+// Runs the isomorphic path (InProcessApiClient over toFetchHandler) — no server needed.
+// Run: node --experimental-strip-types missions/scripts/verify-carrier-errors.mjs (or via tsx)
+import { toFetchHandler } from '../../packages/host/apiproxy/src/fetch/handler.ts'
+import { InProcessApiClient } from '../../packages/host/apiproxy/src/fetch/client.ts'
+import { RpcId } from '../../packages/host/apiproxy/src/api/rpc.ts'
+import { serverResponseSchema } from '../../packages/host/apiproxy/src/api/rpc.schema.ts'
+
+let failures = 0
+const report = (n, p, d = '') => { failures += p ? 0 : 1; console.log(`${p ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`) }
+
+const okList = { rpcId: RpcId('x'), result: { ok: true, value: { items: [] } } }
+/** Minimal ApiProxy stub; per-test cases override single methods. */
+function makeApi(overrides = {}) {
+ return {
+ sessions: {
+ list: async (r) => ({ ...okList, rpcId: r.rpcId }),
+ create: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { sessionId: 's1' } } }),
+ history: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }),
+ prompt: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { accepted: true } } }),
+ cancel: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { accepted: true } } }),
+ ...overrides.sessions,
+ },
+ host: {
+ describe: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { version: '0', cwd: '/', attachedSessions: 0 } } }),
+ ...overrides.host,
+ },
+ events: {
+ mux: overrides.mux ?? async function* () {},
+ host: overrides.hostStream ?? async function* () {},
+ },
+ respond: async () => ({ accepted: false, reason: 'not-pending' }),
+ }
+}
+
+// ---- A1: mid-stream impl throw → one stream/error frame on the wire, then clean close ----
+{
+ const api = makeApi({
+ hostStream: async function* () {
+ yield { rpcId: RpcId('f1'), payload: { type: 'host/session-status', sessionId: 's1', running: true } }
+ throw new Error('impl exploded mid-stream')
+ },
+ })
+ const client = new InProcessApiClient(toFetchHandler(api))
+ const seen = []
+ for await (const frame of client.events.host({}, new AbortController().signal)) seen.push(frame.payload)
+ report('A1 流中 impl throw → stream/error 帧真到达 client', seen.some(f => f.type === 'stream/error' && f.error.code === 'internal' && /impl exploded/.test(f.error.message)), JSON.stringify(seen.map(f => f.type)))
+ report('A1b stream/error 后流正常收尾(迭代自然结束不 throw)', true)
+}
+
+// ---- A2: S→C frame validation — a malformed frame is dropped, the stream survives ----
+{
+ const api = makeApi({
+ hostStream: async function* () {
+ yield { rpcId: RpcId('bad'), payload: { type: 'host/session-status', sessionId: 's1' } } // missing `running`
+ yield { rpcId: RpcId('good'), payload: { type: 'host/session-status', sessionId: 's1', running: false } }
+ },
+ })
+ const client = new InProcessApiClient(toFetchHandler(api))
+ const seen = []
+ for await (const frame of client.events.host({}, new AbortController().signal)) seen.push(frame)
+ report('A2 坏帧被丢弃且不杀流(后续好帧照常到达)', seen.length === 1 && seen[0].payload.running === false, `seen=${seen.length}`)
+}
+
+// ---- A2: S→C unary value validation — a wrong-shaped ok value throws at the client boundary ----
+{
+ const api = makeApi({ sessions: { list: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { items: 'not-an-array' } } }) } })
+ const client = new InProcessApiClient(toFetchHandler(api))
+ const threw = await client.sessions.list({}).then(() => false, () => true)
+ report('A2b unary ok value 过 Value schema(坏形状在 client 边界抛出)', threw)
+}
+
+// ---- A4: envelope parse failure backfills a salvageable rpcId; otherwise the sentinel — and the response parses as a valid ServerResponse ----
+{
+ const handler = toFetchHandler(makeApi())
+ const post = (body) => handler.fetch('http://dsh.internal/api/session.list', {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
+ })
+ const salvaged = await (await post({ rpcId: 'my-id', method: 5 })).json()
+ report('A4 信封烂但 rpcId 可捞 → 回填原值', serverResponseSchema.safeParse(salvaged).success && salvaged.rpcId === 'my-id', JSON.stringify(salvaged.rpcId))
+ const sentinel = await (await post({ nothing: true })).json()
+ report('A4b rpcId 不可捞 → invalid-request 哨兵,且过 serverResponseSchema', serverResponseSchema.safeParse(sentinel).success && sentinel.rpcId === 'invalid-request', JSON.stringify(sentinel.rpcId))
+}
+
+// ---- A10: external signal aborts an in-flight unary ----
+{
+ const api = makeApi({ sessions: { list: () => new Promise(() => {}) } })
+ const client = new InProcessApiClient(toFetchHandler(api))
+ const ctl = new AbortController()
+ const call = client.sessions.list({}, ctl.signal).then(() => 'resolved', (e) => String(e))
+ ctl.abort(new Error('user cancelled'))
+ const outcome = await call
+ report('A10 unary 外部 signal 可取消在途请求', outcome !== 'resolved', outcome.slice(0, 60))
+}
+
+// ---- onOpen: stream-established signal fires before any frame is delivered ----
+{
+ const api = makeApi({
+ hostStream: async function* () {
+ yield { rpcId: RpcId('f'), payload: { type: 'host/session-removed', sessionId: 's1' } }
+ },
+ })
+ const client = new InProcessApiClient(toFetchHandler(api))
+ const order = []
+ const iter = client.events.host({}, new AbortController().signal, () => order.push('open'))[Symbol.asyncIterator]()
+ await iter.next()
+ order.push('frame')
+ report('C2 信号:onOpen 先于首帧交付', order.join(',') === 'open,frame', order.join(','))
+ await iter.return?.()
+}
+
+console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`)
+process.exit(failures === 0 ? 0 : 1)
diff --git a/missions/scripts/verify-rpclog-panel.mjs b/missions/scripts/verify-rpclog-panel.mjs
new file mode 100644
index 0000000000..0721529641
--- /dev/null
+++ b/missions/scripts/verify-rpclog-panel.mjs
@@ -0,0 +1,99 @@
+// RPC panel browser acceptance (fixture mode); step tags §D-1..§D-6 match the report labels.
+// Prereqs: dsh web running on 3080, apps/web/dist freshly built, playwright chromium installed.
+// Run: node missions/scripts/verify-rpclog-panel.mjs (not part of any gate system)
+import { chromium } from 'playwright'
+
+const BASE = process.env.DSH_WEB_URL ?? 'http://127.0.0.1:3080'
+let failures = 0
+
+function report(name, pass, detail = '') {
+ failures += pass ? 0 : 1
+ console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
+}
+
+const browser = await chromium.launch()
+try {
+ const page = await browser.newPage()
+ await page.goto(`${BASE}/?fixture`, { waitUntil: 'load' })
+
+ // §D-1 page shell + rpclog rail button present, unread badge > 0 (boot auto-ping + subscribed frames); shell presence = the list sidebar.
+ await page.waitForSelector('aside')
+ const railBtn = page.locator('nav button[title="RPC 日志"]')
+ await railBtn.waitFor({ state: 'visible' })
+ await page.waitForFunction(() => {
+ const el = document.querySelector('nav button[title="RPC 日志"] span[class*="unread"]')
+ return el !== null && /\d/.test(el.textContent ?? '')
+ }, undefined, { timeout: 5000 })
+ report('§D-1 角标存在且未读数 > 0', true)
+
+ // §D-2 activate the rpclog bar: the ledger page fills the panel area, kinds cover three quadrants (direction symbols ↑ ↓ ⇟, up/down spatial metaphor)
+ await railBtn.click()
+ const list = page.locator('section:has(header)').locator('div[class*="list"]')
+ await list.waitFor({ state: 'visible' })
+ const rowTexts = await list.locator('button[class*="rowLine"]').allTextContents()
+ const joined = rowTexts.join('\n')
+ const hasThree = joined.includes('↑') && joined.includes('↓') && joined.includes('⇟')
+ report('§D-2 展开见台账,三象限方向符齐', hasThree, `rows=${rowTexts.length}`)
+ const unreadAfterOpen = await page.locator('span[class*="unread"]').count()
+ report('§D-2 展开后未读徽标消失', unreadAfterOpen === 0)
+
+ // §D-3 click ping: adds one client-request/server-response pair (host.describe)
+ const rowsBefore = await list.locator('button[class*="rowLine"]').count()
+ await page.locator('button', { hasText: 'ping' }).click()
+ await page.waitForFunction(
+ (n) => document.querySelectorAll('button[class*="rowLine"]').length >= n + 2,
+ rowsBefore, { timeout: 3000 },
+ )
+ const lastTwo = (await list.locator('button[class*="rowLine"]').allTextContents()).slice(-2)
+ const pingPair = lastTwo[0]?.includes('host.describe') && lastTwo[0]?.includes('↑')
+ && lastTwo[1]?.includes('host.describe') && lastTwo[1]?.includes('↓')
+ report('§D-3 ping 新增一对 describe 往返', Boolean(pingPair), lastTwo.map((t) => t.slice(0, 30)).join(' | '))
+
+ // §D-3b hover pair highlight: hovering the last row (server-response) lights its client-request row too
+ const rows = list.locator('div[class*="row"]:not([class*="rowLine"])')
+ await list.locator('button[class*="rowLine"]').last().hover()
+ await page.waitForTimeout(100)
+ const pairedCount = await list.locator('div[class*="rowPaired"]').count()
+ report('§D-3b hover 同 rpcId 配对行高亮(2 行)', pairedCount === 2, `paired=${pairedCount}`)
+
+ // §D-4 click a row to expand the JSON payload, click again to collapse
+ const firstRow = list.locator('button[class*="rowLine"]').first()
+ await firstRow.click()
+ const payloadShown = await list.locator('pre[class*="payload"]').count()
+ await firstRow.click()
+ const payloadHidden = await list.locator('pre[class*="payload"]').count()
+ report('§D-4 点行 JSON 展开/收起', payloadShown === 1 && payloadHidden === 0)
+
+ // §D-5 scrolling up pauses; resume restores follow
+ // First overflow the list (no scroll overflow → onScroll can never fire): click ping until ≥30 rows
+ while (await list.locator('button[class*="rowLine"]').count() < 30) {
+ await page.locator('button', { hasText: 'ping' }).click()
+ await page.waitForTimeout(30)
+ }
+ await list.evaluate((el) => { el.scrollTop = 0 })
+ await page.waitForSelector('div[class*="pausedBar"]', { timeout: 3000 })
+ const resumeBtn = page.locator('button', { hasText: '继续' })
+ report('§D-5 上滚触发暂停(按钮态+提示条)', await resumeBtn.count() === 1)
+ await resumeBtn.click()
+ await page.waitForTimeout(100)
+ const followRestored = await list.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight < 30)
+ report('§D-5b 继续恢复贴底跟随', followRestored)
+
+ // §D-6 clear: list empty, counters reset; periodic frames keep arriving (wait 6s for new rows)
+ await page.locator('button', { hasText: '清空' }).click()
+ const emptyAfterClear = await list.locator('button[class*="rowLine"]').count()
+ report('§D-6 清空后列表空', emptyAfterClear === 0)
+ await page.waitForFunction(
+ () => document.querySelectorAll('button[class*="rowLine"]').length > 0,
+ undefined, { timeout: 8000 },
+ )
+ report('§D-6b 清空后周期帧继续进入', true)
+} catch (error) {
+ failures += 1
+ console.log(`FAIL 脚本异常 — ${error instanceof Error ? error.message : String(error)}`)
+} finally {
+ await browser.close()
+}
+
+console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`)
+process.exit(failures === 0 ? 0 : 1)
diff --git a/missions/scripts/verify-session-real.mjs b/missions/scripts/verify-session-real.mjs
new file mode 100644
index 0000000000..02e6130628
--- /dev/null
+++ b/missions/scripts/verify-session-real.mjs
@@ -0,0 +1,132 @@
+// Real-host spot check (condensed acceptance + connection stability): real sessions in the list,
+// history renders on open, real prompt streams back. The stability assertions guard against
+// fixture masking: fake streams never touch real SSE, so bridge-layer bugs (e.g. the req 'close'
+// misdetection) only surface against a real host.
+import { chromium } from 'playwright'
+const BASE = process.env.VERIFY_BASE ?? 'http://127.0.0.1:3080'
+let failures = 0
+const report = (n, p, d = '') => { failures += p ? 0 : 1; console.log(`${p ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`) }
+const browser = await chromium.launch()
+try {
+ const page = await browser.newPage()
+ page.on('pageerror', (e) => console.log('[pageerror]', String(e).slice(0, 300)))
+ const apiRequests = []
+ let apiFailed = 0
+ page.on('request', (r) => { if (r.url().includes('/api/')) apiRequests.push(r.url()) })
+ page.on('requestfailed', (r) => { if (r.url().includes('/api/')) apiFailed++ })
+ await page.goto(`${BASE}/`, { waitUntil: 'load' })
+ // E2-0 connection stability: within a 12s window /api requests must be one-time setup cost
+ // (two streams + describe + list <= 10), zero aborts. The 300ms reconnect storm
+ // (the bridge bug fixed 2026-07-20) shows up here instantly.
+ await page.waitForTimeout(12000)
+ report('E2-0a 12s 内 /api 请求 ≤10(无重连风暴)', apiRequests.length <= 10, `count=${apiRequests.length}`)
+ report('E2-0b 无 requestfailed(SSE 不被 client abort)', apiFailed === 0, `failed=${apiFailed}`)
+ // E2-0c cold-session merge: list must include persisted sessions from previous host runs,
+ // not just in-memory attached ones (guards the R4 regression: first screen empty after
+ // restart). Requires at least one prior run's session on disk — every run of this script
+ // leaves some behind, so only a truly virgin .sessions root skips the assertion.
+ const listRes = await page.evaluate(async (base) => {
+ const res = await fetch(`${base}/api/session.list`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ type: 'client-request', rpcId: 'verify-cold-list', method: 'session.list', payload: {} }),
+ })
+ return res.json()
+ }, BASE)
+ const coldItems = listRes?.result?.ok ? listRes.result.value.items : []
+ const sorted = coldItems.every((it, i) => i === 0 || coldItems[i - 1].updatedAt >= it.updatedAt)
+ if (coldItems.length > 0) {
+ report('E2-0c 冷 session 进 list 且 updatedAt 倒序', sorted, `count=${coldItems.length}`)
+ const coldRows = await page.locator('aside button[class*="item"]').count()
+ report('E2-0d 首屏列表渲染冷 session(非空)', coldRows >= 1, `rows=${coldRows}`)
+ // Legacy no-cwd logs are not served (pre-release stance: no compatibility) —
+ // every listed session must carry its project cwd.
+ const noCwd = coldItems.filter((it) => typeof it.cwd !== 'string' || it.cwd.length === 0)
+ report('E2-0c2 无 cwd 存量不可见(全部条目携带 project cwd)', noCwd.length === 0, `noCwd=${noCwd.length}`)
+ } else {
+ console.log('SKIP E2-0c/E2-0c2/E2-0d 冷 session 断言(.sessions 为空的全新 host)')
+ }
+ // E2-0e error-channel fidelity: an unknown id must come back as session-not-found,
+ // never disguised as internal (and vice versa — guards the R3 regression).
+ const nf = await page.evaluate(async (base) => {
+ const res = await fetch(`${base}/api/session.history`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ type: 'client-request', rpcId: 'verify-not-found', method: 'session.history', payload: { sessionId: 'session-00000000-dead-beef-0000-000000000000' } }),
+ })
+ return res.json()
+ }, BASE)
+ report('E2-0e 未知 id 回 session-not-found(不伪装 internal)', nf?.result?.ok === false && nf.result.error.code === 'session-not-found', `code=${nf?.result?.error?.code}`)
+ // E2-1 create a real session into the list via '+' (covers the create path).
+ await page.locator('aside button[title="新建 session"]').click()
+ await page.waitForSelector('aside button[class*="item"]', { timeout: 8000 })
+ const n = await page.locator('aside button[class*="item"]').count()
+ report('E2-1 新建真 session 入列表', n >= 1, `count=${n}`)
+ // E2-1b default-project injection: a create without an explicit cwd must still get one
+ // (the host default — its process working directory), so the session lands in a project
+ // bucket instead of _no-cwd (guards the B-decision regression).
+ const afterCreate = await page.evaluate(async (base) => {
+ const res = await fetch(`${base}/api/session.list`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ type: 'client-request', rpcId: 'verify-default-cwd', method: 'session.list', payload: {} }),
+ })
+ return res.json()
+ }, BASE)
+ const newest = afterCreate?.result?.ok ? afterCreate.result.value.items[0] : undefined
+ report('E2-1b 新建 session 携带默认 cwd(host 进程目录注入)', typeof newest?.cwd === 'string' && newest.cwd.length > 0, `cwd=${newest?.cwd ?? '(absent)'}`)
+ // E2-2 open the first row: openState reaches open (input enabled)
+ await page.locator('aside button[class*="item"]').first().click()
+ await page.waitForSelector('main textarea:not([disabled])', { timeout: 8000 })
+ report('E2-2 打开真 session(history 通、输入可用)', true)
+ // E2-3 real prompt: user bubble lands + partial pulse (real model streaming).
+ // Ask for a ~100-char reply: too-short replies finish inside waitForSelector's polling gap,
+ // making the pulse assertion race into a false failure.
+ await page.locator('main textarea').fill('用大约100字介绍事件溯源,最后一句以「介绍完毕」结尾')
+ await page.locator('main button[class*="primary"]').click()
+ await page.waitForSelector('main div[class*="bubble"]', { timeout: 5000 })
+ report('E2-3a user 气泡入流', true)
+ const sawPulse = await page.waitForSelector('main span[class*="pulse"]', { timeout: 30000 }).then(() => true).catch(() => false)
+ report('E2-3b 真模型流式 partial 出现', sawPulse)
+ await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 60000 })
+ const text = (await page.locator('main').textContent()) ?? ''
+ report('E2-3c 回复定稿入流', text.includes('介绍完毕') || text.includes('事件溯源'), text.slice(-60))
+ // E2-4 stop mid-stream freezes the partial (aborted turns never finalize): the accumulated
+ // text survives as an interrupted terminal node (已停止 marker), the pulse stops, and later
+ // messages land after it. A reload must reconstruct the same node from the logged chunks.
+ const primary = page.locator('main button[class*="primary"]')
+ await page.locator('main textarea').fill('请从头背诵出师表全文,直接开始不要客套')
+ await primary.click()
+ await page.waitForSelector('main span[class*="pulse"]', { timeout: 30000 })
+ // Let visible content accumulate so the frozen node has a body to keep.
+ await page.waitForTimeout(2500)
+ await primary.click() // stop mid-stream (the no-finalize abort path)
+ await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 15000 })
+ const pulseGone = await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 2000 }).then(() => true).catch(() => false)
+ const frozenMark = await page.locator('main span[class*="stopped"]', { hasText: '已停止' }).count()
+ report('E2-4a 停止后 partial 定格(脉冲停+已停止标记+文本保留)', pulseGone && frozenMark >= 1, `pulseGone=${pulseGone} marks=${frozenMark}`)
+ await page.locator('main textarea').fill('请只回复四个字:顺序正常')
+ await primary.click()
+ await page.waitForSelector('main div[class*="bubble"]:has-text("顺序正常")', { timeout: 10000 })
+ const rows = await page.evaluate(() => {
+ const scroll = document.querySelector('main div[class*="scroll"]')
+ return [...(scroll?.children ?? [])].map((el) => (el.textContent ?? '').trim()).filter(Boolean)
+ })
+ const iStopped = rows.findIndex((t) => t.includes('出师表'))
+ const iNew = rows.findIndex((t) => t.includes('顺序正常'))
+ report('E2-4b 停止后再发消息顺序正确(新消息在末尾)', iNew > iStopped && iStopped >= 0, `stopped@${iStopped} new@${iNew}`)
+ // E2-4c reload: history replay re-freezes the interrupted node (live view and replay agree).
+ await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 60000 })
+ await page.reload({ waitUntil: 'load' })
+ await page.locator('aside button[class*="item"]').first().click()
+ await page.waitForSelector('main textarea:not([disabled])', { timeout: 8000 })
+ const marksAfterReload = await page.locator('main span[class*="stopped"]', { hasText: '已停止' }).count()
+ report('E2-4c 刷新后中断消息仍在(history 重建一致)', marksAfterReload >= 1, `marks=${marksAfterReload}`)
+} catch (e) {
+ failures += 1
+ console.log(`FAIL 脚本异常 — ${String(e).slice(0, 300)}`)
+} finally {
+ await browser.close()
+}
+console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`)
+process.exit(failures === 0 ? 0 : 1)
diff --git a/missions/scripts/verify-session.mjs b/missions/scripts/verify-session.mjs
new file mode 100644
index 0000000000..d4d9864c6f
--- /dev/null
+++ b/missions/scripts/verify-session.mjs
@@ -0,0 +1,361 @@
+// Session UI browser acceptance (fixture mode); step tags match the report labels.
+// Prereqs: dsh web running on 3080, apps/web/dist freshly built, playwright chromium installed.
+// Run: node missions/scripts/verify-session.mjs (not part of any gate system)
+import { chromium } from 'playwright'
+
+const BASE = process.env.DSH_WEB_URL ?? 'http://127.0.0.1:3080'
+let failures = 0
+
+function report(name, pass, detail = '') {
+ failures += pass ? 0 : 1
+ console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
+}
+
+const browser = await chromium.launch()
+try {
+ const page = await browser.newPage()
+ await page.goto(`${BASE}/?fixture`, { waitUntil: 'load' })
+
+ // §E1-1 three list rows + fx-alpha running dot + fx-beta lineage indent + empty right pane
+ await page.waitForSelector('aside button[class*="item"]', { timeout: 5000 })
+ const items = page.locator('aside button[class*="item"]')
+ report('§E1-1a 列表 3 条', await items.count() === 3, `count=${await items.count()}`)
+ const alphaDot = page.locator('aside button[title="fx-alpha"] span[class*="running"]')
+ report('§E1-1b fx-alpha running 绿点', await alphaDot.count() === 1)
+ const betaPad = await page.locator('aside button[title="fx-beta"]').evaluate((el) => el.style.paddingLeft)
+ report('§E1-1c fx-beta 谱系缩进(depth=1 → 24px)', betaPad === '24px', `paddingLeft=${betaPad}`)
+ report('§E1-1d 右侧空态', (await page.locator('main').textContent())?.includes('选择或新建') ?? false)
+
+ // §E1-2 open fx-alpha: all history node kinds render, scroll lands at bottom
+ await page.locator('aside button[title="fx-alpha"]').click()
+ await page.waitForSelector('main div[class*="bubble"]', { timeout: 5000 })
+ const mainText = await page.locator('main').textContent()
+ report('§E1-2a user 气泡渲出', (mainText ?? '').includes('问题 59'))
+ report('§E1-2b assistant 正文渲出', (mainText ?? '').includes('回答 59'))
+ // Bottom check BEFORE expanding reasoning (a local expand grows height without triggering follow — view state, not a snapshot change; by design).
+ const scroll = page.locator('main div[class*="scroll"]')
+ const atBottom = await scroll.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight < 30)
+ report('§E1-2i 打开后滚动在底部', atBottom)
+ const reasoningToggle = page.locator('main button[class*="reasoningToggle"]').last()
+ report('§E1-2c reasoning 折叠钮存在', await reasoningToggle.count() > 0)
+ await reasoningToggle.click()
+ report('§E1-2d reasoning 展开有内容', ((await page.locator('main').textContent()) ?? '').includes('思考过程'))
+ report('§E1-2e 工具卡渲出', await page.locator('main div[class*="card"] span[class*="name"]', { hasText: 'echo' }).count() > 0)
+ report('§E1-2f steering 徽标渲出', await page.locator('main span[class*="badge"]', { hasText: '插话' }).count() > 0)
+ report('§E1-2g context 折叠卡渲出', await page.locator('main button', { hasText: '上下文注入' }).count() > 0)
+ report('§E1-2h 常驻审批占位卡', await page.locator('main div[class*="card"]', { hasText: '等待审批' }).count() === 1)
+
+ // §E1-3 load-older: prepend one page, viewport stays anchored
+ const olderBtn = page.locator('main button', { hasText: '加载更早' })
+ report('§E1-3a hasMore 显示加载更早钮', await olderBtn.count() === 1)
+ const beforeAnchor = await scroll.evaluate((el) => ({ h: el.scrollHeight, t: el.scrollTop }))
+ await scroll.evaluate((el) => { el.scrollTop = 0 }) // scroll up before paging (realistic gesture)
+ const anchorTop = await scroll.evaluate((el) => el.scrollTop)
+ await olderBtn.click()
+ await page.waitForFunction((prev) => {
+ const el = document.querySelector('main div[class*="scroll"]')
+ return el !== null && el.scrollHeight > prev
+ }, beforeAnchor.h, { timeout: 5000 })
+ const afterAnchor = await scroll.evaluate((el) => ({ h: el.scrollHeight, t: el.scrollTop }))
+ const drift = Math.abs(afterAnchor.t - (anchorTop + (afterAnchor.h - beforeAnchor.h)))
+ report('§E1-3b 翻页锚定(scrollTop 补偿高度差)', drift < 4, `drift=${drift}px`)
+ report('§E1-3c 更早消息已前插', ((await page.locator('main').textContent()) ?? '').includes('问题 20'))
+
+ // §E1-5 send (queue): user bubble lands + typewriter partial + finalize; draft clears.
+ // Button rulings 2026-07-20: one primary button (send idle / stop running); running locks the input.
+ const input = page.locator('main textarea')
+ const primaryBtn = page.locator('main button[class*="primary"]')
+ // fx-alpha opens running=true (fixture list material) — the merged primary reads 停止 there; reset to idle first.
+ if (await primaryBtn.getAttribute('aria-label') === '停止') {
+ await primaryBtn.click()
+ await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 })
+ }
+ await input.fill('验收消息一')
+ await primaryBtn.click()
+ await page.waitForSelector('main div[class*="bubble"]:has-text("验收消息一")', { timeout: 3000 })
+ report('§E1-5a user 气泡入流', true)
+ report('§E1-5b 草稿清空', await input.inputValue() === '')
+ // Typewriter: the partial pulse is visible
+ await page.waitForSelector('main span[class*="pulse"]', { timeout: 3000 })
+ report('§E1-5c 流式 partial 脉冲出现', true)
+ // Running dot lights up (fixture prompt flips status)
+ await page.waitForSelector('main div[class*="head"] span[data-running]', { timeout: 3000 })
+ report('§E1-5d running 状态点亮', true)
+
+ // §E1-6 running locks the input (ruling 2026-07-20 #3, supersedes the hover menu):
+ // textarea disabled (draft visible but frozen), no queue/steer menu, stop is the only action.
+ report('§E1-6a running 时输入框置灰', await input.isDisabled())
+ report('§E1-6b running 时无排队/插话菜单', await page.locator('main button[class*="menuItem"]').count() === 0)
+ report('§E1-6c running 时主按钮为停止且可用', await primaryBtn.isEnabled() && (await primaryBtn.getAttribute('aria-label')) === '停止')
+
+ // Wait for finalize: pulse gone + echo body present (partial -> finalized node swap)
+ await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 15000 })
+ report('§E1-5e 定稿切换(脉冲消失)', true)
+ report('§E1-5f 回声正文定稿', ((await page.locator('main').textContent()) ?? '').includes('回声:验收消息一'))
+
+ // §E1-7 stop: send another, the primary button flips to stop (same slot) mid-replay
+ await input.fill('验收消息二')
+ await primaryBtn.click()
+ await page.waitForSelector('main button[aria-label="停止"]', { timeout: 3000 })
+ report('§E1-7d 运行中主按钮原地变停止', true)
+ await primaryBtn.click() // now the stop action
+ await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 })
+ report('§E1-7a 停止后 running 熄灭', true)
+ report('§E1-7b 中断标记入流', ((await page.locator('main').textContent()) ?? '').includes('(已中断)'))
+ report('§E1-7e 停止后主按钮回到发送', await primaryBtn.getAttribute('aria-label') === '发送')
+ // Turn end unlocks the box and returns focus (before any fill taints activeElement).
+ await page.waitForTimeout(200)
+ report('§E1-7f 停止解禁后焦点回输入框', await page.evaluate(() => document.activeElement?.tagName === 'TEXTAREA'))
+ await input.fill('x')
+ await primaryBtn.hover()
+ await page.waitForTimeout(300)
+ report('§E1-7c 无排队/插话菜单(空闲 hover 亦无)', await page.locator('main button[class*="menuItem"]').count() === 0)
+ await input.fill('')
+
+ // §E1-8 switch to fx-beta and back: empty conversation / instant re-render (resident instances)
+ await page.locator('aside button[title="fx-beta"]').click()
+ await page.waitForFunction(() => {
+ const main = document.querySelector('main')
+ return main !== null && (main.textContent ?? '').includes('fx-beta')
+ }, undefined, { timeout: 3000 })
+ const betaBubbles = await page.locator('main div[class*="bubble"]').count()
+ report('§E1-8a fx-beta 空对话', betaBubbles === 0, `bubbles=${betaBubbles}`)
+ const t0 = Date.now()
+ await page.locator('aside button[title="fx-alpha"]').click()
+ await page.waitForSelector('main div[class*="bubble"]:has-text("验收消息一")', { timeout: 2000 })
+ report('§E1-8b 切回 fx-alpha 即时呈现(常驻实例)', Date.now() - t0 < 1500, `${Date.now() - t0}ms`)
+
+ // §E1-9 create selects and opens immediately
+ const before = await items.count()
+ await page.locator('aside button[title="新建 session"]').click()
+ await page.waitForFunction((n) => document.querySelectorAll('aside button[class*="item"]').length > n, before, { timeout: 3000 })
+ const newSelected = await page.locator('aside button[class*="selected"]').getAttribute('title')
+ report('§E1-9 新建即入列表并选中', newSelected !== null && newSelected.startsWith('fx-'), `selected=${newSelected}`)
+
+ // §E1-11 InputBar regression pins (IME composition / autorepeat / caret / autosize / draft semantics)
+ await page.locator('aside button[title="fx-alpha"]').click()
+ const inputBox = page.locator('main textarea')
+ await inputBox.waitFor({ timeout: 3000 })
+
+ // B1: composition Enter must not send (IME candidate pick)
+ const bubblesB1 = await page.locator('main div[class*="bubble"]').count()
+ await inputBox.fill('IME 探测')
+ await inputBox.evaluate((el) => {
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, isComposing: true, bubbles: true, cancelable: true }))
+ })
+ await page.waitForTimeout(200)
+ report('§E1-11a IME 组合期 Enter 不发送', await page.locator('main div[class*="bubble"]').count() === bubblesB1 && await inputBox.inputValue() === 'IME 探测')
+
+ // B6: key-repeat Enter must not send
+ await inputBox.evaluate((el) => {
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, repeat: true }))
+ })
+ await page.waitForTimeout(200)
+ report('§E1-11b Enter 长按 autorepeat 不发送', await page.locator('main div[class*="bubble"]').count() === bubblesB1)
+ await inputBox.fill('')
+
+ // B2: mid-text edit keeps the caret (synchronous controlled-value notify)
+ await inputBox.fill('abcdef')
+ await inputBox.evaluate((el) => el.setSelectionRange(3, 3))
+ await inputBox.press('x')
+ await page.waitForTimeout(100)
+ const caret = await inputBox.evaluate((el) => ({ v: el.value, s: el.selectionStart }))
+ report('§E1-11c 中段编辑光标不跳', caret.v === 'abcxdef' && caret.s === 4, `value=${caret.v} caret=${caret.s}`)
+ await inputBox.fill('')
+
+ // B3: soft-wrap long text grows the box (mirror-div auto-grow), capped at the 14-line baseline (336px)
+ const hEmpty = (await inputBox.boundingBox())?.height ?? 0
+ await inputBox.fill('这是一段没有换行符但是非常长的文本'.repeat(60))
+ await page.waitForTimeout(100)
+ const hLong = (await inputBox.boundingBox())?.height ?? 0
+ report('§E1-11d 软换行自增高且封顶', hLong > hEmpty + 20 && hLong <= 344, `h ${hEmpty} -> ${hLong}`)
+ await inputBox.fill('')
+
+ // B4 (reworked under ruling 3): sending locks the box for the turn; focus returns on unlock (pinned at §E1-7f)
+ const primary = page.locator('main button[class*="primary"]')
+ await inputBox.fill('焦点验收')
+ await primary.click()
+ await page.waitForTimeout(200)
+ report('§E1-11e 发送后运行期输入锁定', await inputBox.isDisabled())
+ report('§E1-11f 发送即清稿(乐观清)', await inputBox.inputValue() === '')
+
+ // B5: single primary slot — the button must not move when running flips (send<->stop in place)
+ await page.waitForSelector('main button[aria-label="停止"]', { timeout: 3000 })
+ const yRunning = (await primary.boundingBox())?.y ?? -1
+ await primary.click() // stop
+ await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 })
+ const yIdle = (await primary.boundingBox())?.y ?? -2
+ report('§E1-11g 发送/停止原地切换不跳动', Math.abs(yRunning - yIdle) < 2, `primary.y ${yRunning} vs ${yIdle}`)
+
+ // Sending force-scrolls to the bottom even when scrolled away (own words must be visible;
+ // passive follow still respects scrolled-away readers during streaming).
+ const scrollBox = page.locator('main div[class*="scroll"]')
+ await scrollBox.evaluate((el) => { el.scrollTop = 0 })
+ await inputBox.fill('置底验收消息')
+ await primary.click()
+ await page.waitForSelector('main div[class*="bubble"]:has-text("置底验收消息")', { timeout: 3000 })
+ const nearBottom = await scrollBox.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight < 30)
+ report('§E1-11h 上滚状态下发送强制置底', nearBottom)
+ await page.waitForSelector('main button[aria-label="停止"]', { timeout: 3000 })
+ await primary.click() // stop the replay to leave the fixture idle
+ await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 })
+
+ // §E1-10 RPC panel cross-check: this run's traffic is visible in the ledger (history/prompt/cancel round trips).
+ // The ledger is a left-menu bar page now: activate it from the icon rail, assert panel-area content.
+ await page.locator('nav button[title="RPC 日志"]').click()
+ await page.waitForSelector('section[class*="panel"]', { timeout: 3000 })
+ const panelText = (await page.locator('section[class*="panel"]').textContent()) ?? ''
+ const sawHistory = panelText.includes('session.history') || panelText.includes('session/event')
+ report('§E1-10 调试面板见 session 流量', sawHistory)
+ await page.locator('nav button[title="会话列表"]').click() // restore the sessions panel for later steps
+
+ // §E1-12 时序批(audit S1/S3/S4):fixture __fxTiming 后门制造慢 history / 丢帧 / 重连窗口。
+ // 新开 page = 全新 fixture 实例,不受上面步骤污染。
+ const page2 = await browser.newPage()
+ await page2.goto(`${BASE}/?fixture`, { waitUntil: 'load' })
+ await page2.waitForSelector('aside button[title="fx-alpha"]', { timeout: 5000 })
+
+ // S1: open 窗口期来的 live 帧必须缝合进窗口(慢 history 下打开正在流式的会话)
+ await page2.evaluate(() => globalThis.__fxTiming.setHistoryDelay(700))
+ await page2.locator('aside button[title="fx-alpha"]').click()
+ await page2.waitForTimeout(120) // open 在途(history 还有 ~580ms 才回)
+ await page2.evaluate(() => {
+ globalThis.__fxTiming.appendUser('fx-alpha', '开窗期实时消息A')
+ globalThis.__fxTiming.appendUser('fx-alpha', '开窗期实时消息B')
+ })
+ // 就绪信号用气泡而非 textarea:fx-alpha 初始 running=true,输入框在 running 期被禁用
+ await page2.waitForSelector('main div[class*="bubble"]', { timeout: 8000 })
+ await page2.waitForSelector('main div[class*="bubble"]:has-text("开窗期实时消息B")', { timeout: 5000 }).catch(() => {})
+ await page2.evaluate(() => globalThis.__fxTiming.setHistoryDelay(0))
+ const s1Text = (await page2.locator('main').textContent()) ?? ''
+ report('§E1-12a open 期间来帧缝合不丢(S1)', s1Text.includes('开窗期实时消息A') && s1Text.includes('开窗期实时消息B'))
+ report('§E1-12b 缝合后无 fold 降级(S1)', !s1Text.includes('历史视图降级'))
+
+ // S3: 途中丢一帧造出 seq 洞 → resync-lite 重拉尾页找回丢帧,且不触发 fold 降级
+ await page2.evaluate(() => {
+ globalThis.__fxTiming.appendSilent('fx-alpha', '途中丢失的消息C') // 只进 log 不发 mux 帧
+ globalThis.__fxTiming.appendUser('fx-alpha', '洞后到达的消息D') // client 看见 seq 跳 2
+ })
+ const gapRepaired = await page2.waitForSelector('main div[class*="bubble"]:has-text("途中丢失的消息C")', { timeout: 5000 }).then(() => true).catch(() => false)
+ report('§E1-12c seq 洞触发补拉,丢帧经 history 找回(S3)', gapRepaired)
+ const s3Text = (await page2.locator('main').textContent()) ?? ''
+ report('§E1-12d 洞后帧不丢(S3)', s3Text.includes('洞后到达的消息D'))
+ report('§E1-12e 洞不再触发 fold 降级(S3)', !s3Text.includes('历史视图降级'))
+
+ // S4: open 在途时断线,在途 history 注定失败 → 重连 resync 的 generation 必须作废旧结果,
+ // 不得在新窗口成功打开后被迟到的旧失败定格成 error。用 fx-beta(无定时素材,running 恒 false)。
+ await page2.evaluate(() => {
+ globalThis.__fxTiming.setHistoryDelay(1200)
+ globalThis.__fxTiming.failNextHistory()
+ })
+ await page2.locator('aside button[title="fx-beta"]').click()
+ await page2.waitForTimeout(150) // 注定失败的 open 在途
+ await page2.evaluate(() => {
+ globalThis.__fxTiming.setHistoryDelay(0)
+ globalThis.__fxTiming.breakStreams() // 双流断 → 重连(退避 ~250-500ms + 宽限 150ms)→ resync
+ })
+ await page2.waitForSelector('main textarea:not([disabled])', { timeout: 8000 })
+ await page2.waitForTimeout(1400) // 等旧 doomed 请求(~1350ms 处)失败落地后再断言
+ const s4ErrStrips = await page2.locator('main div[class*="openError"]').count()
+ const s4InputOk = await page2.locator('main textarea:not([disabled])').count()
+ report('§E1-12f 断线窗口在途 open 不定格失败(S4 generation 作废)', s4ErrStrips === 0 && s4InputOk === 1, `errStrips=${s4ErrStrips} input=${s4InputOk}`)
+ await page2.close()
+
+ // §E1-13 引用稳定(audit S5+C3):流式 chunk 期间 memo 必须真实命中——
+ // 稳定的 ToolCallCard/SessionListItem 渲染次数不随 chunk 帧线性增长。
+ const page3 = await browser.newPage()
+ await page3.addInitScript(() => { globalThis.__renderCounts = {} })
+ await page3.goto(`${BASE}/?fixture`, { waitUntil: 'load' })
+ await page3.waitForSelector('aside button[title="fx-alpha"]', { timeout: 5000 })
+ await page3.locator('aside button[title="fx-alpha"]').click()
+ await page3.waitForSelector('main div[class*="bubble"]', { timeout: 8000 })
+ // 复位到空闲(fx-alpha 开局 running)
+ const primary3 = page3.locator('main button[class*="primary"]')
+ if (await primary3.getAttribute('aria-label') === '停止') {
+ await primary3.click()
+ await page3.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 })
+ }
+ await page3.evaluate(() => { globalThis.__renderCounts = {} })
+ // 发送触发 fixture 流式回放(约 20+ 个 chunk 帧)
+ await page3.locator('main textarea').fill('memo 稳定性验收')
+ await primary3.click()
+ await page3.waitForSelector('main span[class*="pulse"]', { timeout: 5000 })
+ await page3.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 20000 })
+ const counts = await page3.evaluate(() => globalThis.__renderCounts)
+ // 历史窗口 50 条消息里有 ~10 张工具卡,全部已定稿:chunk 期间它们的 props 引用应稳定,
+ // memo 全程命中 → 整轮流式回放中每张卡渲染次数为 0(发送时快照 nodes 未变)。
+ // 列表条目:running 翻转 2 次(true/false)+ updatedAt 变 1 次是合法渲染,帧驱动重渲则会到几十次。
+ const toolRenders = counts.ToolCallCard ?? 0
+ const listRenders = counts.SessionListItem ?? 0
+ report('§E1-13a 流式期间已定稿工具卡 memo 命中(S5)', toolRenders <= 12, `ToolCallCard renders=${toolRenders}(10 卡;>12 即 memo 失效)`)
+ report('§E1-13b 流式期间列表条目 memo 命中(S5+C3)', listRenders <= 12, `SessionListItem renders=${listRenders}(3 行 × 合法状态翻转;>12 即 memo 失效)`)
+
+ // §E1-15 tool 卡三级回退(toolcard-wire):fixture 60-62 turn 携带三型 view 样本;
+ // echo(无 presenter)钉住无 view 兜底 JSON 卡路径。
+ {
+ const scroll3 = page3.locator('main div[class*="scroll"]')
+ // fx-bash terminal 卡:命令占卡头 name 槽 + cwd + exit 胶囊 + 输出
+ const termCmd = await page3.locator('main span[class*="name"]', { hasText: 'ls -la' }).count()
+ const termCwd = await page3.locator('main span[class*="cwd"]', { hasText: '/tmp/fixture' }).count()
+ const termPill = await page3.locator('main span[class*="pill"]', { hasText: 'exit 0' }).count()
+ report('§E1-15a terminal 卡渲出(命令+cwd+exit 胶囊)', termCmd >= 1 && termCwd >= 1 && termPill >= 1, `cmd=${termCmd} cwd=${termCwd} pill=${termPill}`)
+ const termOut = (await scroll3.textContent() ?? '').includes('drwxr-xr-x fixture')
+ report('§E1-15b terminal 卡输出体渲出', termOut)
+ // fx-write diff 卡:path 头 + 新文本块
+ const diffPath = await page3.locator('main div[class*="diffPath"]', { hasText: 'notes/demo.txt' }).count()
+ const diffNew = await page3.locator('main pre[class*="diffNew"]', { hasText: 'hello fixture' }).count()
+ report('§E1-15c diff 卡渲出(path 头+新文本)', diffPath >= 1 && diffNew >= 1, `path=${diffPath} new=${diffNew}`)
+ // fx-note generic 卡:view 标题上头 + kind 图标
+ const genTitle = await page3.locator('main span[class*="name"]', { hasText: '记录笔记' }).count()
+ report('§E1-15d generic 卡渲出(view 标题)', genTitle >= 1, `title=${genTitle}`)
+ // echo 无 presenter:老 JSON 折叠卡兜底(参数折叠钮仍在)
+ const echoCard = await page3.locator('main div[class*="card"]:has(span[class*="name"]:text-is("echo")) button', { hasText: '参数' }).count()
+ report('§E1-15e 无 view 工具兜底 JSON 卡(echo)', echoCard >= 1, `echoParamToggles=${echoCard}`)
+ }
+
+ // §E1-16 壳骨架(app-shell knife 3):tabs 条在、占位页渲、点 tool 卡展开右栏 detail、再点收起。
+ {
+ // tabs 条:conversation + gantt 两 tab 注册后条自然出现(单 tab 时不渲染的分支反证)。
+ const tabConv = await page3.locator('main button', { hasText: '会话' }).count()
+ const tabGantt = await page3.locator('main button', { hasText: '甘特' }).count()
+ report('§E1-16a tabs 条渲出(会话+甘特)', tabConv >= 1 && tabGantt >= 1, `conv=${tabConv} gantt=${tabGantt}`)
+ // 占位页:切甘特 tab 渲说明性占位,切回会话流还在。
+ await page3.locator('main button', { hasText: '甘特' }).click()
+ const placeholderText = (await page3.locator('main').textContent()) ?? ''
+ report('§E1-16b 甘特占位页渲出', placeholderText.includes('视图建设中'))
+ await page3.locator('main button', { hasText: '会话' }).first().click()
+ await page3.waitForSelector('main div[class*="bubble"]', { timeout: 3000 })
+ // 点 tool 卡头 → 右栏 detail 展开(callId+argsRaw JSON);同卡再点 → 收起。
+ const echoHead = page3.locator('main div[class*="card"]:has(span[class*="name"]:text-is("echo")) div[class*="head"]').first()
+ await echoHead.click()
+ const detailShown = await page3.waitForSelector('span[class*="detailTitle"]', { timeout: 3000 }).then(() => true).catch(() => false)
+ const detailText = detailShown ? (await page3.locator('div[class*="detailBody"]').textContent()) ?? '' : ''
+ report('§E1-16c 点卡展开右栏 detail(callId+argsRaw)', detailShown && detailText.includes('callId') && detailText.includes('argsRaw'))
+ await echoHead.click()
+ const detailGone = await page3.waitForSelector('span[class*="detailTitle"]', { state: 'detached', timeout: 3000 }).then(() => true).catch(() => false)
+ report('§E1-16d 同卡再点收起', detailGone)
+ // 关闭钮路径:再开一次,点 × 收起(空 detail 兜底由 jsdom 层守)。
+ await echoHead.click()
+ await page3.waitForSelector('span[class*="detailTitle"]', { timeout: 3000 })
+ await page3.locator('button[title="关闭详情"]').click()
+ const closedByBtn = await page3.waitForSelector('span[class*="detailTitle"]', { state: 'detached', timeout: 3000 }).then(() => true).catch(() => false)
+ report('§E1-16e 关闭钮收起', closedByBtn)
+ }
+
+ // §E1-14 连接状态可见(audit C1):断流 → 顶部细条出现;重连成功 → 细条消失。
+ report('§E1-14a 连接正常时无断线细条', await page3.locator('div[class*="banner"]').count() === 0)
+ await page3.evaluate(() => globalThis.__fxTiming.breakStreams())
+ const bannerShown = await page3.waitForSelector('div[class*="banner"]', { timeout: 5000 }).then(() => true).catch(() => false)
+ report('§E1-14b 断流后重连细条出现', bannerShown)
+ const bannerGone = await page3.waitForSelector('div[class*="banner"]', { state: 'detached', timeout: 8000 }).then(() => true).catch(() => false)
+ report('§E1-14c 重连成功后细条消失', bannerGone)
+ await page3.close()
+} catch (error) {
+ failures += 1
+ console.log(`FAIL 脚本异常 — ${error instanceof Error ? error.message : String(error)}`)
+} finally {
+ await browser.close()
+}
+
+console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`)
+process.exit(failures === 0 ? 0 : 1)
diff --git a/missions/scripts/verify-webserver-backpressure.mjs b/missions/scripts/verify-webserver-backpressure.mjs
new file mode 100644
index 0000000000..3e03f42ae6
--- /dev/null
+++ b/missions/scripts/verify-webserver-backpressure.mjs
@@ -0,0 +1,45 @@
+// Webserver bridge backpressure probe (audit R2, webserver half): a paused client socket
+// must stall the SSE pump (res.write false → await drain) instead of buffering unboundedly.
+// Self-contained: starts startWebServer on PORT with a stub apiHandler; no host needed.
+// Run: node_modules/.bin/tsx missions/scripts/verify-webserver-backpressure.mjs
+import { connect } from 'node:net'
+import { once } from 'node:events'
+import { startWebServer } from '../../packages/host/webserver/src/index.ts'
+
+const PORT = Number(process.env.PROBE_PORT ?? 3097)
+const CHUNK = 64 * 1024
+const TOTAL = 200 // 200 × 64KB = 12.5MB — far beyond any socket buffer
+let failures = 0
+const report = (n, p, d = '') => { failures += p ? 0 : 1; console.log(`${p ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`) }
+
+let pulled = 0
+const apiHandler = {
+ fetch: async () => new Response(new ReadableStream({
+ pull(controller) {
+ if (pulled >= TOTAL) return controller.close()
+ pulled++
+ controller.enqueue(new Uint8Array(CHUNK))
+ },
+ }), { headers: { 'content-type': 'text/event-stream' } }),
+}
+
+const server = await startWebServer({ port: PORT, distIndex: '/nonexistent/index.html', apiHandler }, (e) => console.error(String(e)))
+const socket = connect(PORT, '127.0.0.1')
+await once(socket, 'connect')
+socket.write(`GET /api/events.host HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n\r\n`)
+socket.pause() // stop reading: kernel+node buffers fill, then res.write must return false
+
+await new Promise(r => setTimeout(r, 1500))
+const stalled = pulled
+// Without drain-await the pump races through all chunks regardless of the paused reader.
+report('R2 暂停读的慢客户端使泵停在低水位(非全量吞入内存)', stalled < TOTAL / 2, `pulled=${stalled}/${TOTAL}`)
+
+socket.resume() // drain: the pump must resume and finish
+const t0 = Date.now()
+while (pulled < TOTAL && Date.now() - t0 < 10_000) await new Promise(r => setTimeout(r, 100))
+report('R2b 恢复读后泵继续推进到完成', pulled === TOTAL, `pulled=${pulled}/${TOTAL}`)
+
+socket.destroy()
+await server.close()
+console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`)
+process.exit(failures === 0 ? 0 : 1)
diff --git a/missions/scripts/verify-webserver-hardening.mjs b/missions/scripts/verify-webserver-hardening.mjs
new file mode 100644
index 0000000000..e8c6f636f8
--- /dev/null
+++ b/missions/scripts/verify-webserver-hardening.mjs
@@ -0,0 +1,37 @@
+// Webserver hardening probe (audit R1): malformed requests must yield 4xx/5xx, never kill
+// the process. Prereq: dsh web on 3080. Run: node missions/scripts/verify-webserver-hardening.mjs
+const BASE = process.env.DSH_WEB_URL ?? 'http://127.0.0.1:3080'
+let failures = 0
+
+function report(name, pass, detail = '') {
+ failures += pass ? 0 : 1
+ console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
+}
+
+async function status(path, init) {
+ try {
+ return (await fetch(`${BASE}${path}`, init)).status
+ } catch {
+ return 0 // connection refused/reset — the server died or dropped us
+ }
+}
+
+// R1 trigger set: bad %-encodings (decodeURIComponent URIError), long path, bad method, non-JSON API body.
+const cases = [
+ ['/%', [400]],
+ ['/%c0', [400]],
+ ['/%zz%', [400]],
+ ['/' + 'a'.repeat(9000), [200]], // SPA fallback, must not throw
+ ['/foo', [405], { method: 'DELETE' }],
+ ['/api/session.list', [400], { method: 'POST', body: 'not json' }],
+]
+for (const [path, expect, init] of cases) {
+ const got = await status(path, init)
+ const label = path.length > 24 ? `${path.slice(0, 24)}…` : path
+ report(`${init?.method ?? 'GET'} ${label} -> ${expect.join('/')}`, expect.includes(got), `got=${got}`)
+}
+
+report('server alive after the barrage (GET / -> 200)', (await status('/')) === 200)
+
+console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`)
+process.exit(failures === 0 ? 0 : 1)
diff --git a/missions/tasks/20260719-1843-step1-skeleton-design/README.md b/missions/tasks/20260719-1843-step1-skeleton-design/README.md
new file mode 100644
index 0000000000..921d07ec57
--- /dev/null
+++ b/missions/tasks/20260719-1843-step1-skeleton-design/README.md
@@ -0,0 +1,34 @@
+# step1 骨架设计(GUI)
+
+任务:为 DeepSeek Harness GUI「step1 骨架」写中文设计文档,说清动线(boot / 构建 / 请求 / 停机)与五模块的 API 暴露面。设计文档任务,不写实现代码。
+
+## 已拍板约束(2026-07-19,用户定)
+
+- 五模块:`apps/dsc`(bin 入口,node:http 内联静态服务)、`packages/host/apiproxy`(程序化组合 harness core,agents: [])、`packages/client/web-runtime`(浏览器启动层,无 React)、`packages/client/web-ui`(React 层)、`apps/web`(vite build 主入口,产 dist)。
+- apps/dsc 通过 workspace 依赖 apps/web 从包内解析 dist;不要 --static-dir。
+- 监听 0.0.0.0,打印 http://127.0.0.1:;`--port` 用 node util.parseArgs。
+- API key 从根 .env 读;`dsc web` 零参数即起。
+- step1 不做:连接协议、/health、session 通信、vite dev server 代理、精细 drain。停机:SIGINT → 关 HTTP → dispose cordis root。
+- 前端依赖版本参考 deepseekchat(deepsuite-frontend)基线。
+- 不遵循仓库门禁(coverage/doc-sync/JSDoc 等)。
+- 禁读 worktree-webpreview 旧 GUI 资产。
+
+## 文件索引
+
+| 文件 | 内容 |
+|---|---|
+| `deepseekchat-baseline.md` | worker 产出:deepsuite-frontend 前端工程版本基线 |
+| `harness-boot-facts.md` | worker 产出:harness 程序化 boot 接线事实(带 file:line) |
+| `design.md` | 最终设计文档 |
+
+## 进展
+
+| 时间 | 事项 |
+|---|---|
+| 2026-07-19 18:43 | 任务下达(team-lead → dispatcher) |
+| 2026-07-19 18:47 | 建归档目录;派出两个 background worker(deepseekchat 基线 / harness 接线) |
+| 2026-07-19 18:52 | dispatcher 自查 root 工程事实:workspaces=`vendor/*`+`packages/*/*`+`website`;tsdown 只收 vendor/packages;website 是「自带 build、不进 tsc/tsdown 构建图」的 workspace 成员先例;tsconfig.base.json 的 dsh-* paths 通配需为 host/client 组各加一行 |
+| 2026-07-19 18:55 | worker1(deepseekchat 基线)完成:deepsuite-frontend 用 Rush+Rspack **无 Vite**;可借鉴 React ^18.2、TS 6.0.3 strict/Bundler/react-jsx、zustand ~4.4.7、CSS Modules+PostCSS;Vite 版本需自定(列入遗留问题) |
+| 2026-07-19 18:57 | 已读 `deepseekchat-baseline.md` 全文并确认可用;等待 worker2(harness 接线)后动笔 design.md |
+| 2026-07-19 19:12 | dispatcher 两次 idle 未产出,主会话停掉 dispatcher、收回素材直写 design.md 完成(五模块/四动线/包清单/3 个遗留问题) |
+| 2026-07-19 20:30 | 重开恢复:前任 teammate 因主会话意外关闭中断(20:14 API 超时又丢一轮),新 owner 从归档恢复历史后重写 design.md 为 v2 实现级(⓪已锁结论/①目录树/②根配置精确编辑/③五包 package.json 全文/④五 tsconfig 全文/⑤bootHost+bin.ts+vite 三件套源文件/⑥12 条验收/⑦step2 接缝只指向 apiproxy 文档/⑧v1 差异)。核实点:app-boot loadEnv 可直接 import;LlmDeepSeek 为函数插件 `import * as`;SessionPersistenceJsonl root 必填;dist 解析定 createRequire |
diff --git a/missions/tasks/20260719-1843-step1-skeleton-design/deepseekchat-baseline.md b/missions/tasks/20260719-1843-step1-skeleton-design/deepseekchat-baseline.md
new file mode 100644
index 0000000000..3e380fde4c
--- /dev/null
+++ b/missions/tasks/20260719-1843-step1-skeleton-design/deepseekchat-baseline.md
@@ -0,0 +1,74 @@
+# deepseekchat(deepsuite-frontend)前端工程基线调研
+
+调研对象:`/weka-hg/prod/deepseek/permanent/ys/private/workspace/gitlab/deepsuite-frontend`。
+该仓库是 **Rush + pnpm** monorepo(无根 package.json,项目清单在 `rush.json`),主聊天 web 应用选定为 **`apps/chat`(`@deepseek/chat`)**。
+
+**重要前提:该仓库不用 Vite,构建器是 Rspack(`@rspack/cli` + `builtin:swc-loader`)。** 全仓 `find` 无任何 `vite.config.*`,也没有 `vite` 依赖。下文 "vite 配置要点" 一节相应改为 rspack 配置要点,供新骨架用 Vite 对齐等效能力时参考。
+## 1. 关键依赖版本表
+
+| 项目 | 版本 / 值 | 出处 |
+| --- | --- | --- |
+| react | `^18.2.0`(lock 解析为 18.3.1) | `apps/chat/package.json` dependencies;`common/config/rush/pnpm-lock.yaml` |
+| react-dom | `^18.2.0`(lock 18.3.1) | 同上 |
+| @types/react / @types/react-dom | `~18.3.1` / `~18.3.0` | `apps/chat/package.json` |
+| 构建器 | **Rspack**:`@rspack/cli` 2.0.2、`@rspack/core` 2.0.2、`@rspack/dev-server` 2.0.1(无 vite) | `apps/chat/package.json` devDependencies |
+| React 转换 | 无 @vitejs/plugin-react\*;用 rspack `builtin:swc-loader`,`react.runtime: 'automatic'`,dev 下 `react-refresh`(`@rspack/plugin-react-refresh` 2.0.0) | `shared/rspack-base-config/index.ts`、`shared/rspack-base-config/package.json` |
+| typescript | `6.0.3` | `apps/chat/package.json`、`shared/tsconfig-base` 的消费方统一为 6.0.3 |
+| 状态管理 | **zustand `~4.4.7`**(配 `immer ~10.1.1`;数据请求用 `swr ~2.2.4`;另有 rxjs) | `apps/chat/package.json` dependencies |
+| 路由 | react-router-dom `^6.16.0`(lock 6.30.4) | `apps/chat/package.json` |
+| 样式方案 | **CSS Modules(`*.module.css`)+ PostCSS**(postcss-nested、postcss-custom-media、@csstools/postcss-global-data、autoprefixer);类型用 `typed-css-modules`(tcm)生成 `.css.d.ts`;类名工具 `clsx`。无 tailwind/less/styled-components | `apps/chat/rspack.config.ts`(`css/auto` + `createPostcssUse`)、`shared/rspack-postcss-rule/index.ts`、`apps/chat/package.json` |
+| SVG | `@svgr/webpack ^8.1.0`(`?url` 走 asset,其余 tsx 引用走 SVGR 组件) | `apps/chat/rspack.config.ts` |
+| Lint/格式化 | oxlint `1.63.0` + oxfmt `0.48.0`(非 eslint/prettier) | `apps/chat/package.json` |
+| 测试 | vitest `~4.0.18` | `apps/chat/package.json` |
+| Node 版本 | `>=20.19.0 <21.0.0 \|\| >=22.12.0 <23.0.0 \|\| >=26.0.0 <27.0.0` | `rush.json` `nodeSupportedVersionRange` |
+| 包管理器 | Rush `5.175.1` + pnpm `10.33.4`(`useWorkspaces: true`,无独立 packageManager 字段) | `rush.json`、`common/config/rush/pnpm-config.json` |
+| npm registry | `https://registry.npmmirror.com` | `common/config/rush/.npmrc` |
+
+## 2. build/dev 脚本清单(apps/chat/package.json scripts,构建相关)
+
+- `dev` → `rushx dev:staging`;`dev:staging` = `DEPLOY_ENV=staging rspack serve -c rspack.config.ts`(dev server 需要 `DEPLOY_ENV` 环境变量,否则 config 直接 throw)
+- `dev:production` = `DEPLOY_ENV=production rspack serve ...`
+- `devc` = 杀 8080 进程 + `run-p dev tcm:watch watch:deps`(并行跑 dev server、CSS Modules 类型 watch、上游依赖 watch)
+- `build` = lint + type + test + 清 dist + `rspack build -c rspack.config.ts` + 产物语法兼容检查(`check:bundle-compat`)
+- `build:production` / `build:staging` = 设 `DEPLOY_ENV` 后走 `build`
+- `rspack` = `rspack build -c rspack.config.ts`
+- `analyze` = `RSDOCTOR=true ... rspack build`(Rsdoctor 分析)
+- `type` = 并行:`tcm`(生成 css.d.ts)+ `tsc -p tsconfig.scripts.json` + `tsc -p src/tsconfig.json`(全部 noEmit,类型检查与打包分离)
+- `tcm` / `tcm:watch` = `typed-css-modules` 扫 `src/**/*.module.css`
+- `test` = `vitest --run __tests__`
+- `preview` = `rspack serve -c rspack.preview.config.ts`
+
+## 3. 构建配置要点(rspack.config.ts;Vite 骨架对齐参考)
+
+- **入口/产物**:entry `./src/index.tsx`;输出 `static/[name].[contenthash:10].js`,dev 用无 hash 名;`publicPath` 生产走 CDN(`https://fe-static.deepseek.com/chat/`),dev 为 `/`。
+- **HTML**:`HtmlRspackPlugin` 两份模板 `src/index.html` 与 `src/share.html`(多页),模板参数注入 git commit id 与内联 analytics 脚本。
+- **浏览器 target / polyfill**:swc `env.targets = ['ios >= 12', 'chrome >= 66']`,`mode: 'usage'` + `core-js 3.41`(`shared/rspack-base-config/browserTargets.cjs`)。仓库无 browserslist 文件,target 就是这份常量。
+- **JSX/TS 转换**:`builtin:swc-loader`,typescript+tsx 语法,`react.runtime: 'automatic'`,dev 开 `development` + `refresh`。
+- **CSS**:原生 `css/auto`(rspack 内置 CSS Modules,`namedExports: false`,生产 localIdentName `[hash:8]`)+ postcss-loader(nested / custom-media / global-data 注入共享 media.css / autoprefixer)。
+- **别名**:仅 `core-js` 与 `@swc/helpers` 指到 resolve 出的包目录(保证单实例),**没有 `@/` → `src` 之类的路径别名**;`resolve.extensions = ['.tsx', '.ts', '.js']`。
+- **dev server**:staging 端口 8080 / production 8090,`historyApiFallback: true`,`/api` 等前缀 proxy 到 `https://chat-dev.deepseek.com`(或生产域名),`allowedHosts: 'all'`。
+- **特殊产物**:SRI(子资源完整性)插件、sourcemap 上传插件、`NormalModuleReplacementPlugin` 按环境替换 debug 模块、splitChunks 手工分 vendors/mermaid/katex/prismjs 分组——这些属于该产品线定制,新骨架不需要。
+
+## 4. tsconfig 关键 compilerOptions
+
+`apps/chat/src/tsconfig.json` extends `../tsconfig.web.json` extends `@deepseek/tsconfig-base/tsconfig.json`(`shared/tsconfig-base/tsconfig.json`),叠加后 web 源码生效值:
+
+- `strict: true`(另显式 `noImplicitAny`、`useUnknownInCatchVariables`)
+- `target: "ESNext"`,`lib: ["DOM", "DOM.Iterable", "ESNext"]`
+- `module: "ESNext"`,`moduleResolution: "Bundler"`(base 里是 CommonJS,web 层覆写)
+- `jsx: "react-jsx"`
+- `noEmit: true`、`isolatedModules: true`、`skipLibCheck: true`
+- `noUnusedLocals` / `noUnusedParameters` / `noImplicitOverride` / `noImplicitReturns`、`checkJs: true`
+- `allowSyntheticDefaultImports: true`;src 层 `types: ["react", "react-dom"]`
+
+## 5. 目录组织要点
+
+`apps/chat/src/` 一级结构:`index.html` + `share.html`(HTML 模板在 src 内,非仓根)、入口 `index.tsx`(副作用 setup 一串 + `App.tsx`)、`router.tsx` / `setupRouter.ts` / `routes/`(react-router v6)、`components/`(每组件一目录,`Foo.tsx` + `Foo.module.css` + 生成的 `.css.d.ts`)、`store/`(zustand 各 store 按文件拆分)、`service/`、`models/`、`hooks/`、`jobs/`(启动任务)、`utils/`、`i18n/`、`style/`(global.css)、`assets/`、`config/`,另有 `css.d.ts` / `svg.ts` / `shims.d.ts` 等全局声明。React 挂载(`createRoot`)封装在共享包 `packages/app-kit-web` 的 app 框架内,业务入口只做 setup + 配置。
+
+## 对新 React + Vite 骨架的启示(简结)
+
+可直接继承的基线:React 18 + react-dom 18、TS strict + `jsx: react-jsx` + `module: ESNext` + `moduleResolution: Bundler`、zustand(+immer)状态、CSS Modules + clsx 样式、react-router v6、pnpm + Node 22。
+这些继续依赖他们
+
+构建器一项无法照搬(对方是 Rspack),Vite 侧等效物:`@vitejs/plugin-react`(swc 版可选)替代 builtin:swc-loader + react-refresh;Vite 原生 CSS Modules 替代 `css/auto` + tcm;`server.proxy` 替代 devServer.proxy;`build.target` 若无需老浏览器可不必带 core-js polyfill 链。
+我们继续使用 vite,主要考虑到后面会用他们的模块
diff --git a/missions/tasks/20260719-1843-step1-skeleton-design/design.md b/missions/tasks/20260719-1843-step1-skeleton-design/design.md
new file mode 100644
index 0000000000..736b56de57
--- /dev/null
+++ b/missions/tasks/20260719-1843-step1-skeleton-design/design.md
@@ -0,0 +1,636 @@
+# step1 骨架 · 实现规格(v2)
+
+> 2026-07-19 v2 重写:读者是**无本会话上下文的编码 teammate**——照本文档即可建目录、写文件、跑通验收,不需要再查素材。事实核实基于 HEAD `9eb1fbd5d`。设计权衡见 git 历史里的 v1;本文只给结论。
+> 范围:五模块骨架 + 静态服务 + host boot + 停机。**不含任何 /api 路由、/health、session 通信、协议实现**(step2,契约见 `../20260719-1902-apiproxy-api-design/design.md`)。
+
+## ⓪ 已锁定结论(直接照做,不再讨论)
+
+- 五模块:`apps/dsc`、`apps/web`、`packages/host/apiproxy`、`packages/client/web-runtime`、`packages/client/web-ui`。
+- 包名/bin:`@deepseek-ai/dsc`,bin 名 `dsc`,子命令 `web`;前端包 `@deepseek-ai/dsc-web`;三个 dsh 包 `@deepseek-ai/dsh-apiproxy` / `@deepseek-ai/dsh-web-runtime` / `@deepseek-ai/dsh-web-ui`。
+- 版本:vite `^6.0.0`、`@vitejs/plugin-react` `^4.0.0`、react/react-dom `^18.2.0`、`@types/react` `~18.3.1`、`@types/react-dom` `~18.3.0`、typescript `^6.0.3`(跟根)。
+- `--port` 用 `node:util` 的 `parseArgs`,默认 **3080**;`listen(port, '0.0.0.0')`,打印 `http://127.0.0.1:`。
+- API key:bin 先 `loadEnv()` 读根 `.env` 进 process.env(直接 import 自 `@deepseek-ai/dsh-app-boot`,已核实是普通具名导出函数,无 Loader 依赖),`LlmDeepSeek` 插件层自己兜底读 `$DEEPSEEK_API_KEY`(缺 key 在 plugin load 期 throw,fail loud)。
+- persistenceRoot:`'./.sessions'`(cwd 相对;demo:web 从仓库根跑,与现有 demos 一致)。
+- 停机:照 `packages/examples/jsonrpc-demo/src/bin.ts` 的 `disposeAndExit` 样板;SIGINT→130、SIGTERM→0;关 HTTP 后 dispose cordis root。不做 drain。
+- 纪律:**不遵循仓库门禁**(coverage/doc-sync/JSDoc/README/knip/根 typecheck references 一概不动、不补);只求 `demo:web` 能跑通验收清单。
+- step1 不做:`/api/*` 路由(含 /health)、session 通信、agent 预建、vite dev server/proxy、ClientSlot、tsdown/构建产物(dev 期 tsx 跑 src)、测试。
+
+## ① 目录树(新建文件全清单)
+
+```
+apps/ ← 新顶层目录
+ dsc/
+ package.json ← §③-1
+ tsconfig.json ← §④-1
+ src/bin.ts ← §⑤-5(bin 全部逻辑单文件:parseArgs + bootHost + 静态服务 + 信号)
+ web/
+ package.json ← §③-2
+ tsconfig.json ← §④-2
+ index.html ← §⑤-4(vite 默认入口位置 = 包根)
+ vite.config.ts ← §⑤-4
+ src/main.ts ← §⑤-4
+packages/host/ ← 新包组
+ apiproxy/
+ package.json ← §③-3
+ tsconfig.json ← §④-3
+ src/index.ts ← §⑤-1(bootHost)
+packages/client/ ← 新包组
+ web-runtime/
+ package.json ← §③-4
+ tsconfig.json ← §④-4
+ src/index.ts ← §⑤-2(Runtime + createRuntime)
+ web-ui/
+ package.json ← §③-5
+ tsconfig.json ← §④-5
+ src/index.tsx ← §⑤-3(mount + App)
+```
+
+不建:tests/、README.md(含 packages/host/README.md、packages/client/README.md 组说明)、tsdown.config.ts——门禁跳过,step 后续补。
+
+## ② 根配置改动(四处,给出精确编辑)
+
+### ②-1 `pnpm-workspace.yaml`
+
+`packages:` 列表在 `- packages/*/*` 之后插入一行:
+
+```yaml
+ - packages/*/*
+ - apps/* # ← 新增
+ - website
+```
+
+### ②-2 根 `package.json` 两处
+
+a) `workspaces` 数组(与 pnpm-workspace.yaml 保持一致)加一项:
+
+```json
+ "workspaces": [
+ "vendor/*",
+ "packages/*/*",
+ "apps/*",
+ "website"
+ ],
+```
+
+b) `scripts` 加一行(无 `--expose-internals`,无 HMR):
+
+```json
+ "demo:web": "node --import tsx apps/dsc/src/bin.ts web",
+```
+
+### ②-3 `tsconfig.base.json`
+
+`"@deepseek-ai/dsh-*"` paths 数组**末尾**追加两行(位置无关——包目录名全仓唯一、first-on-disk-wins;注意给上一行 `"./packages/support/*/src"` 补逗号):
+
+```json
+ "./packages/support/*/src",
+ "./packages/host/*/src",
+ "./packages/client/*/src"
+```
+
+这两行是 tsx 跑 `demo:web` 时把 `@deepseek-ai/dsh-apiproxy` 等裸名解析到源码的**必要条件**(tsx 读 tsconfig paths;lib/ 未构建)。`@deepseek-ai/dsc-web` 不匹配 `dsh-*` 通配、走 node_modules workspace 软链 + package exports 解析,无需 paths。
+
+### ②-4 `.gitignore`
+
+现有 `.gitignore` 无泛 `dist/` 条目(只有 `dist-exe/`),追加一行:
+
+```
+apps/web/dist/
+```
+
+其余根配置(tsconfig.json references、tsconfig.build.json、tsdown.config.ts、coverage/knip/jscpd 各 glob)**一概不动**——apps/* 与新包学 website 先例:workspace 成员、自带构建、不进根构建图与门禁。
+
+## ③ 五个包 package.json 全文
+
+依赖纪律(本 step 统一):**全部用平铺 `dependencies`,内部包(含 vendored 的 cordis / @cordisjs/plugin-timer)一律 `workspace:^`**;不做仓库惯例的 peer+dev 双列(apps 是叶子、client 两包非 cordis 插件;apiproxy 正规化时再改)。全部 `private: true`,不发布。
+
+### ③-1 `apps/dsc/package.json`
+
+bin 字段按仓库惯例指 `lib/bin.js`,但 step1 不构建、不经 bin 调用——唯一运行路径是根 script `demo:web`(tsx 跑 src)。
+
+```json
+{
+ "name": "@deepseek-ai/dsc",
+ "description": "dsc CLI: `dsc web` serves the built web UI and boots the harness host",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "bin": {
+ "dsc": "lib/bin.js"
+ },
+ "files": [
+ "lib/bin.js",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@deepseek-ai/dsc-web": "workspace:^",
+ "@deepseek-ai/dsh-apiproxy": "workspace:^",
+ "@deepseek-ai/dsh-app-boot": "workspace:^"
+ }
+}
+```
+
+### ③-2 `apps/web/package.json`
+
+无 `main`/`"."` export——它是 vite 构建入口不是库;`"./dist/*"` export 是 apps/dsc 解析 dist 的唯一通道(`require.resolve` 走 exports 映射)。依赖四项都必须列(v2.1 修正,实测两次 build 失败得出):**`dsh-web-runtime`**——`src/main.ts` 直接 import,pnpm 严格 node_modules 下未声明不可解析;**react / react-dom**——`@vitejs/plugin-react` 强制 `resolve.dedupe: ['react','react-dom']`,dedupe 让 vite 从项目根 apps/web 解析而非从 importer(web-ui),apps/web 自己没有 react 即 resolve NULL。
+
+```json
+{
+ "name": "@deepseek-ai/dsc-web",
+ "description": "dsc web frontend: vite build entry producing dist/ served by apps/dsc",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "exports": {
+ "./dist/*": "./dist/*",
+ "./package.json": "./package.json"
+ },
+ "scripts": {
+ "build": "vite build",
+ "watch": "vite build --watch"
+ },
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@deepseek-ai/dsh-web-runtime": "workspace:^",
+ "@deepseek-ai/dsh-web-ui": "workspace:^",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.0.0",
+ "typescript": "^6.0.3",
+ "vite": "^6.0.0"
+ }
+}
+```
+
+### ③-3 `packages/host/apiproxy/package.json`
+
+入口按仓库模板指 `lib/`(step1 不构建;tsx 经 tsconfig paths 直接吃 src,lib 只为将来构建留位)。
+
+```json
+{
+ "name": "@deepseek-ai/dsh-apiproxy",
+ "description": "Programmatic harness host composition for dsc: bootHost mounts the core spine; step2 adds the ApiProxy contract",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@cordisjs/plugin-timer": "workspace:^",
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-agent-loop": "workspace:^",
+ "@deepseek-ai/dsh-bash-local": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+ "@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-tasks": "workspace:^",
+ "@deepseek-ai/dsh-tools": "workspace:^",
+ "cordis": "workspace:^"
+ }
+}
+```
+
+### ③-4 `packages/client/web-runtime/package.json`
+
+**入口直接指 src**(与 apiproxy 不同):消费者只有 vite(啃源码打包),step1 这两个 client 包不做任何构建。零依赖。
+
+```json
+{
+ "name": "@deepseek-ai/dsh-web-runtime",
+ "description": "Browser-side runtime layer for the dsc web UI (no React): runtime creation; step2 adds the api client and store",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./package.json": "./package.json"
+ },
+ "license": "BSD-3-Clause"
+}
+```
+
+### ③-5 `packages/client/web-ui/package.json`
+
+```json
+{
+ "name": "@deepseek-ai/dsh-web-ui",
+ "description": "React component layer for the dsc web UI: mount(el, runtime)",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "src/index.tsx",
+ "types": "src/index.tsx",
+ "exports": {
+ ".": "./src/index.tsx",
+ "./package.json": "./package.json"
+ },
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@deepseek-ai/dsh-web-runtime": "workspace:^",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "~18.3.1",
+ "@types/react-dom": "~18.3.0"
+ }
+}
+```
+
+## ④ 五个 tsconfig.json 全文
+
+形状照 `packages/examples/stdio-demo/tsconfig.json`(extends 根 base / rootDir src / outDir lib/types / include src / references 指依赖包目录)。apps/* 在顶层第二级,extends 相对路径少一级(`../../`)。这些 tsconfig step1 只服务 tsx 的 paths 解析与编辑器;不进根构建图、不跑 tsc 门禁。浏览器侧三包(web-runtime/web-ui/apps-web)覆写 `lib` 加 DOM、清空 `types`(去掉 base 的 node);含 JSX 的再加 `"jsx": "react-jsx"`。
+
+### ④-1 `apps/dsc/tsconfig.json`
+
+```json
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ { "path": "../../vendor/cordis" },
+ { "path": "../../packages/host/apiproxy" },
+ { "path": "../../packages/ui/app-boot" }
+ ]
+}
+```
+
+### ④-2 `apps/web/tsconfig.json`
+
+`vite.config.ts` 不进 include(它要 node 环境类型,与浏览器 src 冲突;vite 自己能跑它,编辑器红线忍受或将来拆 tsconfig.node.json——step1 不管)。
+
+```json
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
+ "types": [],
+ "jsx": "react-jsx"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ { "path": "../../packages/client/web-ui" }
+ ]
+}
+```
+
+### ④-3 `packages/host/apiproxy/tsconfig.json`
+
+```json
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ { "path": "../../../vendor/cordis" },
+ { "path": "../../../vendor/timer" },
+ { "path": "../../llm/llm" },
+ { "path": "../../llm/llm-deepseek" },
+ { "path": "../../core/session" },
+ { "path": "../../core/system-prompt" },
+ { "path": "../../core/tools" },
+ { "path": "../../core/agent" },
+ { "path": "../../tasks/tasks" },
+ { "path": "../../core/agent-loop" },
+ { "path": "../../session-persistence/session-persistence-jsonl" },
+ { "path": "../../bash/bash-local" }
+ ]
+}
+```
+
+### ④-4 `packages/client/web-runtime/tsconfig.json`
+
+```json
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
+ "types": []
+ },
+ "include": [
+ "src"
+ ]
+}
+```
+
+### ④-5 `packages/client/web-ui/tsconfig.json`
+
+```json
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
+ "types": [],
+ "jsx": "react-jsx"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ { "path": "../web-runtime" }
+ ]
+}
+```
+
+## ⑤ 源文件内容
+
+### ⑤-1 `packages/host/apiproxy/src/index.ts` — bootHost
+
+签名与插件清单(顺序即代码顺序;cordis 按 inject 自动挂起等依赖,顺序仅为可读性,但**逐个 await** 保证失败在 boot 期确定性上抛——不装 agent-spine-demo bundle,其 apply 内不 await 子插件、失败晚爆):
+
+```ts
+import { Context } from 'cordis'
+import Timer from '@cordisjs/plugin-timer'
+import LlmService from '@deepseek-ai/dsh-llm'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import TaskService from '@deepseek-ai/dsh-tasks'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
+
+export interface BootHostOptions {
+ persistenceRoot: string // apps/dsc 传 './.sessions'
+}
+
+export interface HostHandle {
+ ctx: Context
+ dispose(): Promise // = ctx.fiber.dispose()
+}
+
+export async function bootHost(options: BootHostOptions): Promise {
+ const ctx = new Context()
+ await ctx.plugin(Timer)
+ await ctx.plugin(LlmService)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SystemPrompt, { persona: '' })
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TaskService)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(LlmDeepSeek, {})
+ await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
+ await ctx.plugin(LocalBashExecutor, {})
+ return { ctx, dispose: () => ctx.fiber.dispose() }
+}
+```
+
+逐项说明(结论 + 一句注记):
+
+| 插件 | config 实参 | 注记 |
+|---|---|---|
+| `Timer` | 无 | AgentLoop 依赖链要 timer |
+| `LlmService` | 无 | default export 类 |
+| `SessionStore` | 无 | |
+| `SystemPrompt` | `{ persona: '' }` | schema 有 default(''),传空串显式化;step2 再定真 persona |
+| `ToolRegistry` | 无 | inject: ['systemPrompt'],晚于 SystemPrompt 列出仅为可读性 |
+| `AgentRegistry` | 无 | |
+| `TaskService` | 无 | |
+| `AgentLoop` | `{ agents: [] }` | **不预建 agent**(acp-demo 同款);inject: agents/sessions/llm/tools/systemPrompt |
+| `LlmDeepSeek` | `{}` | **函数插件,`import * as` 挂载**(named exports,无 default);apply 内 `config.apiKey ?? process.env.DEEPSEEK_API_KEY`,缺 key 直接 throw |
+| `SessionPersistenceJsonl` | `{ root: options.persistenceRoot }` | root 必填无默认(schema `.required()`) |
+| `LocalBashExecutor` | `{}` | default export 类;cwd 默认 process.cwd() |
+
+不装:skill 族、workspace-context、invariants、tool-bash(模型工具面 step2 随 agent 通信一起定)、fs 族、compact、subagent、UI 插件。step1 这个 host 起来后**什么都不做**,只证明 boot/dispose 通。
+
+### ⑤-2 `packages/client/web-runtime/src/index.ts`
+
+```ts
+export interface Runtime {
+ baseUrl: string // step2 的 ApiClient 从这里长出来
+}
+
+export function createRuntime(): Runtime {
+ return { baseUrl: window.location.origin }
+}
+```
+
+### ⑤-3 `packages/client/web-ui/src/index.tsx`
+
+```tsx
+import { createRoot } from 'react-dom/client'
+import type { Runtime } from '@deepseek-ai/dsh-web-runtime'
+
+function App({ runtime }: { runtime: Runtime }) {
+ return dsc web · skeleton · {runtime.baseUrl}
+}
+
+export function mount(el: HTMLElement, runtime: Runtime): () => void {
+ const root = createRoot(el)
+ root.render()
+ return () => root.unmount()
+}
+```
+
+### ⑤-4 apps/web 三件套
+
+`apps/web/index.html`(vite 约定:包根、script 指 src 入口):
+
+```html
+
+
+
+
+
+ dsc
+
+
+
+
+
+
+```
+
+`apps/web/src/main.ts`:
+
+```ts
+import { createRuntime } from '@deepseek-ai/dsh-web-runtime'
+import { mount } from '@deepseek-ai/dsh-web-ui'
+
+const el = document.getElementById('root')
+if (el === null) throw new Error('missing #root')
+mount(el, createRuntime())
+```
+
+`apps/web/vite.config.ts`(vite 默认 outDir 就是 dist、默认吃包根 index.html,无需多配;react 插件负责 web-ui 里的 JSX/tsx):
+
+```ts
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+})
+```
+
+vite 对 workspace 依赖的处理:`@deepseek-ai/dsh-web-ui` / `dsh-web-runtime` 经 node_modules 软链解析到源文件(package.json 入口直指 src,§③-4/③-5),vite 当普通源码编译——**不需要** resolve.alias 或 optimizeDeps 配置。
+
+### ⑤-5 `apps/dsc/src/bin.ts` — 动线(伪代码级,函数边界与真实 API 已核实)
+
+```ts
+#!/usr/bin/env node
+import { createServer } from 'node:http'
+import { parseArgs } from 'node:util'
+import { createRequire } from 'node:module'
+import { dirname, join, normalize, resolve, extname } from 'node:path'
+import { readFile } from 'node:fs/promises'
+import { loadEnv } from '@deepseek-ai/dsh-app-boot'
+import { bootHost } from '@deepseek-ai/dsh-apiproxy'
+
+// ---- 1. 参数 ----
+// argv: dsc web [--port N];positionals[0] !== 'web' → usage 到 stderr,exit 1
+const { values, positionals } = parseArgs({
+ args: process.argv.slice(2),
+ options: { port: { type: 'string', default: '3080' } },
+ allowPositionals: true,
+})
+if (positionals[0] !== 'web') { process.stderr.write('usage: dsc web [--port N]\n'); process.exit(1) }
+const port = Number(values.port)
+if (!Number.isInteger(port) || port <= 0 || port > 65535) { /* stderr + exit 1 */ }
+
+// ---- 2. env ----
+loadEnv('dsc') // 读 /.env 进 process.env;ENOENT 静默(app-boot 具名导出,已核实无 Loader 牵连)
+
+// ---- 3. host ----
+const host = await bootHost({ persistenceRoot: './.sessions' })
+// 缺 DEEPSEEK_API_KEY 时 LlmDeepSeek 在这里 throw → 顶层 rejection 打印后进程退出(fail loud,不 catch)
+
+// ---- 4. dist 根 ----
+// 选型:createRequire(同步、返回文件路径、workspace 软链下走真实 exports 映射;
+// import.meta.resolve 返回 URL 还得 fileURLToPath,弃)
+const require = createRequire(import.meta.url)
+const distIndex = require.resolve('@deepseek-ai/dsc-web/dist/index.html')
+// dist 不存在(没跑 vite build)时这里同步 throw ERR_MODULE_NOT_FOUND →
+// catch 后打印「先跑 pnpm --filter @deepseek-ai/dsc-web build」,exit 1
+const distRoot = dirname(distIndex)
+
+// ---- 5. 静态服务 ----
+const MIME: Record = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.json': 'application/json',
+ '.map': 'application/json',
+}
+const server = createServer(async (req, res) => {
+ // 只服务 GET/HEAD,其余 405
+ const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
+ const target = resolve(normalize(join(distRoot, pathname)))
+ // 路径穿越拒绝:target 必须等于 distRoot(即 `/`)或以 distRoot + '/' 为前缀,否则 403
+ if (target !== distRoot && !target.startsWith(distRoot + '/')) { /* 403; return */ }
+ try {
+ const body = await readFile(target === distRoot ? distIndex : target)
+ res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' })
+ res.end(body)
+ } catch {
+ // 未命中(ENOENT/EISDIR)一律回 index.html + text/html 200(SPA 将来路由)
+ res.writeHead(200, { 'content-type': MIME['.html'] })
+ res.end(await readFile(distIndex))
+ }
+})
+
+// ---- 6. listen + 打印 ----
+server.listen(port, '0.0.0.0', () => {
+ console.log(`dsc web: http://127.0.0.1:${port}`)
+})
+// listen 失败(EADDRINUSE):server.on('error') → stderr + disposeAndExit(1)
+
+// ---- 7. 停机(照 jsonrpc-demo/src/bin.ts:39-51 样板,多关一个 http server)----
+let exiting = false
+async function disposeAndExit(code: number): Promise {
+ if (exiting) return
+ exiting = true
+ try {
+ server.close() // 停止接受新连接;不等既有连接 drain(step1 不做)
+ await host.dispose() // = ctx.fiber.dispose()
+ } finally {
+ process.exit(code)
+ }
+}
+process.on('SIGTERM', () => { void disposeAndExit(0) })
+process.on('SIGINT', () => { void disposeAndExit(130) })
+```
+
+边界结论(实现时不要改):
+
+- **不用** app-boot 的 `boot()`/`installFailLoud`/`resolveConfigPath`——那是 Loader 路径;本 bin 只借 `loadEnv`。
+- 未知路径回 index.html 用 **200**(不是 404);`/api/*` step1 无特判,同样回 index.html,step2 再切。
+- 路径穿越判定基准:`resolve(target)` 必须等于 distRoot 或以 `distRoot + '/'` 为前缀(普通字符串前缀即可,distRoot 来自 require.resolve 已是绝对真实路径)。
+- 打印行固定 `http://127.0.0.1:`(listen 的是 0.0.0.0,打印回环地址供本地浏览器点击;容器场景用户自己换 IP)。
+
+## ⑥ 验收清单(从仓库根逐条执行)
+
+前提:根 `.env` 含 `DEEPSEEK_API_KEY`(step1 不发请求,但 LlmDeepSeek load 期查 key)。
+
+| # | 命令 | 期望 |
+|---|---|---|
+| 1 | `pnpm install` | 退出 0;`node_modules/@deepseek-ai/dsc-web` 等五个软链出现 |
+| 2 | `pnpm --filter @deepseek-ai/dsc-web build` | 退出 0;产出 `apps/web/dist/index.html` 与 `dist/assets/*.js` |
+| 3 | `pnpm run demo:web &`(后台起) | 数秒内 stdout 出现 `dsc web: http://127.0.0.1:3080` |
+| 4 | `curl -s http://127.0.0.1:3080/` | 返回 index.html 内容(含 ``) |
+| 5 | `curl -s http://127.0.0.1:3080/assets/<步骤2产出的js名>` | 返回 js;`curl -sI` 看 `content-type: text/javascript` |
+| 6 | `curl -s http://127.0.0.1:3080/no/such/route` | 返回 index.html(SPA 回退,HTTP 200) |
+| 7 | `curl -s --path-as-is 'http://127.0.0.1:3080/%2e%2e%2fpackage.json' -o /dev/null -w '%{http_code}'` | `403`(穿越拒绝。v2.1 修正:裸 `/../package.json` 即使带 `--path-as-is` 也测不到 403——server 侧 `new URL()` 先把 `/..` 折叠成 `/`,请求安全落为 SPA 回退 200+index.html、无泄漏;只有编码变体在 decodeURIComponent 后才出现 `..`、真正命中 403 分支) |
+| 8 | 浏览器开 `http://<容器IP>:3080/` | 页面渲出 `dsc web · skeleton · http://<容器IP>:3080` |
+| 9 | 前台 `pnpm run demo:web` 后 Ctrl-C | 进程退出(预期码 130;tsx/pnpm 链路下 shell 观察值可能是信号态,不必较真——能干净退出即过) |
+| 10 | `kill -TERM
` | 退出码 0 |
+| 11 | 缺 key 场景:`env -u DEEPSEEK_API_KEY DEEPSEEK_API_KEY= pnpm run demo:web`(或临时改名 .env) | 非零退出,stderr 含 `llm-deepseek: an API key is required` |
+| 12 | 没跑步骤 2 就 `rm -rf apps/web/dist && pnpm run demo:web` | 非零退出,stderr 提示先跑 `pnpm --filter @deepseek-ai/dsc-web build` |
+
+验收 3-10 期间 `.sessions/` **不应该**出现(没有 agent、没有 session;SessionPersistenceJsonl 惰性建目录)。出现即说明 bootHost 多干了事。
+
+## ⑦ step2 接缝(一句话,不复制契约)
+
+apiproxy 的 API 契约(`api/` 类型层、fetch 载体、SSE 流、web-runtime 侧 ApiClient/fold/store)**唯一权威在 `../20260719-1902-apiproxy-api-design/design.md`**,且其命名体系仍在演进——本文档不复制任何契约类型名。step1 只保证接缝物理位置:`/api/*` 请求将来在 bin.ts 静态服务 handler 最前面加一个前缀分支转给 apiproxy 的 fetch handler,静态部分零改动;bootHost 返回的 `ctx` 就是将来构造 ApiProxy impl 的输入。
+
+## ⑧ 与 v1 的差异记录(给 review 者,不影响实现)
+
+- v1 的三个遗留问题全部已拍板落死:vite ^6 + plugin-react ^4;`@deepseek-ai/dsc` + bin `dsc` + 子命令 `web`;persistenceRoot `./.sessions`。
+- v1 写「apps/dsc deps 双列 peer+dev」参考 examples 模板——v2 改为**五包全平铺 dependencies**(用户拍板:apps 叶子不玩双列;client 包非 cordis 插件同理;apiproxy 将来正规化再改)。
+- v1 未定 client 包入口形态——v2 定为 src 直入口(main/exports 指 `./src/index.ts(x)`),因 step1 唯一消费者是 vite。
+- dist 解析在 createRequire 与 import.meta.resolve 二选一——v2 定 createRequire。
diff --git a/missions/tasks/20260719-1843-step1-skeleton-design/harness-boot-facts.md b/missions/tasks/20260719-1843-step1-skeleton-design/harness-boot-facts.md
new file mode 100644
index 0000000000..4f2a9353fa
--- /dev/null
+++ b/missions/tasks/20260719-1843-step1-skeleton-design/harness-boot-facts.md
@@ -0,0 +1,184 @@
+# Harness 接线事实清单:程序化 boot cordis root + agent spine
+
+核实日期:2026-07-19。所有相对路径均相对 worktree 根 `/weka-hg/prod/deepseek/permanent/ys/private/workspace/github/deepseek-harness/.vscode/worktrees/worktree-web2`。行号以当前 HEAD(9eb1fbd5d)为准。
+
+## 1. 程序化 boot 最小做法(不经 Loader / cordis.yml)
+
+我们自己做一个 preset 放在 apps/dsc 下面,就像 agent-spine-demo 一样。(但是我们这里全拍平,不依赖其他 preset 包)
+还允许开发者配置 cordis.yml 读取 ~/.dsc/cordis.yml
+所以,我们这个也算一个 (portal)独立入口了。
+
+### 1.1 核心 API:`new Context()` + `ctx.plugin()` + await fiber
+
+- `ctx.plugin(plugin, config)` 返回一个 thenable fiber:`vendor/cordis/src/registry.ts:315-335`。`wrapped.then` 直接代理到 `fiber.await()`(`registry.ts:330-333`),所以 **`await ctx.plugin(X, config)` 就是"等该插件 fiber 稳定并重抛启动错误"** —— 没有独立的 `ctx.start()`。
+- `Fiber.await()` 语义(等 inertia 清空、`_error` 存在则 throw):`vendor/cordis/src/fiber.ts:701-707`。
+- 停机侧对应物是 `ctx.fiber.dispose()`(Fiber 的 `dispose` 字段声明在 `vendor/cordis/src/fiber.ts:193`)。
+
+### 1.2 仓库内现成的"纯程序化装满 spine"范例
+
+最完整的一份在 `packages/context/workspace-context/tests/workspace-context.e2e.ts:37-53`:
+
+```
+ctx = new Context() // :37
+await ctx.plugin(LlmService) // :38 @deepseek-ai/dsh-llm
+await ctx.plugin(SessionStore) // :39 @deepseek-ai/dsh-session
+await ctx.plugin(SystemPrompt, { persona: '...' }) // :40 @deepseek-ai/dsh-system-prompt
+await ctx.plugin(ToolRegistry) // :41 @deepseek-ai/dsh-tools
+await ctx.plugin(AgentRegistry) // :42 @deepseek-ai/dsh-agent
+await ctx.plugin(LocalFileSystem, { cwd: '/' }) // :43 @deepseek-ai/dsh-fs-local
+await ctx.plugin(ToolFs) // :44
+await ctx.plugin(WorkspaceContext, { maxBytes: 65536 }) // :45
+await ctx.plugin(AgentLoop, { agents: [] }) // :46 @deepseek-ai/dsh-agent-loop
+await ctx.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] }) // :47
+const handle = await ctx.agents.create({ sessionId, meta: { cwd }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' } }) // :48-52
+```
+
+teardown 用 `await ctx?.fiber.dispose()`(同文件 `:27`)。等空闲的方式是订阅 `ctx.on('agent/status', ...)` 等 `'idle'`(`:56-65`)。
+
+更小的官方 helper:`packages/support/agent-loop-testkit/src/index.ts:37-46`(`mountAgentLoopTestDependencies`)依次 `await ctx.plugin()` 挂 LlmService / SessionStore / SystemPrompt / ToolRegistry / AgentRegistry,注释明确"await 逐个装,失败即 reject"。
+
+### 1.3 更省事的做法:直接装 spine bundle 插件
+
+`@deepseek-ai/dsh-agent-spine-demo` 是一个"函数插件 bundle",其 `apply()` 一次性挂全默认 spine(`packages/examples/agent-spine-demo/src/index.ts:136-170`),子插件与传参逐个是:
+
+| 顺序 | 插件 | config 形状 | 行号 |
+|---|---|---|---|
+| 1 | `@cordisjs/plugin-timer` (Timer) | 无 | :144 |
+| 2 | `@deepseek-ai/dsh-llm` (LlmService) | 无 | :145 |
+| 3 | `@deepseek-ai/dsh-session` (SessionStore) | 无 | :146 |
+| 4 | `@deepseek-ai/dsh-system-prompt` | `{ persona, toolOrder? }` | :148-151 |
+| 5 | `@deepseek-ai/dsh-tools` (ToolRegistry) | `config.tools ?? {}` | :152 |
+| 6 | `@deepseek-ai/dsh-skill` (SkillService) | `config.skills?.registry ?? {}` | :153 |
+| 7 | `@deepseek-ai/dsh-skill-local` | `{ ...skills.local, dshHome }` | :154 |
+| 8 | `@deepseek-ai/dsh-agent` (AgentRegistry) | 无 | :155 |
+| 9 | `@deepseek-ai/dsh-tasks` (TaskService) | 无 | :156 |
+| 10 | `@deepseek-ai/dsh-invariants` | 无 | :157 |
+| 11 | `@deepseek-ai/dsh-tool-bash` | `{ ...toolBash, dshHome }` | :158 |
+| 12 | `@deepseek-ai/dsh-workspace-context` | `config.workspaceContext`(`false` 时不装) | :159-161 |
+| 13 | `@deepseek-ai/dsh-tool-skill` | `config.skills?.tool ?? {}` | :164 |
+| 14 | `@deepseek-ai/dsh-tool-tasks` | `config.toolTasks ?? {}` | :165 |
+| 15 | `@deepseek-ai/dsh-agent-loop` (AgentLoop) | `{ agents: config.agents ?? [], maxParallelToolCalls? }` | :166-169 |
+
+要点:
+
+- **装载顺序无关紧要**(cordis 按 `inject` 挂起 fiber 直到依赖服务出现),列表顺序只为可读性 —— 该文件 JSDoc 明说(`:130-134`)。但两个 session-prefix 生产者(workspaceContext 与 toolSkill)的注册顺序 = 渲染顺序(`:162-164`)。
+- bundle 不装 LLM 适配器、bash 执行器、持久化、UI —— 那些是"部署选择",由外层继续 `ctx.plugin()`(如 `LlmDeepSeek`、`@deepseek-ai/dsh-bash-local`、`SessionPersistenceJsonl`)。
+- bundle 的 `apply()` 内部 `ctx.plugin()` **不 await**;stdio-demo 单测挂完后靠 `setTimeout 80ms` 等子 fiber 稳定(`packages/examples/stdio-demo/tests/stdio-agent.spec.ts:19-27` 及其注释 "The app mounts its children inside apply() (not awaited there)")。程序化 boot 若要确定性等待,逐个 `await ctx.plugin()`(1.2 的做法)更稳。
+
+### 1.4 stdio-demo 的组合形状(composeTerminalApp,作为 app 层参照)
+
+`packages/examples/stdio-demo/src/index.ts:143-173`:先按 TTY 选 UI 模式(readline 时装 `@cordisjs/plugin-logger-console`,`:147`),然后依次 `ctx.plugin(SessionPersistenceJsonl, { root })`(`:148`)、`ctx.plugin(UserInteractionService)`(`:149`)、选定的 `uiTui`/`uiStdio`(带 `welcome`+`sessionId`,`:150-161`)、`ctx.plugin(agentCore, { ...pickSpineConfig(config), agents: [{ id, provider, model, cwd: process.cwd(), sessionId | resumeSessionId }] })`(`:162-171`)、最后 `ctx.plugin(toolAskUser)`(`:172`)。
+
+### 1.5 Loader 路径的 boot(对照,dsh-app-boot)
+
+`packages/ui/app-boot/src/index.ts:114-126` 的 `boot()`:`new Context()` → 设 `ctx.baseUrl`(`:116`)→ `await ctx.plugin(Loader)`(`:117`)→ 注册 include builtin(`:118`)→ `ctx.loader.create({ name: 'cordis:include', config: { path } })`(`:119-122`)→ **`await ctx.loader.await()`** 等整树稳定(`:123`;实现是 `vendor/loader/src/config/tree.ts:43-49`,循环 `Promise.allSettled` 所有 pending 任务)→ `assertEntriesLoaded()` 拒绝无 fiber 条目(`:124`,实现 `:89-95`)。
+
+## 2. acp-demo 不预建 agent 的写法
+
+- acp-demo 的 `apply()` 里 **根本不给 spine 传 `agents` 字段**:`ctx.plugin(agentCore, agentCore.pickSpineConfig(config))`(`packages/examples/acp-demo/src/index.ts:90`)。`pickSpineConfig` 的类型就是 `Omit`(`packages/examples/agent-spine-demo/src/index.ts:112`)。
+- 缺省落到 spine 的 `agents: config.agents ?? []`(`packages/examples/agent-spine-demo/src/index.ts:167`),最终是 AgentLoop schema 的 `.default([])`(`packages/core/agent-loop/src/index.ts:413-419`);AgentLoop 构造器只对 `config.agents` 里的条目预建 agent(`:440` 起的 for 循环),空列表即什么都不建。
+- 语义注释两处:spine 的 Config JSDoc "`agents` to the agent loop (an app that pre-creates no agents, like the ACP bridge, simply omits it)"(`packages/examples/agent-spine-demo/src/index.ts:43-44`);acp-demo Config JSDoc "NOT a pre-created agent — ACP creates agents at `session/new`"(`packages/examples/acp-demo/src/index.ts:27-28`)与 apply JSDoc "pre-creates NO agents (its `agents` list defaults to `[]`) ... creates one agent per `session/new`"(`:83-87`)。
+- agent 真正被创建的时机:ACP bridge 收到 `session/new` RPC 时 `await agents.create({ sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), setup })`(`packages/ui/acp/src/index.ts:654-668`,`agents.create` 在 `:662`)。
+
+## 3. DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL / 根 .env 的读取链路
+
+三层,全部与 dotenv 包和 `node --env-file` 无关:
+
+1. **bin 层读 `.env` 进 process.env**:`loadEnv()` 用 Node 内建 `process.loadEnvFile(resolve(dir, '.env'))`,dir 默认 `process.cwd()`;ENOENT 静默回退到环境(`packages/ui/app-boot/src/index.ts:40-52`,`loadEnvFile` 调用在 `:45`)。各 bin 在 boot 前调用:stdio-demo `src/bin.ts:17`、acp-demo `src/bin.ts:23`(replay 快照模式跳过)、jsonrpc-demo `src/bin.ts:20`。
+2. **cordis.yml 层用 `!!js` 把 env 显式喂进插件 config**:`examples/repl-agent/cordis.yml:18-19`(`apiKey: !!js process.env.DEEPSEEK_API_KEY`、`baseURL: !!js process.env.DEEPSEEK_BASE_URL`);acp-agent 同款(`examples/acp-agent/cordis.yml:10-11`)。
+3. **插件层兜底再读一次 process.env**:`@deepseek-ai/dsh-llm-deepseek` 的 `apply()` 里 `config.apiKey ?? process.env.DEEPSEEK_API_KEY`(缺 key 直接 throw,load 期 fail loud)与 `config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL`(`packages/llm/llm-deepseek/src/index.ts:82-86`)。所以**程序化 boot 只要 process.env 里有 key,`ctx.plugin(LlmDeepSeek, {})` 即可工作**(1.2 范例正是这么干的)。
+
+## 4. 停机 / dispose
+
+统一原语:**`ctx.fiber.dispose()`**(root context 自己的 fiber)。各 demo 的触发方式:
+
+- **jsonrpc-demo(信号处理最完整的样板)**:`packages/examples/jsonrpc-demo/src/bin.ts:39-51` —— `disposeAndExit(code)` 带 `exiting` 单次门闩,`try { await ctx.fiber.dispose() } finally { process.exit(code) }`;接线为 `process.stdin.on('end') → 0`、`SIGTERM → 0`、`SIGINT → 130`(`:49-51`)。
+- **acp-demo**:仅快照模式在 stdin EOF 时 `void ctx.fiber.dispose().then(() => process.exit(0))`(`packages/examples/acp-demo/src/bin.ts:30-34`);正常运行 "editors normally own process lifetime"(`:8`),无信号处理。
+- **cli-demo**:bin 层不直接 dispose——SIGINT/SIGTERM 只 abort 一个 AbortController 并记退出码 130/143(`packages/examples/cli-demo/src/bin.ts:15-33`);dispose 在 cli.ts 内部:`runtime.dispose ?? (target => target.fiber.dispose())`(`packages/examples/cli-demo/src/cli.ts:409`),任务收尾时 `await disposeContext(ctx)`(`:438`)。
+- **stdio-demo**:bin 无信号处理(`src/bin.ts` 全文仅 19 行);退出由 stdio UI 插件驱动——stdin EOF 后 `maybeExit()` 等 agent idle,再经 200ms flush 定时器调 `exit(0)`(默认 `process.exit`)(`packages/ui/stdio/src/index.ts:212-229`,默认 exit 钩子 `:464`)。
+- 测试里的顺序惯例:先 `await ctx.fiber.dispose()` 再清理临时目录(`packages/context/workspace-context/tests/workspace-context.e2e.ts:26-31`)。
+
+## 5. pnpm-workspace.yaml 现状(全文)
+
+`pnpm-workspace.yaml` 全文如下。**glob 是 `packages/*/*`(`:3`),目前没有 `apps/*`**;成员为 `vendor/*`、`packages/*/*`、`website`、`examples`(仅依赖解析、非构建目标,见 `:5-10` 注释)、`python/sdk-runtime`。
+
+```yaml
+packages:
+ - vendor/*
+ - packages/*/*
+ - website
+ # The runnable demo leaves join as ONE workspace member: examples/package.json
+ # declares the union of every leaf's cordis.yml plugins as workspace:*, so a
+ # plain-node (`:lib`) boot of any leaf (examples//cordis.yml) resolves its
+ # plugins through real package `exports`→lib by walking up to examples/node_modules.
+ # Members for DEPENDENCY RESOLUTION only — NOT build targets: tsdown's explicit
+ # globs (vendor/*, packages/*/*) exclude them. See the example-execute-over-tsx RFC.
+ - examples
+ # Deploy root of the single-exe build: a pure dependency manifest whose
+ # closure is what the exe bundles and what the Python runtime distributes.
+ - python/sdk-runtime
+
+peerDependencyRules:
+ allowedVersions:
+ typescript: '>=5 <7'
+
+# pnpm 10+ blocks any dependency shipping an install/build script until it is
+# explicitly reviewed here (strictDepBuilds defaults to true: an unlisted script
+# is a hard install error). Every such package MUST be listed; we deny by
+# default and only allow scripts we need. esbuild (native binary) and lefthook
+# (git hooks) genuinely need theirs.
+allowBuilds:
+ esbuild: true
+ lefthook: true
+ # Pulled in by @earendil-works/pi-ai (optional LLM API backend). pnpm lists
+ # them only because they ship lifecycle scripts, but those are no-ops we don't
+ # need, so we deny them — install still succeeds.
+ '@google/genai': false
+ protobufjs: false
+ node-addon-require-builtin: false
+
+# The Landlock launcher family is our own sibling-repo release, consumed
+# fresh (hours old at each coordinated bump) — the release-age quarantine
+# would block every such bump, so the family is exempted BY NAME, not by
+# pinned version.
+minimumReleaseAgeExclude:
+ - node-addon-landlock-run
+ - node-addon-landlock-run-linux-arm64
+ - node-addon-landlock-run-linux-x64
+ # Cordis release candidates are source-vendored and pinned in vendor/README.md
+ # during the same-day sync that updates package manifests and the lockfile.
+ - '@cordisjs/plugin-loader@1.0.0-rc.5'
+ - cordis@4.0.0-rc.7
+```
+
+tsdown 侧印证 "examples 目录非构建目标":根 `tsdown.config.ts:16` 只 bundle `workspace: ['vendor/*', 'packages/*/*']`。
+
+## 6. demo 脚本运行方式与构建产物
+
+- **demo 脚本全部是 tsx 跑 src**(根 `package.json:81-87`):
+ - `demo:echo` / `demo:repl` / `demo:tui` / `demo:cordis`:`node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples//cordis.yml`(`:81,82,84,86`;`--expose-internals` 是 HMR 需要)
+ - `demo:headless`:`node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml`(`:83`)
+ - `demo:acp`:`node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml`(`:87`,无 `--expose-internals`,因 ACP 无 HMR)
+- **发布/built 路径是 plain node 跑 `lib/bin.js`**:built-bin e2e 明确 "run `lib/bin.js` under plain Node ... NO tsx"(`packages/examples/stdio-demo/tests/built-bin.e2e.ts:10,17,112`)。
+- **构建管线**:`build = tsc -b tsconfig.build.json && tsdown`(根 `package.json:16`)。tsc 先出 `lib/types/*.js + d.ts`,tsdown 从 `lib/types/index.js` bundle 出 `lib/index.js`(根 `tsdown.config.ts:12-27`,`dts: false`)。**带 bin 的包需要自己的 tsdown override 加第二个 entry**:`packages/examples/stdio-demo/tsdown.config.ts` 的 `entry: ['lib/types/index.js', 'lib/types/bin.js']`(acp-demo 同款)。
+- bin 字段指向构建产物:`"bin": { "dsh-stdio-demo": "lib/bin.js" }`(`packages/examples/stdio-demo/package.json:9-11`)、`"dsh-acp-demo": "lib/bin.js"`(`packages/examples/acp-demo/package.json:9-11`)。
+
+## 7. 三个 examples 包 package.json 形状(新包模板参考)
+
+共同形状(三个包一致):
+
+- `"name": "@deepseek-ai/dsh-"`、`"version": "0.0.1"`、`"private": true`、`"type": "module"`、`"license": "BSD-3-Clause"`
+- `"main": "lib/index.js"`、`"types": "lib/types/index.d.ts"`
+- `exports`:`"."` → `{ types: ./lib/types/index.d.ts, default: ./lib/index.js }`;有 bin 的再加 `"./bin"` 同构;一律带 `"./src/*": "./src/*"` 和 `"./package.json": "./package.json"`
+- `files`:`lib/index.js`(+ `lib/bin.js`)、`lib/types/**/*.d.ts`、`lib/types/**/*.d.ts.map`、`src`
+- **依赖模式:所有运行时依赖同时出现在 `peerDependencies`(`^0.0.1` / cordis `^4.0.0-rc.7`)和 `devDependencies`(`workspace:^`)**,符合根约定 "cordis is a peerDependency (+ dev) of every harness package"。
+
+逐包:
+
+- **stdio-demo**(`packages/examples/stdio-demo/package.json`):有 `bin`(`:9-11`)、`./bin` export(`:17-20`);peers 含 plugin-include/plugin-loader/plugin-logger-console、dsh-app-boot、dsh-agent、dsh-agent-loop、dsh-llm、dsh-agent-spine-demo、dsh-workspace-context、dsh-session、dsh-session-persistence-jsonl、dsh-stdio、dsh-tui、dsh-tool-ask-user、dsh-tools、dsh-user-interaction、cordis、schemastery(`:32-51`)。
+- **agent-spine-demo**(`packages/examples/agent-spine-demo/package.json`):无 bin;peers 是 spine 全家(timer、agent、agent-loop、invariants、home、llm、workspace-context、session、skill、skill-local、system-prompt、tasks、tool-bash、tool-skill、tool-tasks、tools、cordis,`:24-42`);**特例:`schemastery` 在 `dependencies` 而非 peer**(`:63-65`)。
+- **acp-demo**(`packages/examples/acp-demo/package.json`):有 `bin`(`:9-11`);peers 含 plugin-include/plugin-loader(无 logger-console —— stdout 纯 JSON-RPC)、dsh-app-boot、dsh-acp、dsh-agent-spine-demo、dsh-workspace-context、dsh-session-persistence-jsonl、dsh-tools、dsh-user-interaction、cordis、schemastery(`:32-44`)。
+
+## 附:repl-agent cordis.yml 的 Loader 声明式全量清单(对照)
+
+`examples/repl-agent/cordis.yml` 挂载(id → 包名,含 config 要点):`hmr`(root `['.']`,`:9-12`)、`llm-deepseek`(apiKey/baseURL 走 `!!js` env,`:15-19`)、`bash` = dsh-bash-local(`timeoutMs: 60000`,`:22-25`)、`stdio-agent` = dsh-stdio-demo(provider/model、resumeSessionId `!!js`、persistenceRoot `./.sessions`、workspaceContext.maxBytes 65536、ui.mode readline、persona,`:28-48`)、`token-meter`(`:51-52`)、`compact-basic`(`:56-57`)、`subagent` + `subagent-spawn` + `subagent-fork` + 两个 `tool-subagent`(`:62-85`)、`workflow-workerthread` + `tool-workflow`(`:90-96`)、`tool-todo`(`:98-99`)、`fs-local`(`cwd: !!js process.cwd()`,`:104-106`)、`fs-policy`(`:108-109`)、`tool-fs`(`:111-112`)、`tool-fs-search`(`:117-118`)、`timeout-policy`(`:124-125`)、`spill-local` + `spill-policy`(`maxInlineBytes: 50000`,`:132-138`)。
diff --git a/missions/tasks/20260719-1902-apiproxy-api-design/README.md b/missions/tasks/20260719-1902-apiproxy-api-design/README.md
new file mode 100644
index 0000000000..1ba87b765b
--- /dev/null
+++ b/missions/tasks/20260719-1902-apiproxy-api-design/README.md
@@ -0,0 +1,68 @@
+# apiproxy 统一 API 层设计(step2 协议基础)
+
+任务:定义 apiproxy 对多形态(Web/Electron/TUI)暴露的统一 API 接口层 + web client 的 HTTP/SSE 架构。主会话直写(决策密度高),文档等用户 review。
+
+## 用户拍板记录(2026-07-19)
+
+| 议题 | 结论 |
+|---|---|
+| 契约权威 | TS interface 权威 + fetch 载体(进程内注入 handler 当 fetch = opencode 同构点) |
+| 流形态 | AsyncIterable + AbortSignal |
+| 事件面 | 两条 SSE:全 session 一条 mux 聚合 + host 信息一条(沿用旧结论) |
+| 接口分组 | 按业务域分文件:`sessions.ts` 一域一文件 |
+| 路径映射 | RPC 风格,不考虑 REST 体验 |
+| 错误模型 | 类型化 ResultType(不 throw) |
+| 校验 | zod 双向校验,**不要 passthrough**;可 dev-only 开启 |
+| 事件 payload | 原则透传 core 结构,不自造封装;tool presentation 先透传,文档标注遗留 |
+| 历史读取 | 按简单来,不做多套(事件重放 + client 单一 fold) |
+| mux 重连 | 不做 since 续传(签名留座),重连=重开流+重拉 history(采纳 opencode 对照建议) |
+| 冷 session | attach 状态不对客暴露,只给 running(冷=false);history/prompt 隐式 resume |
+| history 分页 | **按消息边界切页**(不从消息中间截断,chunk 随定稿消息归组),参数 maxMessages |
+| SessionSummary | v1 不建索引:sessionId + 文件 mtime(updatedAt) + running 三字段 |
+| prompt 载荷 | 直接收 core `ContentBlock[]`,不设 text 简化层 |
+| schema 文件 | 一域一对:`sessions.ts`(类型)+ `sessions.schema.ts`(zod) |
+| subscribed 帧 | 保留 lastSeq 字段(history 补缝竞态检测) |
+| SessionListCursor(2026-07-19 20:00) | **不 brand**:v1 未实现占位用裸 `cursor?: string`,实现分页时再定是否 brand |
+| HostInfo(2026-07-19 20:02) | 五字段定稿:version/cwd/provider?/model?/attachedSessions;**不设 protocolVersion**(client/host 绑定发布,无跨版本组合;独立发布 client 出现时再引入) |
+| RPC 命名体系(2026-07-19 20:15) | ApiResult→**RpcResponse**(方向可辨识:Request 族 / Response 族 / Frame 族);**每方法具名 Request/Response,签名禁内联**;空 request 也具名;流方法 signal 独立第二参不进 Request;HostInfo 并入 HostDescribeResponse;`hostEvents` 方法名改 `host`(对齐 wire 路径 `events.host`,避免 EventsHostEventsRequest 推导怪名) |
+| RPC map(2026-07-19 20:30) | 方向拍板:加 RpcMethodMap + ClientRequest\/ServerResponse\ 泛型索引;`events.host` 改名通过;map key 单复数授权设计层定(定单数 `session.list`,wire 路径同步);形态 A vs B 并排呈案 |
+| RPC map 终选(2026-07-19 20:41) | **形态 B:函数签名即事实源**——参数/返回内联在方法签名,RpcMethodMap 登记方法,ClientRequest/ServerResponse/ServerValue infer 反推;「禁内联」放宽为「禁重复内联」;平铺具名 15 类型删除(zod 直接锚派生类型,报错展开代价用户接受);空 request 用 `{}` |
+| RpcError 强类型化(2026-07-19 20:56) | **details 走错误码→类型 map**(RpcErrorDetailsMap,与 RpcMethodMap 同构第二张表);RpcError 用 map 展开分布式 union(泛型 interface 默认形式不窄化,弃);**details 必填**(internal 显式 `{}`),「必须真填」纪律升编译期强制;zod 用 discriminatedUnion('code') 逐支锚定 |
+| 审批/问答形态(2026-07-19 21:00) | **「unary 对」定案**:请求下行 mux 帧(requested,稳定 id+session 锚)、回答上行 HTTP unary(respond 带 id),不做双向帧;域从不做清单**升格为本轮出协议设计**(实现可排后);细节提案见 core-coverage.md 审批/问答节,随 L1-L7 一并裁决 |
+| rpcId + 信封(2026-07-19 21:04) | **所有 unary 指令带 client mint 的 rpcId**(不做只 prompt 带的区分);**wire 两层分离**:RpcRequestEnvelope(rpcId/method/payload) / RpcResponseEnvelope(rpcId/result);ApiProxy 签名不感知信封(载体层统一包/解);method 字段保留(日志自含+path 校验);RpcId brand(首个 client mint id,构造函数照 SessionId 先例);SSE open 不带 rpcId;prompt 的 rpcId 经 MessageSource 透传为 provisional 关联机制(转正执行仍 v1 不做) |
+| 整轮裁决(2026-07-19 21:13) | **L1/L2/L5 进契约**(SessionSummary+session-added 帧加 parentSessionId?、SessionSummary 加 cwd?、HostFrame 加 host/agent-error);**L3/L4/L6/L7 类型写全预留**(design §8,不进 map——fail loud 优于 not-implemented 兜底);**审批/问答提案整体采纳**并入正文 §3.4(先到先赢/resolved 收敛/subscribed 基线重放/ApprovalRequestId 复用+QuestionAskId host mint 均按推荐);rpcId≠资源 id 辨析入档;design.md 定稿 v1.5 |
+| 问答 id 统一(2026-07-19 21:19) | **取消 QuestionAskId**:问题标识复用 `RpcId` 类型(host 受理 ask 时同一 `RpcId()` mint);RpcId 语义扩为「**交互发起方 mint**」;**审批仍透传 core ApprovalRequestId,有意不对称**(durable 审计事件关联+透传非自造);rpcId≠资源 id 辨析措辞更新(类型统一、语义仍分立) |
+| 帧信封对称化(2026-07-19 21:19) | **每条 SSE 帧包 `RpcFrameEnvelope{rpcId, frame}`**,rpcId 由 server 发帧时 mint(标识这一次推送);职责=日志对账+去重/追溯接缝,**不承担 cursor**(续传锚仍是 event.seq);流侧同样签名不感知信封(AsyncIterable\ 不变,createApiClient 内拆);信封模型全对称:Request/Response/Frame 三信封 |
+| 签名信封化(2026-07-19 21:5x) | **推翻「签名不感知信封」**:ApiProxy 方法签名显式收 `RpcRequest` 封装(rpcId 进签名不进业务 payload);「单次 HTTP 所以 rpcId 不重要」论证被用户否定——rpcId 是逻辑层关联,不因传输自带信道而省略 |
+| 四象限消息模型(2026-07-19 22:00–22:1x,v2.0) | **通道与消息彻底解耦**(HTTP=C→S 通道、SSE=S→C 通道,仅此而已);wire 全形=**四具名判别 union**(ClientRequest/ServerResponse/ServerRequest/ClientResponse,22:1x 用户坚持字面四具名,判别子=type 四字面量);**纯推送=不期待应答的 server-request,严格二分不设 notify**(22:1x 用户采纳设计层方案);**respond 重建模为 client-response**:回填 requested 帧 rpcId、不 mint 新 id、不进 RpcMethodMap,wire=POST /api/respond 单端点、HTTP 应答体=RpcReceipt 载体回执;两个 not-pending 错误码删除;泛型工具撞名改 RequestPayload\/ResponseValue\;流签名 yield RpcRequest\<帧\>(rpcId 暴露给业务层);流程放宽:文档更新完直接生码不等确认 |
+| client 载体类体系(2026-07-20 shape-a + abstract-base,commit 893421d50;本行由 rfc-consolidation 代笔补记——apiproxy-design 静默中,RFC 第二篇写作核码顺手补一致) | **createApiClient 工厂废除,改 AbstractApiClient 抽象基类**:协议不变量(mint/四象限包解/zod/SSE 解帧/超时/rpcId 回显校验)全在基类,平台差异=两切面(抽象 doFetch 传输 + 可覆写 onEnvelope 观测);**IApiClient=caller 视图(shape a)**:unary 收业务 payload 直传、载体 mint、业务代码永不 mint,与 ApiProxy(impl 窄形契约)由基类桥接;**实例级 envelope 观测**:subscribeEnvelopes 批量订阅(微任务合批、异常隔离、无订阅者零成本),旧 onEnvelope 选项+ApiEnvelopeTapEvent 废除,rpcLog 降纯订阅者;子类=InProcessApiClient(同构点新写法)/WebApiClient/FixtureApiClient(协议层覆写虚方法,假信封包装器删除);design.md §4.1/§5 已同步 |
+
+## 文件索引
+
+| 文件 | 内容 |
+|---|---|
+| `design.md` | API 层设计文档(主产出) |
+| `opencode-crosscheck.md` | opencode 调研对照表(同构面验证/CQRS 同向/重连砍 cursor,建议已采纳) |
+| `core-coverage.md` | core 能力面 × 契约覆盖度盘点(7 域四态标注 + 漏判清单 L1-L7 已裁决,存档;契约以 design.md 为准) |
+
+## 进展
+
+| 时间 | 事项 |
+|---|---|
+| 2026-07-19 19:02 | 主会话开写 design.md;核实 core 类型面(SessionEvent/seq/foldSurface/Agent 原语) |
+| 2026-07-19 19:06 | design.md v1 落盘:三域接口(sessions/host/events)、ApiResult、fetch 载体映射、client 分层、不做清单、3 个开放问题 |
+| 2026-07-19 19:10 | opencode 调研回队;对照写入 opencode-crosscheck.md(同构面/CQRS/无 cursor 重连三判断) |
+| 2026-07-19 19:48 | 用户拍板 Q1–Q8;design.md 升 v1.1:重连重建、消息边界分页、ContentBlock 透传、schema 文件对、lastSeq 保留;开放问题清零(时间按 design.md 文件 mtime 推断) |
+| 2026-07-19 20:00 | 用户拍板 SessionListCursor 取消 brand;design.md 同步(id 纪律 + §3.1 `cursor?: string`) |
+| 2026-07-19 20:02 | 用户拍板 HostInfo 五字段定稿(去 protocolVersion,命名回 version);design.md §3.2 落 interface 全文 + 决策边界注记 |
+| 2026-07-19 20:15 | 用户拍板 RPC 命名体系重构;design.md 全文替换:RpcResponse/RpcError(rpc.ts)、11 个具名 Request/Response、Frame 族独立、新增「命名 convention」小节 |
+| 2026-07-19 20:30 | 用户三点裁决:key 单复数授权设计层(定单数)、events.host 通过、map 形态待终选;design.md 落「RPC map 两种形态」对比小节(A 类型对 / B 函数签名 infer,五维差异表 + 推荐 A) |
+| 2026-07-19 20:41 | 用户终选形态 B;design.md 升 v1.3 分批收尾:map 节改终选结论、§3 三域内联回归签名(删 15 个平铺具名)、convention 改「禁重复内联」、zod 锚 infer 派生、布局落 rpc-map.ts、wire 表 key 对齐 |
+| 2026-07-19 20:54 | core-coverage.md 盘点完成(session/agent/subagent/tasks/审批问答/杂项/LLM 七域,file:line 为证);漏判清单 L1-L7:建议进 v1 三条(L1 谱系/L2 cwd/L5 agent-error 帧)、留接缝四条(L3 fork/L4 inject/L6 tasks/L7 provider 枚举),待用户逐条裁决 |
+| 2026-07-19 20:56 | 用户拍板 RpcError.details 强类型化,design.md §2 重写(RpcErrorDetailsMap + 分布式 union + details 必填 + zod discriminatedUnion + 扩展路径);rpc-compare 三采纳项落档:details 真填纪律(并入 §2 升编译期强制)、client unary 超时注记(§5)、并发 resume 去重注记(§3.1) |
+| 2026-07-19 21:00 | 用户拍板审批/问答「unary 对」形态并升格为本轮出协议;core-coverage.md 落协议提案节(方法/帧、id 纪律、竞争语义、subscribed 基线重放恢复、core 事实对齐六表),补核 user-interaction 无 request 级 id、ask 不落日志两事实;随 L1-L7 待整体修订轮裁决 |
+| 2026-07-19 21:04 | 用户拍板全指令 rpcId + 信封两层分离;design.md 升 v1.4(§2 信封类型+纪律、§4 wire 两级 parse、id 纪律 RpcId、convention 二层分离、不做清单改写) |
+| 2026-07-19 21:13 | 用户整轮裁决;design.md 定稿 **v1.5**:L1/L2/L5 合入(批1)、审批/问答域并入正文 §3.4+根接口+map+四帧+两错误码(批2)、§8 预留接缝类型 L3/L4/L6/L7(批3)、版头/README/core-coverage 标注(批4) |
+| 2026-07-19 21:19 | 用户两条修订并入 v1.5:QuestionAskId 取消(问答 id 复用 RpcId,RpcId 语义扩「交互发起方 mint」,审批有意不对称留 ApprovalRequestId);帧信封对称化(RpcFrameEnvelope{rpcId,frame},SSE data 改信封 JSON,流侧签名不感知,rpcId 不承担 cursor);design.md §2/§3.4/§4/id 纪律/convention/版头六处同步,core-coverage 提案节标注修订 |
+| 2026-07-19 21:55 | W1 契约包(旧三信封模型)dispatcher 直写落盘 14 文件 typecheck 绿;Wire 锚定修正回写 §0.5(exactOptionalPropertyTypes 与 zod .optional 不兼容) |
+| 2026-07-19 22:00–22:3x | 用户三轮拍板推到四象限模型(签名信封化→通道解耦→四具名 union+二分裁决);design.md 升 **v2.0**:§2 重写(四具名/窄形/RpcReceipt/错误码删两个)、§3 签名全改 RpcRequest、§3.3 流 yield RpcRequest<帧>、§3.4 respond 重建模、§4 wire 四象限表、rpc-map 6 key+RequestPayload/ResponseValue、convention 同步、§8 补 hostInstanceId 预留(ui-design 提出);期间主会话短暂接管又交还(用户澄清 owner 不变) |
diff --git a/missions/tasks/20260719-1902-apiproxy-api-design/core-coverage.md b/missions/tasks/20260719-1902-apiproxy-api-design/core-coverage.md
new file mode 100644
index 0000000000..f6f0126860
--- /dev/null
+++ b/missions/tasks/20260719-1902-apiproxy-api-design/core-coverage.md
@@ -0,0 +1,170 @@
+# core 能力面 × 契约 v1.3 覆盖度盘点
+
+> 2026-07-19 起盘(分批落盘中)。方法:逐包读 core 源码 types/service 面(file:line 为证),对照 design.md v1.3 标注四态:**已覆盖** / **有意不做**(引拍板)/ **接缝已留**(说在哪)/ **漏判**(需新裁决,汇总见文末清单)。
+> 背景:rpc-compare 发现契约漏 subagent 谱系,根因是当初只按 UI 需求反查 core、未做系统盘点;本文件补这道工序。
+
+## 1. session 域(packages/core/session)
+
+### 1.1 SessionEventMap 全类型表(types.ts:180-252)
+
+| 事件 | core 事实 | 契约状态 |
+|---|---|---|
+| `turn/start` / `turn/end` | types.ts:187/193,turn 边界 + TurnTrigger/TurnEndReason(merge-extensible,types.ts:79-124) | **已覆盖**——mux `session/event` + history 纯透传(design §3.3 透传纪律) |
+| `step/start` / `step/end` | types.ts:195/197 | **已覆盖**(同上透传) |
+| `user/message` | types.ts:199 | **已覆盖**(透传;client fold 消费) |
+| `prompt/blocked` | types.ts:204,veto 的 durable 记录 | **已覆盖**(透传)。UI 是否渲染是 client fold 决策,不是契约缺口 |
+| `context/message` | types.ts:212-217,含 `meta`(模型不可见 durable JSON) | **已覆盖**(透传) |
+| `assistant/chunk` | types.ts:219,token 级 | **已覆盖**——「token 流即事件流」(design §3.3) |
+| `assistant/message` | types.ts:226,含 `usage?: TokenUsage` | **已覆盖**(透传)。**usage/token 统计随之免费到达 client**,无需独立统计接口(§7 LLM 层回引此行) |
+| `tool/call` / `tool/result` | types.ts:232/242;result 带 `meta?: unknown`(tool 私有 presentation 载荷) | **已覆盖**(透传);render intent(presentCall/presentResult)**有意不做**——design §3.3 tool presentation 拍板「先透传,additive 附件帧留座」 |
+| `steering/message` | types.ts:244 | **已覆盖**(透传) |
+| `todo/write` | types.ts:246,全量快照、last-write-wins、log-only | **已覆盖**(透传);client fold 照 last-write-wins 折即可,无需独立 todo 接口(§6 回引此行) |
+| `request/header` | types.ts:251,EpochHeader 快照(config/system/tools/messagePrefix) | **已覆盖**(透传)。UI 可从中读 provider/model 现值变化 |
+| merge-extensible 扩展键 | types.ts:180 map 声明 | **已覆盖**——design §3.3:client fold 对未知 type documented-default,schema 留「合法信封+未知类型」分支 |
+
+### 1.2 Session/SessionStore 服务面(index.ts)
+
+| 能力 | core 事实 | 契约状态 |
+|---|---|---|
+| `session/created` / `session/disposed` 事件 | index.ts:47/57 | **已覆盖**——HostFrame `host/session-added`/`removed`(design §3.3) |
+| `session/event` 追加流 | index.ts:69 | **已覆盖**——mux 的源 |
+| `session/flush` 检查点 | index.ts:79 | **有意不做**(不对客暴露)——durability 是 host 内部事务,client 只见已落地事件 |
+| `store.get/list` | index.ts:818/826(live only) | **已覆盖**——sessions.list(live+冷合并的持久化清单,v1 mtime 三字段拍板) |
+| `SessionHeader.parentSession` + `seedLength` | types.ts:47/52;fork 时写入 index.ts:854-855 | **漏判 →【L1】**——谱系在 core 持久化面存在,契约 SessionSummary/HostFrame 均未携带 |
+| `SessionHeader.cwd` / `createdAt` | types.ts:43/45 | **漏判 →【L2】**——create 收 cwd 入参但 list/describe 均不回吐;createdAt 被 v1「mtime 即 updatedAt」拍板部分覆盖但非同一语义 |
+| `SessionStore.fork()` | index.ts:843-857;SessionForkSource index.ts:546;错误码 SessionForkErrorCode index.ts:556-562(turn/end 边界约束 :890-895) | **漏判 →【L3】**——core 有完整 fork 原语(opencode 也有 POST /session/:id/fork 对照),契约无 session.fork 方法 |
+| repair(`interruptedTurnClosers`) | index.ts:25 导出;repair.ts;persistence 加载时闭合 crash 孤儿 turn(TurnEndReason `interrupted`,types.ts:120) | **已覆盖**(间接)——修复产物就是 `turn/end interrupted` 事件,随 history 透传到达;修复动作本身是 host 内部行为,无需接口 |
+| surface(`foldSurface`/`SurfaceOp`/replace) | index.ts:26-27 导出;types.ts:262-309(SurfaceOp append/replace,compaction 用) | **已覆盖**——design §5「优先复用 core foldSurface」;replace 语义随事件透传,client fold 天然处理 compaction |
+| `deriveMessages`/`requestHeader` 折叠 | index.ts:469/432 | **有意不做**(server 不代折)——「历史=事件重放,client 单一 fold」拍板(README 拍板表) |
+
+## 2. agent 域(packages/core/agent)
+
+| 能力 | core 事实 | 契约状态 |
+|---|---|---|
+| `agent/created` / `agent/disposed` | types.ts:147/156(注册/注销时 emit) | **已覆盖**——HostFrame added/removed 的 agent 侧对应(HostFrame 语义=「session 出现/消失」,v1 agent 与 session 同生命周期) |
+| `agent/status`(idle⇄running→disposed) | types.ts:165;AgentStatus types.ts:47 | **已覆盖**——HostFrame `host/session-status` running 布尔。三态压两态是拍板(冷 session 拍板:attach 不暴露,disposed 即 removed) |
+| `agent/queued`(入箱通知) | types.ts:175 | **有意不做**——prompt() 返回 accepted 即达意;入箱细节属 host 内部(CQRS:渲染靠 session 事件) |
+| send/steer/cancel 原语 | types.ts:103/110/127 | **已覆盖**——prompt(mode: queue/steer)、cancel 1:1 映射(design §3.1) |
+| `inject`(注入上下文不跑模型) | types.ts:119 | **漏判 →【L4】**——core 第三条输入原语,契约只映射了 send/steer;UI 场景(如「贴文件给 agent 但不触发回复」)v1 是否需要待裁决 |
+| `whenIdle()` | types.ts:130 | **有意不做**——client 由 `host/session-status` 事件驱动,不需要 promise 面 |
+| `AgentRegistry.create/resume`(CreateAgentOptions:sessionId/meta{cwd,parentSession,seedLength}/seed/agentOptions/setup) | index.ts:44-90、:352/:371 | **已覆盖**(部分)——sessions.create 走此路(cwd 已在契约入参);**meta.parentSession/seed 是 fork/spawn 用的**,与【L3】同源,client 侧 v1 不透出 |
+| `AgentRegistry.get/list/roots/isOwnedBy` | index.ts:530/550/560+/543 | list/get **已覆盖**(sessions.list + running);`roots()`/`isOwnedBy`(运行时归属树)**漏判 →【L1】共同体**——UI 要画 subagent 树需要谱系,见 L1 处置提案 |
+| initiator scope(`withInitiator`/`initiator()`) | index.ts:288/:258;RFC 2026-07-15-agent-initiator-scope | **有意不做**——进程内 AsyncLocalStorage 机制,本质不可序列化,不属 wire 契约;UI 需要的「谁创建了谁」由持久谱系(L1)承担 |
+| 扩展 seam 事件(pre-step/prompt-submit/request/session-prefix/step-result/post-step/request-error/turn-continuation/turn-stop、agent/error) | types.ts:204-311 | **有意不做**(对客)——插件扩展 seam,是 host 进程内 waterfall/serial 钩子;durable 后果已进 session log 透传(如 prompt/blocked、turn/end error)。`agent/error` 的无 turn 位置失败 →【L5】边缘:live-only 诊断无 session 事件时 client 不可见,待裁决是否要 stream/error 级 host 通知 |
+
+## 3. subagent 包组(packages/subagent/*:spawn/fork/inprocess/subprocess/acp + tool-subagent)
+
+| 能力 | core 事实 | 契约状态 |
+|---|---|---|
+| 子 agent 创建(spawn=白纸 / fork=继承 turn 前缀) | subagent/src/types.ts:52-101(StartRequest:parent 必填、读 parent.session.header 拿 cwd+stamp parentSession,:56-61);SubagentRun.id=子 session id、`parentSession` 记录 parent(:148-154) | **对 UI 的可观测面 = 普通 session**:子 agent 就是 registry 里一个 live agent + store 里一个 session,mux/hostEvents 天然看得见。**缺的只是谱系标注 →【L1】**(host/session-added 无 parentSessionId,UI 无法区分「用户开的」和「agent spawn 的」) |
+| 运行时 run 面(result promise/dispose/sendMessage?/resume?) | types.ts:148-185 | **有意不做**——run 生命周期属 parent agent 的工具调用(tool/call `task` → tool/result),已随事件透传;client 不直接操纵子 run |
+| stopReason / structured output | types.ts:109-141 | **已覆盖**(间接)——结果进 parent 的 tool/result 透传 |
+| ACP/subprocess 远程子 agent | subagent-acp、subagent-subprocess | **同上**——远程 run 无本地 session;parent 侧 tool 事件已覆盖其可观测面;v1 不做远程子会话浏览(不做清单精神,未明文 → 盘点顺手补进 §6 不做清单措辞即可,不算漏判) |
+
+## 4. ctx.tasks 后台任务(packages/tasks/tasks)
+
+| 能力 | core 事实 | 契约状态 |
+|---|---|---|
+| `tasks.list/get/wait`(TaskSnapshot:kind/label/status/detail/output/startedAt/finishedAt) | index.ts:153/167/226/326 | **漏判 →【L6】**——运行时全局后台任务注册表(bash 后台、subagent run 等挂在这),UI「后台任务列表」是常见诉求;但 v1 UI 范围未含此面板,处置建议偏「留接缝」 |
+| `onTaskDone` 完成通知 | index.ts:283 | 同【L6】——若做任务面板需 HostFrame 或独立流;不做面板则无需 |
+| 任务归属(owner: Agent、session-scoped 授权) | index.ts:44-48(TrackedTask.owner)、list(caller) 过滤 | 同【L6】附注:core 已有按 agent 过滤语义,接口若做可直接映射 |
+
+## 5. 审批与问答(packages/ui/user-approval、user-interaction)
+
+| 能力 | core 事实 | 契约状态 |
+|---|---|---|
+| `approval/request` waterfall(待决问题推给 answerer 链) | user-approval/src/index.ts:23-32;ApprovalOutcome :91(allowed-once/rejected/cancelled/unavailable,fail-closed) | **有意不做(v1)**——design §6 不做清单明文「审批/问答域」。**结构性事实需记录**:这是 client→server 反向要答案的面,纯 mux 单向流装不下,将来要么复用 unary(poll/answer 方法对)要么加双向帧——接缝形态建议在拍板时一并定 |
+| `approval/asked` / `approval/decided` session 审计事件 | index.ts:35-60(log-only,merge into SessionEventMap) | **已覆盖**——merge-extensible 事件随 mux 透传(design §3.3 未知类型分支),UI 已可"看到"审批发生过;缺的只是"参与决定"(上行) |
+| `approval/policy`(ask/never,session 内覆写) | index.ts:108 + SessionEventMap merge | **已覆盖**(事件透传);改 policy 的命令面归审批域一并 v2 |
+| `ctx.userInteraction.ask`(AskUserQuestionRequest/Answer,单 provider 注册制) | user-interaction/src/index.ts:43-71(registerProvider 单占 :96-107) | **有意不做(v1)**——同上不做清单。附注:单 provider 语义 ⇒ Web client 接管问答时要经 host 侧代理 provider 中转(provider 在 host 进程注册、答案从 wire 上取),这决定将来接缝在 impl 不在契约新增语义 |
+
+## 6. workflow / todo / skill / compact 可观测面速查
+
+| 包 | core 事实 | 契约状态 |
+|---|---|---|
+| todo(packages/todo) | 唯一持久面 = `todo/write` session 事件(types.ts:246) | **已覆盖**——透传 + client fold last-write-wins(§1.1 已列) |
+| compact(packages/compact) | 产物 = surface `replace` 事件 + `context/message`(SurfaceOp types.ts:292-294) | **已覆盖**——透传;client fold 处理 replace 即正确渲染压缩后视图(design §5 foldSurface 复用) |
+| skill(packages/skill) | 装载产物 = `context/message`(skill 内容注入)+ tool/call 事件 | **已覆盖**(透传);skill 目录浏览/管理面 v1 无 UI 诉求,**有意不做**(catalog 工具是模型面不是 client 面) |
+| workflow(packages/workflow) | worker-thread 引擎;对 session 的可观测面 = 其 tool/call、tool/result + 子 agent session(同 §3) | **已覆盖**(间接);workflow 进度独立流 v1 不做,与 L6 任务面板同性质 |
+| guard(packages/guard) | loop-hygiene 插件,干预结果落 session 事件(steering/turn-stop) | **已覆盖**(透传,无独立面) |
+
+## 7. LLM 层(packages/llm)
+
+| 能力 | core 事实 | 契约状态 |
+|---|---|---|
+| usage/token 统计 | `assistant/message.usage?: TokenUsage`(session types.ts:226,与消息同travel);request/header 里 config | **已覆盖**——透传即达(§1.1 已列);聚合统计(session 累计 token)是 client fold 的算术,不需要 server 接口 |
+| adapter 注册面(provider 现值) | LlmService 注册表;AgentOptions.provider/model(agent types.ts:21-26) | **已覆盖**——host.describe 的 provider/model 现值(20:02 拍板);**adapter 列表枚举**(UI 下拉「可用 provider 有哪些」)**漏判 →【L7】**:describe 只给现值不给候选集,「模型切换」在不做清单但「枚举可选项」是它的读前提,处置建议留接缝 |
+| 模型切换(运行中改 provider/model) | agent/request waterfall 可换 config | **有意不做**——design §6 不做清单明文 |
+
+## 漏判清单(已裁决,2026-07-19 21:13:L1/L2/L5 合入 design.md v1.5;L3/L4/L6/L7 类型预留 design §8;审批/问答提案整体采纳并入 §3.4)
+
+| # | 缺口 | core 事实(file:line) | 建议处置 | 一句话理由 |
+|---|---|---|---|---|
+| **L1** | **subagent/fork 谱系不可见**(已知条目):host/session-added 与 SessionSummary 均无 parent 信息,UI 无法画子 agent 树、无法区分用户开的还是 agent spawn 的 | SessionHeader.parentSession/seedLength(session types.ts:47/52);fork 时写入(session index.ts:854);spawn 时 stamp(subagent types.ts:56-61 REQUIRED parent) | **进 v1 契约**。补法:① `host/session-added` 帧加可选 `parentSessionId?: SessionId`(从 `session.header.parentSession` 读,无谱系时缺省);② SessionSummary 同补 `parentSessionId?`(冷 session 列表也要能画树;jsonl 后端从持久化 header 读)。运行时归属(registry owner/roots,agent index.ts:543/560)**不透出**——durable 谱系已够 UI 用,运行时树是进程内概念 | 字段 core 已持久化、读取零成本;缺它 UI 树状视图无法做,且 additive 可选字段不破坏现契约 |
+| **L2** | session 元数据有入无出:create 收 `cwd` 但 list/history 均不回吐;createdAt 同 | SessionHeader.cwd/createdAt(session types.ts:43/45) | **进 v1 契约**(顺手):SessionSummary 加 `cwd?: string`。createdAt **不加**——v1「mtime=updatedAt」拍板已覆盖排序诉求,再加是第二时间语义 | 多 session 不同 cwd 时列表页无法标注工作目录;一字段事,与 L1 同一次 SessionSummary 改动 |
+| **L3** | fork 无契约方法:core 有完整原语+类型化错误码,opencode 有同款端点 | SessionStore.fork(session index.ts:843-857);SessionForkErrorCode :556-562;turn/end 边界 :890-895 | **留接缝**:v1 不加 `session.fork`(UI 无 fork 按钮诉求);接缝=将来 RpcMethodMap 加 `'session.fork'` 一行 + SessionForkErrorCode 并入 RpcErrorCode 按域扩展,零结构变化 | 形态 B 下加方法是纯 additive;现在加则要陪审 UI 交互(fork 点选择、边界约束提示)不值 v1 |
+| **L4** | `agent.inject` 第三输入原语无映射:prompt 只有 queue/steer | Agent.inject(agent types.ts:119);idle 时一次性 turn 语义 types.ts:84-89 | **留接缝**:prompt 的 `mode` union 将来加 `'inject'` 即可(merge 进闭合 union + impl 分发)。v1 Web UI 无「注入不触发回复」交互 | 三原语中 inject 是插件/自动化面(文件变更通知等 host 内部已在用);人机 UI 场景未出现,union 扩展零迁移 |
+| **L5** | 无 turn 位置的 live 失败 client 不可见:`agent/error` 在 session log 无对应事件时(如 flush 失败、驱动崩溃)UI 只能看到 session 卡死 | agent/error emit(agent types.ts:311,"even when the error has no in-turn position") | **进 v1 契约**(轻量):HostFrame 加 `{ type: 'host/agent-error'; sessionId; message: string }`——只做诊断展示不做恢复语义 | 不加则「agent 停了但 UI 永远转圈」无解释渠道;一帧类型,Frame union additive |
+| **L6** | 后台任务注册表无接口:bash 后台/长任务在 ctx.tasks,UI 任务面板无数据源 | tasks.list/get/wait/onTaskDone(tasks index.ts:153/167/226/283);TaskSnapshot :326 | **留接缝**:任务面板不在 v1 UI 范围;接缝=将来新 `tasks` 域(一域一文件 + RpcMethodMap 数行 + 完成通知并入 hostEvents 或独立流)。设计已天然支持新域(design §1「新域=新文件对+根接口一字段」) | v1 UI 无此面板;域级 additive 是本契约的标准扩展路径,无需预留字段 |
+| **L7** | provider 候选集不可枚举:describe 给现值,UI「切换模型」下拉无数据源 | LlmService adapter 注册表;AgentOptions.provider/model(agent types.ts:21-26) | **留接缝**:模型切换整域在不做清单,枚举是其读前提,一起进将来的 provider 域;不单独提前 | 只读枚举脱离切换动作无用户价值;避免半个域 |
+| — | 审批/问答:形态已拍板(2026-07-19 21:0x,mux 下行帧 + HTTP unary 上行),协议提案见下节,随本清单一并裁决 | 见下节逐条 file:line | 本轮定契约形状,实现可排后 | — |
+
+## 审批/问答域协议提案(已采纳并入 design.md §3.4;**21:19 修订**:QuestionAskId 取消、问题标识复用 RpcId——下文为原提案存档,以 design.md 为准)
+
+**已定**:请求下行 = mux 控制帧(稳定 id + session 锚点);回答上行 = 普通 unary(respond 带 id 回传)。不做双向帧/双工通道——SSE 本就是 server→client,回送有 HTTP。
+
+### A. 方法与帧
+
+```ts
+// RpcMethodMap 增两行(key 按域单数 convention)
+'approval.respond': ApprovalApi['respond']
+'question.respond': QuestionApi['respond']
+
+export interface ApprovalApi {
+ /** 回答一个待决审批。outcome 只收 client 可给的子集(cancelled/unavailable 是 host 侧结局)。 */
+ respond(input: { sessionId: SessionId; id: ApprovalRequestId; outcome: 'allowed-once' | 'rejected' }):
+ Promise>
+}
+export interface QuestionApi {
+ /** 整批回答一次 ask(core 事实:一次 ask 多题一个 answer,user-interaction index.ts:63-67)。 */
+ respond(input: { sessionId: SessionId; id: QuestionAskId; answer: AskUserQuestionAnswer }):
+ Promise>
+}
+
+// MuxFrame 增四帧(Frame 族,session 锚点)
+| { type: 'approval/requested'; sessionId: SessionId; id: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
+| { type: 'approval/resolved'; sessionId: SessionId; id: ApprovalRequestId; outcome: ApprovalOutcome }
+| { type: 'question/requested'; sessionId: SessionId; id: QuestionAskId; questions: AskUserQuestionItem[] }
+| { type: 'question/resolved'; sessionId: SessionId; id: QuestionAskId; outcome: 'answered' | 'cancelled' }
+```
+
+- requested 载荷 = core 类型透传:审批帧字段即 ApprovalRequest 去 agent(换 sessionId 锚)去 signal(user-approval index.ts:190-212);问题帧直接透传 `AskUserQuestionItem[]`(user-interaction index.ts:29-41,model 自带题内 id)。
+- **resolved 帧是收敛面**:多 client 同看一 session 时,别人答掉/超时取消/policy 决掉,观察方靠 resolved 撤卡片;自己答成功也等 resolved 帧统一收敛(respond 的 accepted 只表示受理)。
+
+### B. id 纪律(按现行纪律推导)
+
+- **审批:复用 core `ApprovalRequestId`**(已 brand,user-approval index.ts:76)——SessionId 同款先例:type-only import、id 全部源自 server(requested 帧),client 只回传。
+- **问答:host 造 `QuestionAskId`**(api 层新 brand:`Branded<'question-ask-id'>`)——core 事实:user-interaction **无 request 级 id**(AskUserQuestionRequest 只有题内 model 自给的 string id,index.ts:29-33),host 代理 provider 受理 ask() 时 mint UUID。与 cursor 占位不同(那是未实现故不 brand),此 id 实装即进签名,按「opaque 跨界 id 必 brand」仓规上 brand。
+
+### C. 竞争语义
+
+- **先到先赢,host 内存 pending 表是唯一裁判**:一个 id 只被 settle 一次。竞争方:client respond vs `signal` abort(tool 取消/step 中止 → cancelled,user-approval index.ts:206-211)vs 另一 client respond。policy `'never'` 在 answerer 链之前解决(index.ts:100-108),**requested 帧根本不发**——天然对齐。core 无审批超时(signal 是唯一撤回通道),不发明。
+- **迟到/重复回答**:RpcErrorDetailsMap 增两码——`'approval-not-pending': { id: ApprovalRequestId }`、`'question-not-pending': { id: QuestionAskId }`(分域两码不合一:details 类型不同,且按域扩展是既定纪律)。
+
+### D. 刷新恢复(推荐:subscribed 基线重放)
+
+**推荐**:client 重开 mux 后,host 在每个 session 的 `session/subscribed` 帧之后立即重放该 session 仍 pending 的 `*/requested` 帧(来源=host 内存 pending 表)。理由:单一事实源(全走 mux),与 lastSeq 补缝流程同构,client 无需第二条 bootstrap 路径做 join。**不推荐** host.describe 带 pending 列表:跨 session 聚合 + 与流竞态,两处真相。
+不能从 history 推 pending(审批虽有 `approval/asked`/`decided` 审计事件,但 crash 后 asked-without-decided 是永久悬案——pending 真相只在 host 内存,重放帧无此歧义)。
+
+### E. core 事实对齐(盘点批3 + 本批补核)
+
+| core 事实 | file:line | 提案对齐 |
+|---|---|---|
+| approval 走 policy → answerer waterfall 链,fail-closed unavailable | user-approval index.ts:23-32、:100-108 | host 侧注册一个「wire answerer」进链:收 approval/request → 发 requested 帧 → 等 respond/abort → 返回 outcome。链上仍可有其他 answerer(组合语义不变) |
+| `approval/asked`/`decided` 已是 session 审计事件(log-only) | index.ts:35-60 | 保持透传不动;帧是 live 控制面、事件是 durable 审计,职责分离不算重复造 DTO |
+| ApprovalOutcome 四值闭合 union | index.ts:91 | resolved 帧透传全集;respond 入参窄化为 client 可给二值 |
+| userInteraction 单 provider 注册制 | user-interaction index.ts:96-107 | host 代理 provider 是唯一注册者(盘点 §5 已注),多 web client 竞争在 wire 层由 pending 表裁决,不违单 provider |
+| ask() 不落 session 日志 | user-interaction 全文无 SessionEventMap merge(本批 grep 核实) | 问答无审计事件可依赖 → requested/resolved 帧是问答唯一可观测面,D 的内存重放是唯一恢复路径(自洽) |
+| 一次 ask 多题、整批回答 | index.ts:43-67 | respond 收整个 AskUserQuestionAnswer,不拆单题方法 |
+
+**汇总(裁决后)**:L1/L2/L5 **已合入 v1.5**(SessionSummary.parentSessionId?/cwd?、session-added 帧 parentSessionId?、host/agent-error 帧);L3/L4/L6/L7 **类型已预留**(design §8 完整签名,不进 map——fail loud 优于 not-implemented 兜底);审批/问答提案(下节)**整体采纳**并入 design §3.4。本文件转为盘点存档,后续契约变更以 design.md 为准。
diff --git a/missions/tasks/20260719-1902-apiproxy-api-design/design.md b/missions/tasks/20260719-1902-apiproxy-api-design/design.md
new file mode 100644
index 0000000000..ad2fe70203
--- /dev/null
+++ b/missions/tasks/20260719-1902-apiproxy-api-design/design.md
@@ -0,0 +1,413 @@
+# apiproxy 统一 API 层 · 设计(v2.0:四象限 RPC 消息模型)
+
+> 2026-07-19 主会话起草;19:48 Q1–Q8 修订;20:15 RPC 命名体系(RpcResponse);20:41 形态 B 终选;20:56 RpcError 强类型化;21:04 rpcId+信封两层;21:13 整轮裁决(L1/L2/L5+审批问答+§8 预留);21:19 问答 id 复用 RpcId+帧信封对称;**22:00-22:3x 四象限统一消息模型定型(v2.0)**:通道与消息解耦、四具名判别 union、签名显式收窄形 RpcRequest、respond 重建模为 client-response、泛型工具改名 RequestPayload/ResponseValue。拍板全记录见同目录 README.md。
+> 定位:`packages/host/apiproxy` 对 Web / Electron / TUI 暴露的**唯一契约层**;web client 的 HTTP/SSE 只是它的一种承载。
+
+## 0. 总原则(已拍板)
+
+1. **TS interface 是权威契约**,HTTP/SSE 是载体。同进程形态(Electron main、测试)直接注入 handler 当 fetch,跨进程走真 HTTP——签名完全一致(opencode 同构点,已经调研证实其 fetch 面连 Worker RPC 边界都能过)。
+2. **透传 core 数据结构**:wire 上的事件/消息/内容块就是 `SessionEvent` / `ContentBlock` 等 core 类型,不自造第二套 DTO。类型经 `import type` 依赖链直达浏览器。
+3. **RPC 风格**,按业务域分组,一域一对文件(`sessions.ts` 类型 + `sessions.schema.ts` zod)。
+4. **错误 = 类型化 RpcResponse 信封**(`RpcResponse` + `RpcError`),方法不 throw 业务错误。
+5. **zod 双向校验**(C→S 命令、S→C 事件都 parse),schema 用 `satisfies z.ZodType` 锚定编译期防漂移——形态 B 下 `T` 是 infer 派生类型(`satisfies z.ZodType>`),锚定等价可行,代价(报错信息展开为字面量结构)已被用户接受(2026-07-19 20:41);不 passthrough。可 dev-only 开启(开关是实现细节不进签名)。**实现修正(21:55,W1 落地发现)**:仓库 `exactOptionalPropertyTypes` 与 zod `.optional()` 输出类型(`T | undefined`)不兼容,锚定统一写 `satisfies z.ZodType>`——`Wire` 是深度「| undefined」宽化(api/rpc.schema.ts 定义并注释),JSON wire 上缺席与 undefined 同形故不损失校验语义;透传宽分支(SessionEvent/ContentBlock/帧 union/RpcError discriminatedUnion)与 brand id schema 用显式 cast + 注释。
+6. **历史 = 事件重放**:一套 fold(client 侧),历史分页拉 + live 增量贴同一条代码路径;server 不做物化快照第二套。
+7. **重连 = 重建**:v1 不实现续传 cursor(签名留可选 `since`),断线重连一律重开流 + 重拉 history(opencode 同款)。
+
+## 1. 分层与文件布局
+
+```
+packages/host/apiproxy/src/
+ api/ ← 契约层(纯类型 + zod schema,浏览器可 import)
+ index.ts ← export interface ApiProxy { sessions, host, events }
+ sessions.ts ← SessionsApi 接口(方法签名 = 出入参事实源)
+ sessions.schema.ts ← 上者的 zod schema(一域一对文件,同名 .schema.ts 后缀)
+ host.ts / host.schema.ts
+ events.ts / events.schema.ts ← 流签名 + 帧类型 + 帧 schema
+ approvals.ts / approvals.schema.ts ← 审批域(v1.5,§3.4)
+ questions.ts / questions.schema.ts ← 问答域(v1.5,§3.4;问题标识复用 RpcId,无新 brand)
+ rpc.ts ← RpcResult / RpcError / RpcId / 窄形 RpcRequest·RpcResponse / 四具名 wire 全形 + RpcMessage / RpcReceipt
+ rpc-map.ts ← RpcMethodMap + RequestPayload / ResponseValue
+ impl/ ← Node 侧实现(boot harness core、实现 ApiProxy)
+ fetch/
+ handler.ts ← toFetchHandler(api): (Request) => Promise
+ client.ts ← IApiClient + AbstractApiClient + InProcessApiClient(§4.1 类体系)
+```
+
+- `api/` 零 Node 依赖;`impl/` 只在 host 进程加载;client 包只 import `api/` + `fetch/client.ts`。
+- 新域(provider、approvals…)= 新的一对文件 + `ApiProxy` 根接口一个字段。
+
+### 依赖方向
+
+```
+apps/dsc ──► apiproxy/impl ──► harness core
+ │ implements
+ ▼
+ apiproxy/api(契约,唯一权威)
+ ▲ import type + AbstractApiClient 子类
+web-runtime ──► apiproxy/fetch/client ──► HTTP or 注入的 handler
+```
+
+### id 纪律(branding,2026-07-19 拍板)
+
+- **`SessionId`:复用 core 的 branded 类型**(type-only import 自 dsh-session,浏览器零运行时)。契约中所有 sessionId 一律 `SessionId`。id 全部源自 server 响应(list/create),client 只回传,无需构造器;zod parse 在 shape 校验后一次 cast 上 brand(每个 `.schema.ts` 一个 cast 点)。brand ≠ 存在性校验——`session-not-found` 仍由 impl 判。
+- **cursor 不 brand(2026-07-19 20:00 拍板)**:v1 未实现的预设占位不提前上 brand,签名用裸 `string`(`cursor?: string`);将来实现分页时再决定是否 brand。
+- **`RpcId` brand(2026-07-19 21:04 随信封拍板;21:19 语义扩展)**:opaque + 跨界往返 → brand;与 cursor 占位不同,v1 实装即进签名。mint 方 = **交互发起方**:unary 调用由 client mint(应答只回显);server 发起的交互由 host mint——问答 ask 的问题标识、每条 SSE 帧的推送标识(帧信封)。单一品牌单一构造函数 `RpcId()`(core `SessionId()` 先例),谁发起谁构造。
+- **事件内部 id 免费**:`CallId` 等随 `SessionEvent` 透传,core 已 brand,本层不重复定义。
+- **seq 一族有意不 brand**(`beforeSeq` / `lastSeq` / `since` 值):非 opaque——要做大小比较、且从透传的 `event.seq`(core 裸 `number`)派生;只在本层 brand 会逼每处派生 cast,与透传相抵。v1 仅 seq 一族数字无混用风险;出现第二族(revision/generation)再上 BrandedNumber。
+- 闭合 union(`mode`、错误码)不 brand——union 是更强的约束。
+
+### 命名 convention(2026-07-19 20:15 拍板,20:41 随形态 B 终选改写)
+
+- **方向在消息 tag 上可辨识**(22:00 四象限重写):wire 全形 = `ClientRequest`/`ServerResponse`/`ServerRequest`/`ClientResponse` 四具名判别 union(§2);签名窄形 = `RpcRequest`/`RpcResponse`;payload 派生 = `RequestPayload`/`ResponseValue`。帧是 ServerRequest 的 payload(具名帧 union 保留,§3.3)。
+- **禁重复内联**(20:15「每方法具名/签名禁内联」拍板随 B 放宽):参数/返回的字面量结构只住方法签名一处(事实源),签名之外——handler、client、store、测试——一律 `RequestPayload` / `ResponseValue` 泛型引用,不复写字面量、不另起具名平铺类型。
+- **空 request 写空字面量 `{}`**:将来加字段就地扩展签名,泛型引用处零迁移。
+- **`AbortSignal` 不进 input**:input 定义为 wire 载荷,与 schema 一一对应;signal 不可序列化,混入会迫使 schema omit 字段、破坏 `satisfies z.ZodType` 锚定。流方法签名为 `(input, signal: AbortSignal)`——signal 是本地控制参数,独立第二参。
+- **信封 = `RpcResponse`**(rpc.ts):unary 一律 `Promise>`;`T` 是业务返回结构,信封管成败。
+- **RPC map key 用域单数**:`session.list` / `host.describe` / `events.mux`(events 本身无单复),wire 路径同步 `/api/session.list`。单复数经用户授权由设计层定(2026-07-19 20:30)。
+- **schema 命名按 map key 推导**:`sessionListRequestSchema` / `sessionListValueSchema`(住 `<域>.schema.ts`),锚定对应泛型引用(见 §0.5)。
+- **消息层/业务层两层,签名显式感知窄形**(21:04 两层分离拍板 → 22:00 四象限重写):wire 全形=四具名判别 union(§2),业务 payload 纯净内嵌;域接口签名收/吐窄形 `RpcRequest`/`RpcResponse`(rpcId 显式,业务 payload 内不混 rpcId);全形补全(type tag/method)收口在 fetch 载体层。
+
+### RPC map:函数签名即事实源(2026-07-19 20:41 终选形态 B)
+
+**方法签名是唯一权威**:接口方法的参数/返回结构直接内联写在签名里;`RpcMethodMap` 登记方法本身;Request/Response 一律经条件类型从签名反推,任何签名之外的地方只引用泛型。
+
+```ts
+// rpc-map.ts —— map 只登记 client-request 方法(respond 是 client-response 不在此,22:00 四象限)
+export interface RpcMethodMap {
+ 'session.list': SessionsApi['list']
+ 'session.create': SessionsApi['create']
+ 'session.history': SessionsApi['history']
+ 'session.prompt': SessionsApi['prompt']
+ 'session.cancel': SessionsApi['cancel']
+ 'host.describe': HostApi['describe']
+}
+// 22:1x 撞名重命名(wire 四具名占用原名 ClientRequest/ServerResponse):
+export type RequestPayload = Parameters[0]['payload']
+export type ResponseValue =
+ Awaited> extends RpcResponse ? T : never
+```
+
+- map key 即 wire 路径段(`POST /api/session.list`),`toFetchHandler` / `AbstractApiClient` 对 map key 类型安全机械遍历。
+- 流方法不进 `RpcMethodMap`(不是 unary RPC):`events.mux` / `events.host` 的 input 结构同样内联在签名,帧类型是具名 union(§3.3)。
+- **平铺具名 Request/Response 类型删除**(非降级为派生别名):别名是同一事实的第二个名字,与「任何地方都引用泛型」相抵;zod 直接锚 `RequestPayload<'session.list'>`,不需要中间名。
+- 备选未采用:形态 A(类型对 map,具名 interface 为事实源 + map 登记类型对),2026-07-19 20:30 曾并排呈案,用户终选 B。
+
+## 2. RPC 消息模型:四象限统一信封(2026-07-19 22:00 定型,推翻 21:04「签名不感知信封」)
+
+**通道与消息解耦**:HTTP = client→server 物理通道,SSE = server→client 物理通道,仅此而已。逻辑消息独立于通道,每个 wire 消息统一带 `initiator`(谁发起)× `kind`(request/response)——四象限:① client-request(经 HTTP body)② server-response(经 HTTP 应答,回填①的 rpcId)③ server-request(经 SSE 帧,server mint——审批/问答 requested 即此类)④ client-response(对③的应答,物理经 HTTP 发出,逻辑 kind=response、回填③的 rpcId)。kind/direction 在消息上而非靠通道推断——将来「client-request 的 response 走 SSE 送回」(订阅型/长回答)只是 ② 换了通道,信封不变。
+
+```ts
+// api/rpc.ts
+
+/** 消息关联 id:request=发起方 mint(谁发起谁构造,RpcId() 照 core SessionId() 先例);response=回填对应 request 的 rpcId,不 mint 新 id。 */
+export type RpcId = Branded<'rpc-id'>
+export type RpcInitiator = 'client' | 'server'
+
+/** 业务成败结果(原 RpcResponse 更名:RpcResponse 现在是消息层名字)。 */
+export type RpcResult = { ok: true; value: T } | { ok: false; error: RpcError }
+
+/** 签名层窄形·请求(两个方向通用):rpcId 显式进签名,kind/initiator/method 由调用位置决定、载体层补全。 */
+export interface RpcRequest {
+ rpcId: RpcId
+ payload: P
+}
+
+/** 签名层窄形·应答(两个方向通用):rpcId 恒为对应 request 的回填。 */
+export interface RpcResponse {
+ rpcId: RpcId
+ result: RpcResult
+}
+
+/** wire 全形 = 四具名类型的判别 union(22:1x 用户裁决字面四具名;判别子 = type 四字面量,initiator/kind 由 tag 自明不设冗余字段)。 */
+export interface ClientRequest {
+ type: 'client-request'; rpcId: RpcId; method: string; payload: unknown
+}
+export interface ServerResponse {
+ type: 'server-response'; rpcId: RpcId; result: RpcResult
+}
+/** server 发起的消息:需应答的交互(approval/question requested,rpcId 稳定)与纯推送(session/event 等,rpcId 标识该次推送)共用此形——是否期待应答由 method 静态区分(22:1x 用户采纳设计层严格二分,不设第三 kind)。 */
+export interface ServerRequest {
+ type: 'server-request'; rpcId: RpcId; method: string; payload: unknown
+}
+export interface ClientResponse {
+ type: 'client-response'; rpcId: RpcId; result: RpcResult
+}
+export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse
+
+/** 载体回执(非 RpcMessage——属载体层,同「HTTP status 只表载体」纪律):承载 client-response 的 POST 的 HTTP 应答体。 */
+export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }
+```
+
+**窄形与全形的关系**:`RpcRequest` / `RpcResponse`(上文)是**域接口签名视角**的窄形(只含业务层必须感知的 rpcId+载荷);四具名是 **wire 权威全形**——载体层把窄形补全为全形(补 type tag 与 method),方向不靠通道推断。
+**撞名重命名(全链一致)**:wire 四具名占用 ClientRequest/ServerResponse 名字,rpc-map 的派生泛型工具改名——`ClientRequest` → **`RequestPayload`**(= `Parameters[0]['payload']`,提取 payload 穿过 RpcRequest 窄形)、`ServerResponse`/`ServerValue` → **`ResponseValue`**(= 返回的 `RpcResponse` 中 infer `T`;原两名合一,中间形无消费者)。
+
+**四象限纪律**:
+- **签名显式收信封窄形**(推翻「签名不感知」):unary 方法 `method(request: RpcRequest<{…}>): Promise>`——业务字面量仍只住签名(形态 B 不变),但包在 `RpcRequest<>` 里;impl 必须回显 `request.rpcId` 进返回的 `RpcResponse`(server 感知 rpcId 是模型要求,不因 HTTP 自带信道而省略)。流方法 yield `RpcRequest<帧>`(server-request 窄形)——可应答帧的 rpcId 是 client 回填应答所必需,必须暴露给业务层,不再有「载体层拆掉」一说。
+- **纯推送 = 不期待应答的 server-request,严格二分不设第三 kind(22:1x 用户采纳设计层方案)**:是否期待应答是 method/帧型的静态语义(登记表可查),不是每条消息的动态属性;接收方对两者处理本就相同(处理、不回)。迟到应答走既有 late-response 丢弃路径(RpcReceipt not-pending)。
+- **rpcId mint 规则**:client-request=client mint;server-request=server mint——**其 rpcId 是稳定逻辑请求 id**(ask 受理时 mint 一次,subscribed 基线重放时原样复用,client 以它回填应答);notify 的 rpcId 标识该次推送(每次发射新 mint)。response 一律回填、绝不 mint 新 id(对称性:谁发起谁 mint,应答方回填)。
+- **method 字段**:request 全形必带(unary=map key;帧载 request=帧的 type,与 payload.type 重复是「帧保持 fold 可直接消费」的代价);response 无 method(rpcId 已关联)。handler 仍校验 unary 的 path==method。
+- **client-response 的 HTTP 应答 = RpcReceipt 载体回执**,不是逻辑消息(response 不再有 response);迟到/重复应答 → `{accepted:false, reason:'not-pending'}` + server 日志,逻辑收敛面仍是 resolved 帧。**approval-not-pending / question-not-pending 两错误码随之删除**(其宿主——respond 作为 unary 方法的 RpcResult——已不存在)。
+- **rpcId 不承担 cursor**:durable 事件续传/补缝锚仍是透传的 `event.seq`;prompt 的 rpcId 额外经 `MessageSource` 透传进 `user/message`(provisional 关联机制,执行 v1 不做,§6)。
+- **zod 分层**:wire 全形 schema 一个(kind 判别 + method 合法性)+ 业务 payload schema 按 method/帧型分派,两级 parse。
+
+### 2.1 错误模型(details 强类型化,20:56 拍板;22:00 随四象限删两码)
+
+```ts
+export interface RpcErrorDetailsMap {
+ 'bad-request': { issues: z.ZodIssue[] } // zod 校验失败明细
+ 'session-not-found': { sessionId: SessionId }
+ 'agent-busy': { reason: string } // core 拒绝原因透传
+ 'internal': {} // 无结构化信息可给(message 已在信封)
+}
+export type RpcErrorCode = keyof RpcErrorDetailsMap
+
+// map 展开的分布式 union(非泛型 interface):code 是判别子,switch 后 details 自动窄化。
+export type RpcError = {
+ [C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] }
+}[RpcErrorCode]
+```
+
+- **details 必填**(internal 显式 `{}`):与「必须真填」纪律互锁(rpc-compare 2026-07-19),漏填=编译错误;bad-request 放 zod issues、session-not-found 放 sessionId、agent-busy 放 core 拒绝原因。
+- **zod**:`rpcErrorSchema = z.discriminatedUnion('code', [...])` 逐支锚定;新码=map 加行+schema 加支。
+- transport 故障(断网、进程没起)由 fetch 载体抛异常,与业务错误两层不混;流正常结束=server 关流,中途错误以 `stream/error` 帧收敛,断线由载体抛异常。
+
+## 3. ApiProxy 根接口
+
+```ts
+export interface ApiProxy {
+ sessions: SessionsApi
+ host: HostApi
+ events: EventsApi
+ /** 对 server-request 的应答入口(client-response,回填其 rpcId);不是域方法(22:00 四象限,§3.4)。 */
+ respond(message: ClientResponse): Promise
+}
+```
+
+### 3.1 SessionsApi(sessions.ts)
+
+```ts
+export interface SessionSummary {
+ sessionId: SessionId
+ updatedAt: number // 持久化文件 mtime(v1 不建索引,list 时 readdir+stat)
+ running: boolean // attached agent 的 status;冷 session(未 attach)恒 false
+ parentSessionId?: SessionId // fork/spawn 谱系(session.header.parentSession 透传);根 session 缺省(v1.5,L1)
+ cwd?: string // session 工作目录(header.cwd 透传);未记录则缺省(v1.5,L2)
+}
+
+// 方法签名即事实源(形态 B):参数/返回结构只住在这里,
+// 其余一切引用 RequestPayload<'session.*'> / ResponseValue<'session.*'>。
+
+export interface SessionsApi {
+ /** 列出已持久化 session(updatedAt 倒序)。v1 全量返回;cursor 留座不实现。 */
+ list(request: RpcRequest<{ cursor?: string }>): Promise>
+
+ /** 创建新 session(并创建对应 agent,空闲待命)。 */
+ create(request: RpcRequest<{ cwd?: string }>): Promise>
+
+ /**
+ * 读取历史事件窗口,**页边界对齐消息边界**:一页 = 整数条消息所辖的全部原始事件
+ * (含其 chunk / tool 事件),绝不从一条消息中间截断。尾页(beforeSeq 缺省)额外
+ * 含「进行中 partial」——最后一条未定稿消息已有的 chunk 事件。
+ * 返回仍是原始 SessionEvent[] 透传,client 用统一 fold 重建。
+ */
+ history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
+ Promise>
+
+ /** 发送。content 直接用 core 的 ContentBlock[];mode 1:1 映射 queue→send、steer→steer。 */
+ prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
+ Promise>
+
+ /** 停止:清两条 FIFO + abort 当前 step(agent.cancel 的 1:1)。 */
+ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise>
+}
+```
+
+(22:00 签名信封化:一切 unary 收 `RpcRequest`、返 `RpcResponse`(rpcId 回填);业务字面量仍只住签名(形态 B),impl 感知并回显 rpcId。)
+
+```ts
+```
+
+- **冷 session 隐式 resume**:`history()` / `prompt()` 命中未 attach 的 session 时 impl 内部自动 resume/attach,client 无感;attach 与否不对客暴露(`running` 已覆盖 UI 所需)。实现注记:并发触发同一 session 的 resume 必须去重(`Map` 在途表,照 jsonrpc server `sessionCreations` 先例;rpc-compare 2026-07-19)。
+- `SessionSummary` 保持三字段最小面;title/eventCount/待处理计数等后续按需 additive。
+- prompt 幂等(commandId)v1 不做;input 加可选字段即是接缝。
+- history 分页实现注记:server 从尾向前扫 surface 消息事件(`user/message` / `assistant/message` / `steering/message`)计数到 `maxMessages`,在消息组边界切 `beforeSeq`;chunk 归属其定稿消息(`sourceEventSeqs` 锚定),页内事件保持原始 seq 序。
+
+### 3.2 HostApi(host.ts)
+
+```ts
+export interface HostApi {
+ /**
+ * host 一次性快照。空 request 用空字面量 `{}`(将来加字段就地扩展)。
+ * version = host 应用(apps/dsc)package.json 版本;cwd = host 进程工作目录
+ * (session 持久化与工具执行的根);provider/model = 新建 agent 未显式指定时
+ * 生效的默认值,host 未配置显式默认则缺省(adapter 内部兜底);
+ * attachedSessions = 当前已 attach(有活 agent)的 session 数。
+ */
+ describe(request: RpcRequest<{}>): Promise>
+}
+```
+
+- **不设协议版本**(2026-07-19 20:02 拍板):client/host 绑定发布,wire 兼容判断无消费者;将来若出现独立发布的 client 再引入 protocolVersion。
+- provider/model 形状对齐 core `AgentOptions`(可选裸 `string`,非 branded),透传原则;缺省表示 host 未配置显式默认(adapter 内部兜底)。
+
+### 3.3 EventsApi(events.ts)——两条流
+
+```ts
+export interface EventsApi {
+ /**
+ * 全 session 聚合 mux 流。打开即对每个 attached session 发 subscribed 控制帧。
+ * since:续传接缝,v1 不实现(传了也忽略);重连走「重开流 + 重拉 history」。
+ * signal 是本地流控制参数,独立于 input(不上 wire)。
+ * 22:00 四象限:yield 的是 server 消息窄形 { rpcId, payload: 帧 }——rpcId 必须暴露给业务层
+ * (approval/question requested 帧的应答要回填它),不再有「载体层拆掉信封」。
+ */
+ mux(request: RpcRequest<{ since?: Record }>, signal: AbortSignal): AsyncIterable>
+
+ /** host 级信息流:session 创建/销毁、运行状态翻转。空 payload 用 `{}`。 */
+ host(request: RpcRequest<{}>, signal: AbortSignal): AsyncIterable>
+}
+
+// ---- Frame 族(server→client 推送,具名 union 保留,不适用 infer) ----
+
+export type MuxFrame =
+ | { type: 'session/event'; sessionId: SessionId; event: SessionEvent } // 核心:纯透传
+ | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number } // 控制帧(lastSeq 保留:缝检测)
+ // ---- 审批/问答控制帧(v1.5,§3.4):requested 下行提问,resolved 收敛 ----
+ | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
+ | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
+ | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } // 问题标识=信封 rpcId(帧 payload 无独立 id)
+ | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
+ | { type: 'stream/error'; error: RpcError }
+
+export type HostFrame =
+ | { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId } // 谱系锚(v1.5,L1)
+ | { type: 'host/session-removed'; sessionId: SessionId }
+ | { type: 'host/session-status'; sessionId: SessionId; running: boolean }
+ | { type: 'host/agent-error'; sessionId: SessionId; message: string } // 无 turn 位置的 live 失败诊断(agent/error 无 session 事件时的唯一出口;v1.5,L5)
+ | { type: 'stream/error'; error: RpcError }
+```
+
+**透传纪律**:`session/event` payload 就是 core `SessionEvent`(自带 seq;`assistant/chunk` 原样过——token 流即事件流,无独立 delta 帧)。`SessionEventMap` 是 merge-extensible:client fold 对未知 type documented-default(计数忽略);事件 schema 在 union 层面留「合法信封 + 未知类型」分支,信封(seq/type 结构)仍严格——这不是字段级 passthrough。
+
+**`subscribed.lastSeq` 的用途(已拍板保留)**:client 拉完 history 后对比 history 尾 seq 与 lastSeq,有缝(开流与拉历史之间 session 前进了)就再补一次 history;一个字段消掉一类竞态。
+
+**tool presentation(已拍板:先透传,标注遗留)**:v1 卡片直接渲 `tool/call` / `tool/result` 原始 args/result;`presentCall/presentResult` 的 render intent(generic/terminal/diff/locations)在 Node 侧才有,后续以 additive 附件帧或旁挂字段引入,不动透传主体。
+
+### 3.4 审批/问答域(approvals.ts / questions.ts,v1.5 采纳,2026-07-19 21:13)
+
+**形态(21:00 拍板;22:00 四象限重建模)**:审批/问答的 requested 帧 = **server-request**(rpcId=server mint 的稳定逻辑请求 id);client 的回答 = **client-response**(回填该 rpcId,物理经 `POST /api/respond` 发出,逻辑上是应答不是新调用——**不再是 unary 方法,不 mint 新 rpcId**)。`*/resolved` 帧是收敛面——多 client、tool 取消、policy 决掉都靠它撤卡片;client-response 的 HTTP 应答体是载体回执 `RpcReceipt`(见 §2),最终结局统一看 resolved。
+
+```ts
+// approvals.ts / questions.ts —— 应答 payload 形状(client-response 的 result.value 位)
+/** 审批应答:outcome 只收 client 可给的二值(cancelled/unavailable 是 host 侧结局)。 */
+export interface ApprovalResponsePayload {
+ sessionId: SessionId
+ approvalId: ApprovalRequestId // core 审计关联(impl 对账 asked/decided 用);wire 关联以回填的 rpcId 为准
+ outcome: 'allowed-once' | 'rejected'
+}
+/** 问答应答:整批回答一次 ask(core:一次 ask 多题一个 answer,不拆单题)。 */
+export interface QuestionResponsePayload {
+ sessionId: SessionId
+ answer: AskUserQuestionAnswer
+}
+```
+
+- **respond 不进 RpcMethodMap**(map 只登记 client-request 方法):client-response 是对 server-request 的应答,wire 承载 `POST /api/respond`(单端点,body=ClientResponse 全形,rpcId 即路由键——host 从 pending 表查该 rpcId 属审批还是问答再按对应 payload schema parse)。ApiProxy 根接口相应无 approvals/questions 域方法;client 侧发应答走 `IApiClient.respond(message: ClientResponse)` 载体级入口(AbstractApiClient 实现,§4.1)。
+- **id 双层**:wire 关联 = requested 帧的 rpcId(server mint、重放复用、client 回填);`approvalId`(core `ApprovalRequestId` 透传)保留在审批 payload 内层供 impl 对账 durable 审计事件 `approval/asked`/`decided`——它是 core 已 brand 的透传非本层自造(21:19 拍板的不对称理由继续成立)。问答无 core id,payload 不含资源 id(rpcId 已足)。
+- **竞争语义:先到先赢**,host 内存 pending 表(keyed by rpcId)是唯一裁判,一个 rpcId 只 settle 一次。竞争方:client-response vs tool `signal` abort(→cancelled)vs 另一 client。policy `'never'` 在 answerer 链之前解决,requested 帧根本不发。core 无审批超时,不发明。迟到/重复应答 → `RpcReceipt {accepted:false, reason:'not-pending'}`(载体回执,非业务错误码——两个 not-pending 错误码已随四象限删除)。
+- **刷新恢复:subscribed 基线重放**——mux 重开后,host 在每个 session 的 `session/subscribed` 帧后立即重放该 session 仍 pending 的 `*/requested` 帧(**rpcId 原样复用**,来源=内存 pending 表)。单一事实源走 mux;不从 history 推(问答不落日志;审批 crash 后 asked-without-decided 是悬案)。
+- **impl 结构**:host 注册「wire answerer」进 `approval/request` waterfall 链(收请求→mint rpcId 发 server-request 帧→等 client-response/abort→返回 outcome,链上其他 answerer 组合语义不变);问答侧 host 代理 provider 是 `userInteraction.registerProvider` 的唯一注册者。`approval/asked`/`decided` 审计事件照旧透传——帧=live 控制面,事件=durable 审计,职责分离。
+
+## 4. fetch 载体(RPC 映射,机械可推导)
+
+| 逻辑消息(四象限) | wire 承载 |
+|---|---|
+| client-request | `POST /api/