From a6a3807a07d39c1cd066679a5ccc0d37db65ccb6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:17:57 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(gui):=20step1=20skeleton=20=E2=80=94?= =?UTF-8?q?=20dsc=20web=20serves=20built=20web=20UI=20over=20booted=20harn?= =?UTF-8?q?ess=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new modules: apps/dsc (bin: parseArgs + node:http static server + signal shutdown), packages/host/apiproxy (programmatic harness core composition, agents:[]), packages/client/web-runtime (React-free browser runtime), packages/client/web-ui (React mount), apps/web (vite build entry producing dist consumed by apps/dsc via package exports). Root wiring: apps/* workspace glob, dsh-* paths for host/client groups, demo:web script, apps/web/dist gitignore. No protocol/API routes yet — contract lands in step2 (see missions/tasks/20260719-1902-apiproxy-api-design). Includes the design + implementation archives (spec v2.1, deepseekchat baseline and harness boot research, implementation run log). Acceptance: 12/12 passed incl. real-key llm.stream smoke (51 chunks). feat(gui): apiproxy — four-quadrant RPC contract + fetch carriers, live end to end Contract layer (src/api/, 14 files): four named wire message types (ClientRequest / ServerResponse / ServerRequest / ClientResponse) as a discriminated union over strict bidirectional rpcId (initiator mints, responder echoes; channel and message fully decoupled — HTTP is the client->server pipe, SSE the reverse); narrow RpcRequest

/ RpcResponse signature forms; RpcMethodMap with RequestPayload/ ResponseValue derivation; typed RpcError details map; approval/ question responses modeled as ClientResponse via a single /api/respond endpoint (RpcReceipt carrier ack); zod schemas anchored per Wire against exactOptionalPropertyTypes. impl/api-proxy.ts: describe/list/create, both SSE streams (frame queue pump, subscribed baseline, lifecycle frames, signal cleanup); history pages on message boundaries (tail-back scan, partial included in the tail page); prompt dispatches queue->agent.send / steer->agent.steer with rpcId carried through MessageSource; cancel for attached sessions; cold-session resume deduped via a per-id promise map; host-level provider/model defaults injected at create/resume. fetch/: mechanical UNARY_ROUTES table, two-level parse with path==method check, SSE frames completed to ServerRequest full form; client mints -> narrows -> envelopes outbound, verifies rpcId echo inbound, streams SSE frames, four-quadrant onEnvelope tap (debug panel choke point). Real-browser fixes: URL base resolves to location.origin (hardcoded internal base broke real pages), browser-safe export paths. Design archives: contract design.md v2.0 with decision log, core-coverage audit, comparative studies, step2 impl run log. Probed end to end over real HTTP: prompt -> live model stream -> history returns the finished reply. feat(gui): RpcLog debug panel — fixture-driven milestone, playwright-verified 10/10 web-runtime: rpcLog + ui slices (zustand), four-quadrant RpcLogEntry (client-request / server-response / server-request / client-response), onEnvelope tap -> microtask-batched pump with 500-entry ring buffer, ConnectionController (private state, backoff reconnect), fixture API with fake envelopes (?fixture switch), bootWebRuntime; contract types via temporary local copies (api-types.ts, swapped for real imports when W3 client lands). web-ui: components/panels/RpcLog five-piece set (badge with unread count, floating panel, direction glyphs per quadrant, same-rpcId pair highlighting in two families, JSON payload expand, follow/pause, clear), App shell, utils/formatRelative, light-theme CSS variables with dark placeholders. dsc bin: mime lookup fixed to use the actually-served file (naked '/?query' no longer falls through to octet-stream download); shutdown closes SSE keep-alive connections so SIGTERM actually exits. Acceptance: scripts/verify-rpclog-panel.mjs (chromium headless) ALL PASS 10/10 over design.md §D 1-6. pkg: add web scripts for building feat(gui): session milestone — list + conversation over Session OOP, styled RpcLog v2.1 web-runtime: Session/SessionManager object layer (resident instances, mux frame routing, lineage flattening), foldSurface adapter with padding sentinels for paged windows, chunk accumulator for streaming partials, batched change notification (useSyncExternalStore contract), connection sinks + reconnect fix (the 300ms self-abort reconnect storm that made the session list flap is gone), fixture rewritten as a scripted host (60-turn history, typewriter replay, resident pending approval, child session); temporary contract copies deleted in favor of real apiproxy imports. web-ui: sessions screen (list with lineage indent + selection as container-local state), conversation view (turn grouping, reasoning fold, tool cards, steering, pending interaction cards, upward paging with scroll anchoring), input bar with queue/steer/stop; RpcLog panel restyled per docs/web-styling.md (tokenized palette, quadrant badge glyphs now vertical ↑↓⇟⇞, pair highlighting, floating shadow). docs/web-styling.md: living style guide (tokens, visual baseline, coding rules, evolution log). Acceptance: verify-session.mjs 31/31, verify-session-real.mjs 5/5 (real model streaming), verify-rpclog-panel.mjs 10/10. feat(gui): hostruntime split + repo-wide package prefix rename Package split (design: 20260720-0101-hostruntime-split-design): dsh-host-runtime carries bootHost + createApiProxy + startHost() (RunningHost {api, handler, defaults, ctx, dispose} — the seam Electron and any future shell reuses; ctx is the official front-door mount point); dsh-host-webserver carries the node:http static+API bridge (fixed: abort now keys on res 'close' + writableEnded — req 'close' fires on body end since Node 16 and was killing every SSE stream instantly, the reconnect-storm root cause); apps/dsc is now a thin assembly with web/-p subcommands. dsc -p runs the full isomorphic carrier chain in process (second real protocol consumer; probed end-to-end against the live model). Naming rule (user decree): packages under host/ and client/ carry the directory prefix in their npm name — dsh-host-apiproxy, dsh-client-web-runtime, dsh-client-web-ui renamed repo-wide in one frozen batch; explicit tsconfig paths entries added where the wildcard no longer matches. Acceptance: verify-session 31/31, verify-rpclog-panel 10/10, verify-session-real 7/7 (incl. new 12s connection-stability sentinels), tsc green, dsc web + dsc -p smoke both pass. refactor(gui): AbstractApiClient class hierarchy — OO client with inheritable seams AbstractApiClient (apiproxy) carries every protocol invariant: rpcId minting, four-quadrant envelope wrap/unwrap, zod parsing, SSE frame parsing, the payload-direct IApiClient surface (callers no longer mint rpcIds — the carrier does), and the instance-level envelope observation pump (batched via microtask; moved off module-level globals in rpc-log.ts, which is now a pure subscriber mapping envelopes into store entries — the debug panel observes the connection, it is not part of it). Platform subclasses own two abstract seams (doFetch, onEnvelope) plus three protocol-level virtuals for transportless overrides: InProcessApiClient (apiproxy; dsc -p uses new InProcessApiClient( host.handler)), WebApiClient (web-runtime), FixtureApiClient (fixture now subclasses instead of wrapping). Naming per decree: AbstractApiClient / IApiClient; ApiProxy stays the impl-side narrow-form contract. headless.ts call sites drop rpcRequest wrappers (payload-direct); split-design archive updated with the naming-rule ledger. tsc green; verify-session 31/31, verify-rpclog-panel 10/10, verify-session-real 7/7 (12s connection sentinel count=4); dsc -p smoke CALLER-OK. feat(gui): InputBar final form — bug batch, deepseekchat layout, single primary button, running locks input Squashes the whole InputBar iteration batch: IME/caret/auto-grow/focus/dedup bug fixes, layout aligned to the deepseekchat baseline, single primary button with hover flyout, finalized button semantics with the Codex-style icon circle, and running-state locking where stop is the only mid-turn action. The same batch carried the Chinese-to-English code comment sweep (density pruned), folded in here. docs(gui): purge work-log references from code comments 76 design-doc references cleared across the GUI packages: section pointers inlined as self-contained constraint statements, pure pointer comments dropped, milestone codenames and ruling tags out, and the 14 contract file headers switched to the formal RFC (the only sanctioned external reference). web-styling.md now cites the styling RFC instead of the disposable research archive. grep for work-log reference variants is clean across the GUI packages. docs(gui): file-header comments self-contained — drop RFC filename references RFC renames/reorgs must not require a source sweep (the 2026-07-20 two-way merge proved it). 11 headers lose only the '(RFC …)' tail and stay self-contained; api-proxy.ts keeps its minimal-first note. fix(gui): session streaming — freeze interrupted partials, sweep stale running calls, send force-scrolls Aborted turns never emit the finalizing assistant/message, so the accumulated partial and its running tool cards kept rendering below later messages — the "new message lands above the stopped reply" illusion. turn/end side effects now freeze content-bearing partials into interrupted terminal nodes (fractional seq keeps flow order; the live freeze and history replay converge through applyEventSideEffects, so a refresh reconstructs identical frozen nodes) and turn running tool cards into interrupted terminal cards; only content-free partials are swept outright. ConversationView gains the send-force-scroll rule (own words must be visible) alongside the pre-update atBottom follow flag. Regressions pinned as E2-4a–c (real host) and §E1-11h (fixture). feat(gui): webserver hardening verify script feat(gui): dark-mode toggle pinned to the sidebar bottom Interim home before the Settings page exists (the button re-homes with zero logic change — mechanics live in utils/theme.ts): html[data-theme] flip + dsc.theme localStorage, stored choice wins over the OS prefers-color-scheme default, applied in mount() before first paint so a dark reload never flashes light. Moon/sun inline SVG icon button at the sidebar's pinned bottom row. Pure front-end local concern: no RPC, no Session/store involvement. Dark sweep of list/conversation/input card/RPC panel found no unreadable pairs — no token changes needed. docs(gui): GUI RFCs and web styling handbook Layering+RPC protocol and web client architecture RFCs (post-reorg, developer-facing polish folded in) plus the styling engineering handbook. Mission work logs live in the commit above; PRs can be cut from this commit to include formal docs only. fix(gui): client object-layer hardening — audit timing/reference/resilience batches (S3-S5,C1-C3,C5-C8) fix(gui): carrier error channel + webserver backpressure (audit A1-A5,A7-A10,R2,R5) feat(gui): session persistence surface — cold list, project cwd, legacy no-cwd retirement refactor: rename dsc CLI to dsh — apps/cli, bin name, package scope Includes the root tsconfig project-references fix for host/* and client/web-runtime (originally a separate build fix commit). test(gui): three-tier suite — protocol/object/browser lanes, tier-a fill to per-file 100% test(gui): jsdom lane for web-ui + web-runtime coverage gate entry docs(gui): GUI testing system RFC (zh) feat(gui): tool-card views — contract slot, host-computed delivery, three-level card fallback fix(gui): lint clean across GUI packages — wrap long doc comments, drop dead type args, sync-return methods without awaits docs(gui): doc-sync mechanical fixes — JSDoc on apiproxy/host exports, RFC sketch fences ignore-check, md-wrap paragraphs, drop missions links, web-ui plain-ts entry chore(gui): module-graph regen + knip clean — drop dead re-exports, internalize createFixtureApi, scan web-ui tsx and verify mjs scripts build(gui): wire client/host packages into the lib build shape — tsc references + tsdown (web-ui css-external), lib manifests, cordis peer, apiproxy typed subpaths, vite src aliases test(gui): host-side per-file 100% coverage — apiproxy schema/carrier suites, webserver http-bridge suite, host-runtime composition suite; client/* coverage excluded pending the browser-side testing work item docs(gui): package READMEs for the five GUI packages — model-experience audit entries, limitations sections docs(gui): bilingual RFC pairs + client JSDoc completion — translate the three GUI RFCs to English with i18n records and manifest ratchet, Consequences sections both sides, full client/* export JSDoc, regen doc graphs and RFC index fix(scripts): doc-typecheck built-declarations mode maps /src/* subpath wildcards (apiproxy browser-safe channels) docs(gui): apply dsh rename across pr-gates docs — READMEs, layering RFC en, web-ui entry comment, i18n re-record fix(gui): post-rebase lint reconciliation — wrap main-tree long doc comments, read-through narrowing guards, abortError Error normalization, handleUnary generic justification fix(gui): post-rebase doc/test reconciliation — align host specs with evolved carrier contracts (sentinel rpcId, stream/error surfacing, url-path transport messages, defaults.cwd), Agent Note titles and relocated links, KV Cache effect sections, JSDoc on evolved exports fix(gui): second-rebase reconciliation to 509db0cb3 — restore api panel exports the baseline suites consume, knip workspace entries for jsdom lane and apps/web smokes, hoist result narrowing, align testing.md to the narrowed web-ui exclusion fix(test): vitest-scoped tsconfig maps bare imports for tsx specs — with GUI manifests now pointing at lib, an unmapped importer loaded a second copy of the web-runtime singletons fix(gui): typecheck + lint clean over the tool-card batch — brand callIds and object-form turn/end reason in the view spec, narrow fixture arg stringification, wrap long v8-ignore comments docs(gui): export JSDoc for tool-card surfaces + testing-note pairing header docs: rfc for web testing feat: add tools to host-runtime fix(gui): dispatch agent/error via agentEvents in host-runtime spec — mounted invariants plugin rejects raw ctx.emit without the scope carrier fix(gui): restore GUI knip workspaces + scripts/mjs entries and regenerate lockfile after master rebase fix(gui): post-rebase gate repairs — drop context-node envelope (master unwrapped injected content envelopes), regen event matrix, condense testing.md web-ui exclusion within budget fix(session): browser-safe deep-equal in surface — node:util import broke the vite bundle ci(gates): frontend vite build joins pre-push — node: imports in the client closure pass tsc but break the browser bundle test(tui): drop the checkout-dependent process.cwd() harness default — a long worktree path pushes the footer token counters past the 88-column fake terminal test(gui): jsdom behavior E2E — conversation main path over fixture runtime, reconnect banner lifecycle test(gui): jsdom RPC panel behavior — ledger rows, expand, pairing, pause/clear, follow-pause, payload truncation test(gui): jsdom tier-2 — InputBar guards, reasoning fold, JSON blocks, message variants, theme, create-then-select; act-harden banner case test(gui): jsdom tier-3 — ConversationView states/paging/force-bottom, ToolCallCard arms, PendingCard, list rows test(gui): jsdom tails — view-card variants, LogRow directions, registry hygiene, badge overflow, hook ops, mount glue test(gui): jsdom tails round 2 — call-ref blocks, resume follow, view precedence, failed create, empty-diff arm test(gui): jsdom final arms — anchor compensation, follow-off, interval ticks, view halves, node-over-running precedence test(gui): web-ui joins the per-file 100% coverage gate Annotation-only src changes plus the config swap. The web-ui exclusion is replaced by a single index.tsx entry (stale byte-identical duplicate of mount.tsx, nothing imports it; same entry-glue treatment as bin.ts) and the coverage include gains .tsx. v8-ignore sites (each with its reason inline): - ConversationView 3x ref-null guards; InputBar disabled-click guard - ToolCallCard both-null arms + windowless-custom argsRaw arm - LogRow css-module key fallbacks (start/stop block); RpcLogBody 3x ref-null guards - web-runtime drift from the tool-card batch: fixture presenter catch/str typo-guards, dense-array guards (fold-adapter reset, session rebuild, fixture backscan), live view-present arm (fixture replays are text-only; view vocabulary is covered by the history samples) test(gui): close the PR #443 host-side coverage gaps — apiproxy client abort arms, api-proxy cold/view paths, webserver drain - apiproxy fetch/client.ts: 3 new cases (pre-aborted signal short-circuits before transport + string reason mapping, non-Error/string reason falls to the default AbortError message, signal-less doFetch passthrough) - runtime/api-proxy.ts: one v8-ignore (summarizeCold cwd arm — list() filters cwd-less legacy metas) + api-proxy-cold.spec.ts (cold list merge: mtime source, locate-undefined and vanished-log fallbacks, lineage; no-persistence/no-factory resume → internal) + 2 view cases (history views with meta passthrough and orphan/bad-args/presenterless soft-falls, session/disposed open-call cleanup on the mux stream) - webserver/index.ts: /api/big fixture drives both drain-wait legs (full 8MiB readback after drain, mid-chunk disconnect wakes via 'close') feat: app shell fix: rebase conflicts fix: coverage fix(gui): lint clean after rebase — wrap long v8-ignore comments, unconditional v1 detail-block claim chore(gui): remove browser/probe verify scripts from scripts/ The six GUI acceptance/probe scripts (carrier-errors, rpclog-panel, session, session-real, webserver-backpressure, webserver-hardening) leave the repo's scripts/ tree; the three code comments that pointed at them now describe the coverage lane without naming a script path. fix(webserver): guard the request callback — one malformed request must not kill the process The async handle() had no top-level catch, so any throw inside it (a bad %-escape reaching decodeURIComponent, a client dropping mid-body, a response stream erroring) became an unhandled rejection and took the whole process down (audit R1 must-fix). The guard answers 400 when headers are not out yet, destroys the socket when they are, and reports the failure to onError (the package never prints). Spec covers all three legs: %-escape barrage → 400 + server stays alive, non-Error throw wrapped for onError, mid-stream explosion → socket teardown. feat: client AGENTS.md fix: client/AGENTS.md fix: rebase feat(gui): T0 cut 1 — 12 client package skeletons with contract stubs, dshClient declarations, tsdown client preset, theme token sheets feat(gui): T0 cut 2 — pure git mv migration per v3 §11 (connection six, runtime sessions/kernel, ui-conversation chat, ui-primitives markdown family, web shell + e2e) feat(gui): T0 cuts 3+4 — import rewiring to new package names, .legacy demotion of owner-rewrite files, legacy web-runtime/web-ui/apps-web retired to attic feat(gui): connection 对账刀——index.ts 精确导出清单替换 export *,intents.legacy 溶解删除 feat(client/ui-slots): SlotCore real implementation — kind semantics, sync version + microtask-batched notify, onMutate bridge feat(gui): web shell vite alias — retarget to new client packages, shell static surface only feat(gui): host 侧刀属地半——HostWebPluginRegistry(entries 扫描+internal/plugin 去抖重扫+dshClient 校验+exports./client 解析)、GET /plugins//client.js 分发端点、GET / 与 SPA fallback 注入 __DSH_BOOT__(webPlugins 可选注入,不传行为不变) feat(web-react): add use-sync-external-store dep + local shim typings feat(web-react): bindSnapshotSelector via uSES with-selector shim feat(gui): ui-layout concession-chain solver — pure computeColumns with contract geometry feat(gui): ui-layout LayoutService — four persisted stores, clamped actions, list-driven prune feat(gui): ui-layout AppFrame styles — grid columns, collapse-safe borders, edge drag handles test(gui): 存量 spec 平移——connection 三件+runtime 六件自 attic 捞回改包名路径全绿;api-helpers 按归属拆分(wire 半留 connection、classifier 半随 conversation.ts 入 runtime);boot-intents/preinit/rpc-log 随 intents/rpc-log 退役不迁(记 v3 §3.2 溶解项) feat(client/ui-primitives): StateDot/Button/Pill/Input/Menu atoms, ConnectionBanner de-legacied to pure props, JsonBlock CSS on --dsw tokens feat(web-react): createSnapshotStore engine (rafFlush batch, persist opt-in, dev freeze) + spec feat(gui): ui-layout AppFrame — grid tracks, pointer-capture drag handles with rAF throttle, frame ResizeObserver feat(web-react): useInvoke (external pending store, stable invoke, concurrency count) + spec test(web-react): bind spec — equality bail, custom eq, zero resubscribe, StrictMode, method sources feat(gui): ui-layout index rewiring — real exports, client apply provides ctx.layout and defines three slots feat(web-react): SessionProvider (renderBody deps) + RootBindingProvider + binding contexts + spec feat(gui): web shell AppRoot boot-page styles — self-contained with neutral token fallbacks feat(gui): web shell AppRoot — boot gate over loader status, fail-loud plugin failure list fix(gui): AppRoot gates on explicit settled signal — status-derived readiness races the incrementally filled table feat(client/ui-theme): ThemeService real implementation — registry with built-in light/dark, apply toggles body[data-ds-dark-theme], third-party token overrides as body inline vars feat(web-react): scopedSlots outlet (kind matrix, inject WeakMap caches, per-entry error boundary) + spec feat(gui): web shell module-table seed — pure-library entities for the loader require surface feat(client/i18n): I18nService real implementation — ns×locale registry, stable bind(ns) reference, zh fallback chain, zh/en skeleton dictionaries feat(gui): web shell assembly closure — layout exports via module table, SessionProvider + scopedSlots + RootBindingProvider feat: client/ui-conversation feat: code codedoc build(gui): root bundle green — web shell excluded from the lib workspace (vite app), ui-primitives lib externalizes css side-effect imports (web-ui precedent) gates(gui): verify-cordis-config follows aggregate tsconfig references (root is a shell over host/client programs); module graph regenerated for the twelve client packages chore(gui): retire legacy migration sources — every owner rewrite landed (t0-checklist §7 ledger honored); orphan css of retired components removed gates(gui): knip green groundwork — e2e/tsx entries for the new packages, loader-runtime deps ignored where loading is by specifier string, fake plugin ids un-bare-named, dead test export dropped chore(client): manifest shape batch A — ui-slots/web-react/ui-primitives invariant companions, files whitelist, cordis+invariants peer/dev, tsconfig refs chore(client): manifest shape batch B — connection/runtime/ui-conversation/ui-trajectory files whitelist, cordis peer+dev, explicit invariant lib entries (clientBundle signature) chore(client): manifest shape batch C — i18n/ui-layout/ui-sidebar/ui-theme invariant companions, files whitelist, invariants peer/dev, tsconfig refs chore(client): manifest shape batch D — web shell gains node-half lib entry + invariant companion + uniform files whitelist chore(client): drop verified-unused deps — dsh-tools from runtime/ui-conversation (types ride /presentation), ui-primitives+clsx from ui-layout gates(gui): doc-gate fixes — theme JSDoc prose, three client type-link exemptions, agent-note paths follow the migration, config catalog regenerated gates(gui): type-equiv manifest follows the types.ts extraction, approval JSDoc keeps its link form, persistence catalog regenerated docs(gui): per-constant JSDoc on the contract geometry exports (export-jsdoc gate) test(gates): loader-composition budget covers cold tsx resolution after the program split (was flaking at the default 5s) docs(gui): README substantiation batch 1 — ui-slots/ui-primitives/web-react/connection: Model Experience short form, real deferred-work ledgers, description accuracy pass fix(client): theme/i18n dual-entry split — service classes + cordis merges move to src/client (host catalog scanner no longer misclassifies client services), node halves keep types + empty apply; catalogs regenerated docs(gui): README substantiation batch 2 — runtime/ui-layout/ui-sidebar/ui-conversation: Model Experience short form, package-owned deferred-work ledgers (unload stub, watch approximation, /client value-import rule, global details state, two-state dots, stats duration gap, single-bundle caches) docs(gui): README substantiation batch 3 — ui-trajectory/ui-theme/i18n/web: Model Experience short form, deferred-work ledgers (placeholder charter, no theme toggle owner, empty locale dictionaries, one-shot rendering); both README gates green test(scripts): purity spec adopts clientBundle two-arg signature (explicit libEntry, no default) gates(gui): knip green — declaration-merge dep ignored, fake plugin id assembled at runtime, invariants dep de-duplicated to peer+dev, stale apps/web section dropped feat(gui): 门禁波次 host 三包 invariant 形状——apiproxy explained-empty 伴生(wire 契约层零事件面)、webserver 真关系伴生(manifest 行必解析出 clientPath,防 __DSH_BOOT__ 广告 404 bundle;apps/cli 发布 webPlugins 键供审计)、runtime 补 files 白名单;三包 exports/files/peer+dev/tsconfig refs 齐 fw-react 形状;constraints+invariants 双 gate 零违规 build(client): ui-layout/ui-sidebar tsdown configs adopt the explicit two-arg clientBundle signature (orphaned follow-up of the manifest shape batch) refactor(gui): shell boot becomes a library face — bootWebShell(el) exported for the apps/web entry; main.ts retired refactor(gui): exports 纪律刀1——ui-theme/i18n node index 收敛为只空 apply(Translate/LocaleDict/ThemeTokens 类型下沉 src/client/),ui-conversation 的 I18nService import 改 /client 子路径 build(typecheck): converge to root host aggregate + tsconfig.client.json — delete tsconfig.host.json, verify-cordis-config seeds both aggregates feat(gui): apps/web restored as the vite application — thin main over bootWebShell; dsh-client-web becomes a plain lib (index exports shell surface, vite files and e2e moved out) chore(gates): knip.json rewritten on the master base — same semantics, minimal diff (formatting churn dropped) docs(gui): 时效清扫②——testing.md 删 web-ui 覆盖豁免残句;web-styling.md 加 token 换代头注(--dsw-* 现行、工程约束条款仍有效并注明收编处) docs(gui): 时效清扫③——四对 GUI Agent Note 加路径更新头注(web-runtime/web-ui/dsh-frontend→现行 12 包结构;设计结论存续声明;双语对同步) docs(gui): 时效清扫③b——四对 note 头注的 i18n 配对哈希重录 build(typecheck): minimal-diff tsconfig shape — drop root files entry (purity spec + preset move to client program), compress comments, drop redundant util/home root ref feat(gui): apps/web restoration follow-through — dsh-frontend package name, cli dist resolve, root build:web filter, tsdown exemption dropped, vitest web lane + knip + client aggregate retargeted, e2e paths rebased refactor(gui): exports 纪律刀2——connection wire 六件 git mv 进 src/client/(wire 即该 dshClient 插件的 client 半),node index=只空 apply,/client 半边整面导出(v3 §3.2 清单原样),包内 tests 改 src/client 直取 refactor(gui): exports 纪律刀3——runtime 实现整体下沉 src/client/(sessions/slots/loader;契约类型与 cordis merge 随迁 client/index),node index=只空 apply;./loader exports 指 client/loader;全消费面(web 壳/ui-sidebar/ui-trajectory/tests)bare→/client 机械跟改;vitest.e2e 换 tsconfig.vitest paths(root tsconfig 排除 client 会把 /client import 掉到 exports 的浏览器 dist bundle) refactor(gui): exports 纪律刀3 补遗——ui-layout 三处 bare runtime import 改 /client(刀3 消费面机械跟改漏提交件;跨属地机械一行×3 报备 ui-shell) test(gui): drop the getSessionManager singleton case — the init/get pair is a dead legacy-boot surface with zero live consumers (SessionsService constructs and holds the manager under the plugin architecture); source removal tracked with rt-core refactor(gui): 删 manager.ts 尾部 initSessionManager/getSessionManager 单例对——旧 boot 直连遗物,插件化下 SessionsService 构造持有 manager,全仓零活消费者(convo-b 测试清扫对表,其测试用例已先行退役 7e2c51898);头注释同步去单例措辞 code refactor --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 6 + ...026-07-19-gui-layering-and-rpc-protocol.md | 253 +++ ...-07-19-gui-layering-and-rpc-protocol.zh.md | 251 +++ ...7-19-gui-web-client-architecture.i18n.yaml | 6 + .../2026-07-19-gui-web-client-architecture.md | 148 ++ ...26-07-19-gui-web-client-architecture.zh.md | 148 ++ ...2-slot-type-chain-implementation.i18n.yaml | 6 + ...26-07-22-slot-type-chain-implementation.md | 47 + ...07-22-slot-type-chain-implementation.zh.md | 47 + .../2026-07-19-web-styling-system.i18n.yaml | 6 + .../process/2026-07-19-web-styling-system.md | 61 + .../2026-07-19-web-styling-system.zh.md | 61 + .../2026-07-20-gui-testing-system.i18n.yaml | 6 + .../process/2026-07-20-gui-testing-system.md | 59 + .../2026-07-20-gui-testing-system.zh.md | 59 + .gitignore | 2 + apps/cli/package.json | 23 + apps/cli/src/bin.ts | 22 + apps/cli/src/headless.ts | 104 ++ apps/cli/src/web.ts | 89 + apps/cli/tsconfig.json | 18 + apps/web/index.html | 12 + apps/web/package.json | 37 + apps/web/src/main.ts | 10 + apps/web/tests/smoke-fixture.e2e.ts | 147 ++ apps/web/tests/smoke-real.e2e.ts | 235 +++ apps/web/tests/support.ts | 47 + apps/web/tsconfig.json | 22 + apps/web/vite.config.ts | 26 + docs/config-catalog.md | 17 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 4 +- docs/event-producer-consumer.md | 16 +- docs/module-graph.md | 49 + docs/persistence-catalog.md | 6 +- docs/web-styling.md | 107 ++ eslint.config.mjs | 16 + knip.json | 536 +++++- package.json | 11 +- packages/client/AGENTS.md | 71 + packages/client/connection/README.md | 16 + packages/client/connection/package.json | 53 + packages/client/connection/src/client/api.ts | 47 + .../connection/src/client/connection.ts | 190 +++ .../client/connection/src/client/fixture.ts | 554 +++++++ .../client/connection/src/client/index.ts | 76 + .../connection/src/client/web-api-client.ts | 12 + packages/client/connection/src/index.ts | 10 + packages/client/connection/src/invariant.ts | 32 + .../connection/tests/api-helpers.spec.ts | 21 + .../connection/tests/connection.spec.ts | 238 +++ packages/client/connection/tests/fake-api.ts | 154 ++ .../client/connection/tests/fixture.spec.ts | 338 ++++ .../client/connection/tests/node-half.spec.ts | 10 + packages/client/connection/tsconfig.json | 42 + packages/client/connection/tsdown.config.ts | 3 + packages/client/i18n/README.md | 16 + packages/client/i18n/package.json | 54 + packages/client/i18n/src/client/index.ts | 108 ++ packages/client/i18n/src/index.ts | 11 + packages/client/i18n/src/invariant.ts | 32 + packages/client/i18n/src/locales/en.ts | 2 + packages/client/i18n/src/locales/zh.ts | 2 + packages/client/i18n/tests/i18n.spec.ts | 53 + packages/client/i18n/tests/invariant.spec.ts | 30 + packages/client/i18n/tsconfig.json | 27 + packages/client/i18n/tsdown.config.ts | 3 + packages/client/runtime/README.md | 18 + packages/client/runtime/package.json | 63 + packages/client/runtime/src/client/index.ts | 128 ++ .../client/runtime/src/client/loader/index.ts | 247 +++ .../src/client/sessions/conversation.ts | 165 ++ .../src/client/sessions/fold-adapter.ts | 194 +++ .../runtime/src/client/sessions/lineage.ts | 63 + .../runtime/src/client/sessions/manager.ts | 250 +++ .../runtime/src/client/sessions/notifier.ts | 61 + .../runtime/src/client/sessions/partial.ts | 89 + .../runtime/src/client/sessions/service.ts | 221 +++ .../runtime/src/client/sessions/session.ts | 527 ++++++ packages/client/runtime/src/client/slots.ts | 107 ++ packages/client/runtime/src/index.ts | 11 + packages/client/runtime/src/invariant.ts | 52 + .../runtime/tests/client-loader-bundle.e2e.ts | 77 + .../runtime/tests/client-loader.spec.ts | 192 +++ .../client/runtime/tests/conversation.spec.ts | 23 + packages/client/runtime/tests/event-script.ts | 50 + packages/client/runtime/tests/fake-api.ts | 157 ++ .../client/runtime/tests/fold-adapter.spec.ts | 145 ++ packages/client/runtime/tests/lineage.spec.ts | 55 + packages/client/runtime/tests/manager.spec.ts | 223 +++ .../client/runtime/tests/node-half.spec.ts | 10 + .../client/runtime/tests/notifier.spec.ts | 75 + packages/client/runtime/tests/partial.spec.ts | 91 + packages/client/runtime/tests/session.spec.ts | 625 +++++++ .../runtime/tests/sessions-service.spec.ts | 141 ++ .../runtime/tests/slots-service.spec.ts | 65 + packages/client/runtime/tsconfig.json | 39 + packages/client/runtime/tsdown.config.ts | 23 + packages/client/tsdown.client.ts | 152 ++ packages/client/ui-conversation/README.md | 22 + packages/client/ui-conversation/package.json | 63 + .../ui-conversation/src/client/apply.ts | 185 +++ .../client/chat/AssistantMarkdown.module.css | 33 + .../src/client/chat/AssistantMarkdown.tsx | 56 + .../src/client/chat/ChatView.module.css | 101 ++ .../src/client/chat/ChatView.tsx | 293 ++++ .../src/client/chat/GenericToolCard.tsx | 36 + .../src/client/chat/IconSparkle16.tsx | 15 + .../src/client/chat/MessageItem.module.css | 34 + .../src/client/chat/MessageItem.tsx | 56 + .../src/client/chat/PendingCard.module.css | 31 + .../src/client/chat/PendingCard.tsx | 31 + .../src/client/chat/StatsLine.module.css | 13 + .../src/client/chat/StatsLine.tsx | 68 + .../src/client/chat/ToolRow.module.css | 88 + .../src/client/chat/ToolRow.tsx | 75 + .../src/client/chat/ToolViewOutlet.tsx | 89 + .../src/client/chat/chat-flow.ts | 46 + .../src/client/chat/register.ts | 52 + .../src/client/contract/slots.ts | 63 + .../src/client/contract/tool-call-model.ts | 126 ++ .../src/client/contract/toolview.ts | 77 + .../src/client/contract/views.ts | 68 + .../ui-conversation/src/client/index.ts | 42 + .../ui-conversation/src/client/service.ts | 251 +++ .../skeleton/ConversationRoot.module.css | 129 ++ .../src/client/skeleton/ConversationRoot.tsx | 109 ++ .../client/skeleton/DetailsPanel.module.css | 94 ++ .../src/client/skeleton/DetailsPanel.tsx | 111 ++ .../src/client/skeleton/EmptyState.module.css | 68 + .../src/client/skeleton/EmptyState.tsx | 106 ++ .../src/client/skeleton/InputBar.module.css | 160 ++ .../src/client/skeleton/InputBar.tsx | 142 ++ .../client/toolviews/bash-sample.module.css | 48 + .../src/client/toolviews/bash-sample.tsx | 52 + .../src/client/toolviews/registry.ts | 102 ++ .../ui-conversation/src/css-modules.d.ts | 6 + packages/client/ui-conversation/src/index.ts | 10 + .../client/ui-conversation/src/invariant.ts | 33 + .../tests/apply-inject.spec.tsx | 276 ++++ .../ui-conversation/tests/chat-apply.spec.tsx | 105 ++ .../tests/chat-branch-tails.spec.tsx | 156 ++ .../tests/chat-stats-bash-sample.spec.tsx | 178 ++ .../tests/chat-tool-row.spec.tsx | 147 ++ .../ui-conversation/tests/chat-view.spec.tsx | 305 ++++ .../tests/coverage-tails.spec.tsx | 127 ++ .../tests/gate-branch-tails.spec.tsx | 140 ++ .../ui-conversation/tests/input-bar.spec.tsx | 131 ++ .../tests/selection-survival.spec.ts | 121 ++ .../tests/service-orchestration.spec.ts | 190 +++ .../tests/service-stores.spec.ts | 176 ++ .../tests/skeleton-branches.spec.tsx | 245 +++ .../ui-conversation/tests/skeleton.spec.tsx | 181 ++ .../tests/toolview-entry-types.spec.ts | 62 + .../tests/toolview-registry.spec.ts | 101 ++ .../tests/toolviews-type-chain.spec.ts | 96 ++ .../tests/views-type-chain.spec.tsx | 100 ++ packages/client/ui-conversation/tsconfig.json | 46 + .../client/ui-conversation/tsdown.config.ts | 3 + packages/client/ui-layout/README.md | 19 + packages/client/ui-layout/package.json | 59 + .../ui-layout/src/client/AppFrame.module.css | 73 + .../client/ui-layout/src/client/AppFrame.tsx | 149 ++ .../client/ui-layout/src/client/columns.ts | 79 + packages/client/ui-layout/src/client/index.ts | 81 + .../client/ui-layout/src/client/service.ts | 132 ++ .../client/ui-layout/src/css-modules.d.ts | 6 + packages/client/ui-layout/src/index.ts | 10 + packages/client/ui-layout/src/invariant.ts | 31 + .../client/ui-layout/tests/app-frame.spec.tsx | 208 +++ packages/client/ui-layout/tests/apply.spec.ts | 71 + .../client/ui-layout/tests/columns.spec.ts | 100 ++ .../client/ui-layout/tests/service.spec.ts | 138 ++ packages/client/ui-layout/tsconfig.json | 37 + packages/client/ui-layout/tsdown.config.ts | 3 + packages/client/ui-primitives/README.md | 18 + packages/client/ui-primitives/package.json | 42 + .../ui-primitives/src/Button.module.css | 73 + packages/client/ui-primitives/src/Button.tsx | 31 + .../src/ConnectionBanner.module.css | 13 + .../ui-primitives/src/ConnectionBanner.tsx | 16 + .../client/ui-primitives/src/FishLogo.tsx | 27 + .../client/ui-primitives/src/Input.module.css | 38 + packages/client/ui-primitives/src/Input.tsx | 23 + .../client/ui-primitives/src/Menu.module.css | 68 + packages/client/ui-primitives/src/Menu.tsx | 81 + .../client/ui-primitives/src/Pill.module.css | 27 + packages/client/ui-primitives/src/Pill.tsx | 31 + .../ui-primitives/src/StateDot.module.css | 65 + .../client/ui-primitives/src/StateDot.tsx | 55 + .../client/ui-primitives/src/css-modules.d.ts | 6 + .../client/ui-primitives/src/icons/index.tsx | 575 +++++++ .../client/ui-primitives/src/icons/props.ts | 8 + packages/client/ui-primitives/src/index.ts | 19 + .../client/ui-primitives/src/invariant.ts | 31 + .../src/markdown/JsonBlock.module.css | 32 + .../ui-primitives/src/markdown/JsonBlock.tsx | 32 + .../src/markdown/MessageText.module.css | 9 + .../src/markdown/MessageText.tsx | 7 + .../client/ui-primitives/tests/atoms.spec.tsx | 122 ++ .../client/ui-primitives/tests/icons.spec.tsx | 58 + .../ui-primitives/tests/invariant.spec.ts | 12 + .../ui-primitives/tests/markdown.spec.tsx | 49 + .../ui-primitives/tests/state-dot.spec.tsx | 45 + packages/client/ui-primitives/tsconfig.json | 25 + .../client/ui-primitives/tsdown.config.ts | 31 + packages/client/ui-sidebar/README.md | 19 + packages/client/ui-sidebar/package.json | 62 + .../ui-sidebar/src/client/Rows.module.css | 158 ++ .../client/ui-sidebar/src/client/Rows.tsx | 122 ++ .../src/client/SidebarRoot.module.css | 248 +++ .../ui-sidebar/src/client/SidebarRoot.tsx | 164 ++ .../ui-sidebar/src/client/contract/slots.ts | 49 + .../client/ui-sidebar/src/client/index.ts | 71 + .../client/ui-sidebar/src/client/store.ts | 94 ++ packages/client/ui-sidebar/src/client/tree.ts | 265 +++ .../client/ui-sidebar/src/css-modules.d.ts | 6 + packages/client/ui-sidebar/src/index.ts | 10 + packages/client/ui-sidebar/src/invariant.ts | 32 + .../client/ui-sidebar/tests/apply.spec.tsx | 158 ++ .../client/ui-sidebar/tests/invariant.spec.ts | 18 + .../ui-sidebar/tests/sidebar-root.spec.tsx | 195 +++ .../client/ui-sidebar/tests/store.spec.ts | 111 ++ packages/client/ui-sidebar/tests/tree.spec.ts | 234 +++ packages/client/ui-sidebar/tsconfig.json | 40 + packages/client/ui-sidebar/tsdown.config.ts | 3 + packages/client/ui-slots/README.md | 18 + packages/client/ui-slots/package.json | 38 + packages/client/ui-slots/src/index.ts | 407 +++++ packages/client/ui-slots/src/invariant.ts | 32 + packages/client/ui-slots/tests/core.spec.ts | 209 +++ .../client/ui-slots/tests/invariant.spec.ts | 12 + .../client/ui-slots/tests/surface.spec.ts | 52 + .../client/ui-slots/tests/type-chain.spec.tsx | 140 ++ packages/client/ui-slots/tsconfig.json | 21 + packages/client/ui-theme/README.md | 17 + packages/client/ui-theme/package.json | 52 + packages/client/ui-theme/src/client/index.ts | 85 + packages/client/ui-theme/src/index.ts | 11 + packages/client/ui-theme/src/invariant.ts | 31 + packages/client/ui-theme/src/styles/base.css | 10 + .../ui-theme/src/styles/design-platform.css | 326 ++++ .../src/styles/gradient-shadow-text.css | 224 +++ .../client/ui-theme/tests/invariant.spec.ts | 27 + packages/client/ui-theme/tests/theme.spec.ts | 61 + packages/client/ui-theme/tsconfig.json | 24 + packages/client/ui-theme/tsdown.config.ts | 3 + packages/client/ui-trajectory/README.md | 15 + packages/client/ui-trajectory/package.json | 59 + .../client/TrajectoryStatsHeader.module.css | 7 + .../src/client/TrajectoryStatsHeader.tsx | 28 + .../src/client/TrajectoryView.tsx | 28 + .../src/client/WaterfallView.tsx | 49 + .../client/ui-trajectory/src/client/index.ts | 46 + .../client/ui-trajectory/src/client/spans.ts | 71 + .../ui-trajectory/src/client/views.module.css | 37 + .../client/ui-trajectory/src/css-modules.d.ts | 6 + packages/client/ui-trajectory/src/index.ts | 10 + .../client/ui-trajectory/src/invariant.ts | 32 + .../ui-trajectory/tests/client-bundle.spec.ts | 81 + .../client/ui-trajectory/tests/views.spec.tsx | 197 +++ packages/client/ui-trajectory/tsconfig.json | 34 + .../client/ui-trajectory/tsdown.config.ts | 3 + packages/client/web-react/README.md | 17 + packages/client/web-react/package.json | 50 + packages/client/web-react/src/bind.ts | 22 + packages/client/web-react/src/env.d.ts | 5 + packages/client/web-react/src/index.ts | 41 + packages/client/web-react/src/invariant.ts | 32 + .../client/web-react/src/scoped-slots.tsx | 191 +++ .../client/web-react/src/session-provider.tsx | 74 + packages/client/web-react/src/store/index.ts | 150 ++ packages/client/web-react/src/use-invoke.ts | 62 + .../src/use-sync-external-store.d.ts | 14 + packages/client/web-react/tests/bind.spec.tsx | 125 ++ .../tests/scoped-slots-real-core.spec.tsx | 70 + .../web-react/tests/scoped-slots.spec.tsx | 274 +++ .../web-react/tests/session-provider.spec.tsx | 106 ++ packages/client/web-react/tests/store.spec.ts | 135 ++ .../web-react/tests/use-invoke.spec.tsx | 84 + packages/client/web-react/tsconfig.json | 25 + packages/client/web-react/tsdown.config.ts | 42 + packages/client/web/README.md | 19 + packages/client/web/package.json | 51 + packages/client/web/src/AppRoot.module.css | 66 + packages/client/web/src/AppRoot.tsx | 52 + packages/client/web/src/app.tsx | 89 + packages/client/web/src/base.css | 19 + packages/client/web/src/boot.tsx | 66 + packages/client/web/src/css-modules.d.ts | 6 + packages/client/web/src/index.ts | 11 + packages/client/web/src/invariant.ts | 32 + packages/client/web/src/seed.ts | 35 + packages/client/web/tests/app-root.spec.tsx | 73 + packages/client/web/tests/boot.spec.tsx | 200 +++ packages/client/web/tsconfig.json | 49 + packages/client/web/tsdown.config.ts | 31 + packages/core/session/package.json | 11 +- packages/core/session/src/surface.ts | 24 +- packages/core/session/tests/surface.spec.ts | 32 + packages/core/tools/package.json | 5 + packages/host/apiproxy/README.md | 27 + packages/host/apiproxy/package.json | 59 + .../host/apiproxy/src/api/approvals.schema.ts | 21 + packages/host/apiproxy/src/api/approvals.ts | 21 + .../host/apiproxy/src/api/events.schema.ts | 42 + packages/host/apiproxy/src/api/events.ts | 69 + packages/host/apiproxy/src/api/host.schema.ts | 19 + packages/host/apiproxy/src/api/host.ts | 25 + packages/host/apiproxy/src/api/index.ts | 46 + .../host/apiproxy/src/api/questions.schema.ts | 26 + packages/host/apiproxy/src/api/questions.ts | 19 + packages/host/apiproxy/src/api/rpc-map.ts | 26 + packages/host/apiproxy/src/api/rpc.schema.ts | 97 ++ packages/host/apiproxy/src/api/rpc.ts | 113 ++ .../host/apiproxy/src/api/sessions.schema.ts | 110 ++ packages/host/apiproxy/src/api/sessions.ts | 73 + packages/host/apiproxy/src/fetch/client.ts | 302 ++++ packages/host/apiproxy/src/fetch/handler.ts | 197 +++ packages/host/apiproxy/src/index.ts | 13 + packages/host/apiproxy/src/invariant.ts | 33 + .../apiproxy/tests/client-handler.spec.ts | 407 +++++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 305 ++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 161 ++ packages/host/apiproxy/tsconfig.json | 33 + packages/host/runtime/README.md | 31 + packages/host/runtime/package.json | 82 + packages/host/runtime/src/api-proxy.ts | 429 +++++ packages/host/runtime/src/boot.ts | 133 ++ packages/host/runtime/src/index.ts | 14 + packages/host/runtime/src/invariant.ts | 31 + packages/host/runtime/src/start.ts | 58 + packages/host/runtime/src/web-plugins.ts | 63 + .../host/runtime/tests/api-proxy-cold.spec.ts | 94 ++ .../host/runtime/tests/api-proxy-view.spec.ts | 179 ++ .../host/runtime/tests/host-runtime.spec.ts | 367 +++++ .../host/runtime/tests/web-plugins.e2e.ts | 71 + .../host/runtime/tests/web-plugins.spec.ts | 114 ++ packages/host/runtime/tsconfig.json | 144 ++ packages/host/webserver/README.md | 23 + packages/host/webserver/package.json | 37 + packages/host/webserver/src/index.ts | 208 +++ packages/host/webserver/src/invariant.ts | 49 + packages/host/webserver/src/static.ts | 58 + packages/host/webserver/src/web-plugins.ts | 184 +++ .../host/webserver/tests/invariant.spec.ts | 50 + .../host/webserver/tests/web-plugins.spec.ts | 210 +++ .../host/webserver/tests/webserver.spec.ts | 337 ++++ packages/host/webserver/tsconfig.json | 18 + .../tests/loader-composition.spec.ts | 5 +- packages/llm/llm/package.json | 9 + packages/ui/tui/tests/tui.spec.ts | 8 +- packages/ui/user-approval/package.json | 5 + packages/ui/user-approval/src/index.ts | 24 +- packages/ui/user-approval/src/types.ts | 29 + packages/ui/user-approval/tsdown.config.ts | 30 + packages/ui/user-interaction/package.json | 5 + packages/ui/user-interaction/src/index.ts | 40 +- packages/ui/user-interaction/src/types.ts | 44 + pnpm-lock.yaml | 1463 ++++++++++++++++- pnpm-workspace.yaml | 1 + scripts/check-workspace-constraints.ts | 22 + scripts/client-bundle-purity.spec.ts | 60 + scripts/doc-typecheck-paths.ts | 11 + scripts/gen-cordis-catalog.ts | 3 + scripts/run-gates.ts | 2 + scripts/translation-pairing.manifest.json | 10 +- scripts/type-equiv.manifest.json | 1185 ++++++++++--- scripts/verify-client-domain-graph.ts | 102 ++ scripts/verify-cordis-config.ts | 30 +- .../verify-package-readme-model-experience.ts | 15 + tsconfig.base.json | 36 +- tsconfig.build.json | 15 + tsconfig.client.json | 42 + tsconfig.json | 9 + tsconfig.vitest.json | 15 + vitest.config.ts | 17 +- vitest.e2e.config.ts | 9 +- vitest.web.config.ts | 30 + 379 files changed, 33246 insertions(+), 411 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md create mode 100644 .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md create mode 100644 .agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-19-web-styling-system.md create mode 100644 .agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md create mode 100644 .agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-20-gui-testing-system.md create mode 100644 .agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md create mode 100644 apps/cli/package.json create mode 100644 apps/cli/src/bin.ts create mode 100644 apps/cli/src/headless.ts create mode 100644 apps/cli/src/web.ts create mode 100644 apps/cli/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/main.ts create mode 100644 apps/web/tests/smoke-fixture.e2e.ts create mode 100644 apps/web/tests/smoke-real.e2e.ts create mode 100644 apps/web/tests/support.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docs/web-styling.md create mode 100644 packages/client/AGENTS.md create mode 100644 packages/client/connection/README.md create mode 100644 packages/client/connection/package.json create mode 100644 packages/client/connection/src/client/api.ts create mode 100644 packages/client/connection/src/client/connection.ts create mode 100644 packages/client/connection/src/client/fixture.ts create mode 100644 packages/client/connection/src/client/index.ts create mode 100644 packages/client/connection/src/client/web-api-client.ts create mode 100644 packages/client/connection/src/index.ts create mode 100644 packages/client/connection/src/invariant.ts create mode 100644 packages/client/connection/tests/api-helpers.spec.ts create mode 100644 packages/client/connection/tests/connection.spec.ts create mode 100644 packages/client/connection/tests/fake-api.ts create mode 100644 packages/client/connection/tests/fixture.spec.ts create mode 100644 packages/client/connection/tests/node-half.spec.ts create mode 100644 packages/client/connection/tsconfig.json create mode 100644 packages/client/connection/tsdown.config.ts create mode 100644 packages/client/i18n/README.md create mode 100644 packages/client/i18n/package.json create mode 100644 packages/client/i18n/src/client/index.ts create mode 100644 packages/client/i18n/src/index.ts create mode 100644 packages/client/i18n/src/invariant.ts create mode 100644 packages/client/i18n/src/locales/en.ts create mode 100644 packages/client/i18n/src/locales/zh.ts create mode 100644 packages/client/i18n/tests/i18n.spec.ts create mode 100644 packages/client/i18n/tests/invariant.spec.ts create mode 100644 packages/client/i18n/tsconfig.json create mode 100644 packages/client/i18n/tsdown.config.ts create mode 100644 packages/client/runtime/README.md create mode 100644 packages/client/runtime/package.json create mode 100644 packages/client/runtime/src/client/index.ts create mode 100644 packages/client/runtime/src/client/loader/index.ts create mode 100644 packages/client/runtime/src/client/sessions/conversation.ts create mode 100644 packages/client/runtime/src/client/sessions/fold-adapter.ts create mode 100644 packages/client/runtime/src/client/sessions/lineage.ts create mode 100644 packages/client/runtime/src/client/sessions/manager.ts create mode 100644 packages/client/runtime/src/client/sessions/notifier.ts create mode 100644 packages/client/runtime/src/client/sessions/partial.ts create mode 100644 packages/client/runtime/src/client/sessions/service.ts create mode 100644 packages/client/runtime/src/client/sessions/session.ts create mode 100644 packages/client/runtime/src/client/slots.ts create mode 100644 packages/client/runtime/src/index.ts create mode 100644 packages/client/runtime/src/invariant.ts create mode 100644 packages/client/runtime/tests/client-loader-bundle.e2e.ts create mode 100644 packages/client/runtime/tests/client-loader.spec.ts create mode 100644 packages/client/runtime/tests/conversation.spec.ts create mode 100644 packages/client/runtime/tests/event-script.ts create mode 100644 packages/client/runtime/tests/fake-api.ts create mode 100644 packages/client/runtime/tests/fold-adapter.spec.ts create mode 100644 packages/client/runtime/tests/lineage.spec.ts create mode 100644 packages/client/runtime/tests/manager.spec.ts create mode 100644 packages/client/runtime/tests/node-half.spec.ts create mode 100644 packages/client/runtime/tests/notifier.spec.ts create mode 100644 packages/client/runtime/tests/partial.spec.ts create mode 100644 packages/client/runtime/tests/session.spec.ts create mode 100644 packages/client/runtime/tests/sessions-service.spec.ts create mode 100644 packages/client/runtime/tests/slots-service.spec.ts create mode 100644 packages/client/runtime/tsconfig.json create mode 100644 packages/client/runtime/tsdown.config.ts create mode 100644 packages/client/tsdown.client.ts create mode 100644 packages/client/ui-conversation/README.md create mode 100644 packages/client/ui-conversation/package.json create mode 100644 packages/client/ui-conversation/src/client/apply.ts create mode 100644 packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/ChatView.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/ChatView.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/IconSparkle16.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/MessageItem.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/MessageItem.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/PendingCard.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/PendingCard.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/StatsLine.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/StatsLine.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/ToolRow.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/ToolRow.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/chat-flow.ts create mode 100644 packages/client/ui-conversation/src/client/chat/register.ts create mode 100644 packages/client/ui-conversation/src/client/contract/slots.ts create mode 100644 packages/client/ui-conversation/src/client/contract/tool-call-model.ts create mode 100644 packages/client/ui-conversation/src/client/contract/toolview.ts create mode 100644 packages/client/ui-conversation/src/client/contract/views.ts create mode 100644 packages/client/ui-conversation/src/client/index.ts create mode 100644 packages/client/ui-conversation/src/client/service.ts create mode 100644 packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/InputBar.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/InputBar.tsx create mode 100644 packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css create mode 100644 packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx create mode 100644 packages/client/ui-conversation/src/client/toolviews/registry.ts create mode 100644 packages/client/ui-conversation/src/css-modules.d.ts create mode 100644 packages/client/ui-conversation/src/index.ts create mode 100644 packages/client/ui-conversation/src/invariant.ts create mode 100644 packages/client/ui-conversation/tests/apply-inject.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-apply.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-tool-row.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-view.spec.tsx create mode 100644 packages/client/ui-conversation/tests/coverage-tails.spec.tsx create mode 100644 packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx create mode 100644 packages/client/ui-conversation/tests/input-bar.spec.tsx create mode 100644 packages/client/ui-conversation/tests/selection-survival.spec.ts create mode 100644 packages/client/ui-conversation/tests/service-orchestration.spec.ts create mode 100644 packages/client/ui-conversation/tests/service-stores.spec.ts create mode 100644 packages/client/ui-conversation/tests/skeleton-branches.spec.tsx create mode 100644 packages/client/ui-conversation/tests/skeleton.spec.tsx create mode 100644 packages/client/ui-conversation/tests/toolview-entry-types.spec.ts create mode 100644 packages/client/ui-conversation/tests/toolview-registry.spec.ts create mode 100644 packages/client/ui-conversation/tests/toolviews-type-chain.spec.ts create mode 100644 packages/client/ui-conversation/tests/views-type-chain.spec.tsx create mode 100644 packages/client/ui-conversation/tsconfig.json create mode 100644 packages/client/ui-conversation/tsdown.config.ts create mode 100644 packages/client/ui-layout/README.md create mode 100644 packages/client/ui-layout/package.json create mode 100644 packages/client/ui-layout/src/client/AppFrame.module.css create mode 100644 packages/client/ui-layout/src/client/AppFrame.tsx create mode 100644 packages/client/ui-layout/src/client/columns.ts create mode 100644 packages/client/ui-layout/src/client/index.ts create mode 100644 packages/client/ui-layout/src/client/service.ts create mode 100644 packages/client/ui-layout/src/css-modules.d.ts create mode 100644 packages/client/ui-layout/src/index.ts create mode 100644 packages/client/ui-layout/src/invariant.ts create mode 100644 packages/client/ui-layout/tests/app-frame.spec.tsx create mode 100644 packages/client/ui-layout/tests/apply.spec.ts create mode 100644 packages/client/ui-layout/tests/columns.spec.ts create mode 100644 packages/client/ui-layout/tests/service.spec.ts create mode 100644 packages/client/ui-layout/tsconfig.json create mode 100644 packages/client/ui-layout/tsdown.config.ts create mode 100644 packages/client/ui-primitives/README.md create mode 100644 packages/client/ui-primitives/package.json create mode 100644 packages/client/ui-primitives/src/Button.module.css create mode 100644 packages/client/ui-primitives/src/Button.tsx create mode 100644 packages/client/ui-primitives/src/ConnectionBanner.module.css create mode 100644 packages/client/ui-primitives/src/ConnectionBanner.tsx create mode 100644 packages/client/ui-primitives/src/FishLogo.tsx create mode 100644 packages/client/ui-primitives/src/Input.module.css create mode 100644 packages/client/ui-primitives/src/Input.tsx create mode 100644 packages/client/ui-primitives/src/Menu.module.css create mode 100644 packages/client/ui-primitives/src/Menu.tsx create mode 100644 packages/client/ui-primitives/src/Pill.module.css create mode 100644 packages/client/ui-primitives/src/Pill.tsx create mode 100644 packages/client/ui-primitives/src/StateDot.module.css create mode 100644 packages/client/ui-primitives/src/StateDot.tsx create mode 100644 packages/client/ui-primitives/src/css-modules.d.ts create mode 100644 packages/client/ui-primitives/src/icons/index.tsx create mode 100644 packages/client/ui-primitives/src/icons/props.ts create mode 100644 packages/client/ui-primitives/src/index.ts create mode 100644 packages/client/ui-primitives/src/invariant.ts create mode 100644 packages/client/ui-primitives/src/markdown/JsonBlock.module.css create mode 100644 packages/client/ui-primitives/src/markdown/JsonBlock.tsx create mode 100644 packages/client/ui-primitives/src/markdown/MessageText.module.css create mode 100644 packages/client/ui-primitives/src/markdown/MessageText.tsx create mode 100644 packages/client/ui-primitives/tests/atoms.spec.tsx create mode 100644 packages/client/ui-primitives/tests/icons.spec.tsx create mode 100644 packages/client/ui-primitives/tests/invariant.spec.ts create mode 100644 packages/client/ui-primitives/tests/markdown.spec.tsx create mode 100644 packages/client/ui-primitives/tests/state-dot.spec.tsx create mode 100644 packages/client/ui-primitives/tsconfig.json create mode 100644 packages/client/ui-primitives/tsdown.config.ts create mode 100644 packages/client/ui-sidebar/README.md create mode 100644 packages/client/ui-sidebar/package.json create mode 100644 packages/client/ui-sidebar/src/client/Rows.module.css create mode 100644 packages/client/ui-sidebar/src/client/Rows.tsx create mode 100644 packages/client/ui-sidebar/src/client/SidebarRoot.module.css create mode 100644 packages/client/ui-sidebar/src/client/SidebarRoot.tsx create mode 100644 packages/client/ui-sidebar/src/client/contract/slots.ts create mode 100644 packages/client/ui-sidebar/src/client/index.ts create mode 100644 packages/client/ui-sidebar/src/client/store.ts create mode 100644 packages/client/ui-sidebar/src/client/tree.ts create mode 100644 packages/client/ui-sidebar/src/css-modules.d.ts create mode 100644 packages/client/ui-sidebar/src/index.ts create mode 100644 packages/client/ui-sidebar/src/invariant.ts create mode 100644 packages/client/ui-sidebar/tests/apply.spec.tsx create mode 100644 packages/client/ui-sidebar/tests/invariant.spec.ts create mode 100644 packages/client/ui-sidebar/tests/sidebar-root.spec.tsx create mode 100644 packages/client/ui-sidebar/tests/store.spec.ts create mode 100644 packages/client/ui-sidebar/tests/tree.spec.ts create mode 100644 packages/client/ui-sidebar/tsconfig.json create mode 100644 packages/client/ui-sidebar/tsdown.config.ts create mode 100644 packages/client/ui-slots/README.md create mode 100644 packages/client/ui-slots/package.json create mode 100644 packages/client/ui-slots/src/index.ts create mode 100644 packages/client/ui-slots/src/invariant.ts create mode 100644 packages/client/ui-slots/tests/core.spec.ts create mode 100644 packages/client/ui-slots/tests/invariant.spec.ts create mode 100644 packages/client/ui-slots/tests/surface.spec.ts create mode 100644 packages/client/ui-slots/tests/type-chain.spec.tsx create mode 100644 packages/client/ui-slots/tsconfig.json create mode 100644 packages/client/ui-theme/README.md create mode 100644 packages/client/ui-theme/package.json create mode 100644 packages/client/ui-theme/src/client/index.ts create mode 100644 packages/client/ui-theme/src/index.ts create mode 100644 packages/client/ui-theme/src/invariant.ts create mode 100644 packages/client/ui-theme/src/styles/base.css create mode 100644 packages/client/ui-theme/src/styles/design-platform.css create mode 100644 packages/client/ui-theme/src/styles/gradient-shadow-text.css create mode 100644 packages/client/ui-theme/tests/invariant.spec.ts create mode 100644 packages/client/ui-theme/tests/theme.spec.ts create mode 100644 packages/client/ui-theme/tsconfig.json create mode 100644 packages/client/ui-theme/tsdown.config.ts create mode 100644 packages/client/ui-trajectory/README.md create mode 100644 packages/client/ui-trajectory/package.json create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryView.tsx create mode 100644 packages/client/ui-trajectory/src/client/WaterfallView.tsx create mode 100644 packages/client/ui-trajectory/src/client/index.ts create mode 100644 packages/client/ui-trajectory/src/client/spans.ts create mode 100644 packages/client/ui-trajectory/src/client/views.module.css create mode 100644 packages/client/ui-trajectory/src/css-modules.d.ts create mode 100644 packages/client/ui-trajectory/src/index.ts create mode 100644 packages/client/ui-trajectory/src/invariant.ts create mode 100644 packages/client/ui-trajectory/tests/client-bundle.spec.ts create mode 100644 packages/client/ui-trajectory/tests/views.spec.tsx create mode 100644 packages/client/ui-trajectory/tsconfig.json create mode 100644 packages/client/ui-trajectory/tsdown.config.ts create mode 100644 packages/client/web-react/README.md create mode 100644 packages/client/web-react/package.json create mode 100644 packages/client/web-react/src/bind.ts create mode 100644 packages/client/web-react/src/env.d.ts create mode 100644 packages/client/web-react/src/index.ts create mode 100644 packages/client/web-react/src/invariant.ts create mode 100644 packages/client/web-react/src/scoped-slots.tsx create mode 100644 packages/client/web-react/src/session-provider.tsx create mode 100644 packages/client/web-react/src/store/index.ts create mode 100644 packages/client/web-react/src/use-invoke.ts create mode 100644 packages/client/web-react/src/use-sync-external-store.d.ts create mode 100644 packages/client/web-react/tests/bind.spec.tsx create mode 100644 packages/client/web-react/tests/scoped-slots-real-core.spec.tsx create mode 100644 packages/client/web-react/tests/scoped-slots.spec.tsx create mode 100644 packages/client/web-react/tests/session-provider.spec.tsx create mode 100644 packages/client/web-react/tests/store.spec.ts create mode 100644 packages/client/web-react/tests/use-invoke.spec.tsx create mode 100644 packages/client/web-react/tsconfig.json create mode 100644 packages/client/web-react/tsdown.config.ts create mode 100644 packages/client/web/README.md create mode 100644 packages/client/web/package.json create mode 100644 packages/client/web/src/AppRoot.module.css create mode 100644 packages/client/web/src/AppRoot.tsx create mode 100644 packages/client/web/src/app.tsx create mode 100644 packages/client/web/src/base.css create mode 100644 packages/client/web/src/boot.tsx create mode 100644 packages/client/web/src/css-modules.d.ts create mode 100644 packages/client/web/src/index.ts create mode 100644 packages/client/web/src/invariant.ts create mode 100644 packages/client/web/src/seed.ts create mode 100644 packages/client/web/tests/app-root.spec.tsx create mode 100644 packages/client/web/tests/boot.spec.tsx create mode 100644 packages/client/web/tsconfig.json create mode 100644 packages/client/web/tsdown.config.ts create mode 100644 packages/host/apiproxy/README.md create mode 100644 packages/host/apiproxy/package.json create mode 100644 packages/host/apiproxy/src/api/approvals.schema.ts create mode 100644 packages/host/apiproxy/src/api/approvals.ts create mode 100644 packages/host/apiproxy/src/api/events.schema.ts create mode 100644 packages/host/apiproxy/src/api/events.ts create mode 100644 packages/host/apiproxy/src/api/host.schema.ts create mode 100644 packages/host/apiproxy/src/api/host.ts create mode 100644 packages/host/apiproxy/src/api/index.ts create mode 100644 packages/host/apiproxy/src/api/questions.schema.ts create mode 100644 packages/host/apiproxy/src/api/questions.ts create mode 100644 packages/host/apiproxy/src/api/rpc-map.ts create mode 100644 packages/host/apiproxy/src/api/rpc.schema.ts create mode 100644 packages/host/apiproxy/src/api/rpc.ts create mode 100644 packages/host/apiproxy/src/api/sessions.schema.ts create mode 100644 packages/host/apiproxy/src/api/sessions.ts create mode 100644 packages/host/apiproxy/src/fetch/client.ts create mode 100644 packages/host/apiproxy/src/fetch/handler.ts create mode 100644 packages/host/apiproxy/src/index.ts create mode 100644 packages/host/apiproxy/src/invariant.ts create mode 100644 packages/host/apiproxy/tests/client-handler.spec.ts create mode 100644 packages/host/apiproxy/tests/fetch-carrier.spec.ts create mode 100644 packages/host/apiproxy/tests/rpc-schemas.spec.ts create mode 100644 packages/host/apiproxy/tsconfig.json create mode 100644 packages/host/runtime/README.md create mode 100644 packages/host/runtime/package.json create mode 100644 packages/host/runtime/src/api-proxy.ts create mode 100644 packages/host/runtime/src/boot.ts create mode 100644 packages/host/runtime/src/index.ts create mode 100644 packages/host/runtime/src/invariant.ts create mode 100644 packages/host/runtime/src/start.ts create mode 100644 packages/host/runtime/src/web-plugins.ts create mode 100644 packages/host/runtime/tests/api-proxy-cold.spec.ts create mode 100644 packages/host/runtime/tests/api-proxy-view.spec.ts create mode 100644 packages/host/runtime/tests/host-runtime.spec.ts create mode 100644 packages/host/runtime/tests/web-plugins.e2e.ts create mode 100644 packages/host/runtime/tests/web-plugins.spec.ts create mode 100644 packages/host/runtime/tsconfig.json create mode 100644 packages/host/webserver/README.md create mode 100644 packages/host/webserver/package.json create mode 100644 packages/host/webserver/src/index.ts create mode 100644 packages/host/webserver/src/invariant.ts create mode 100644 packages/host/webserver/src/static.ts create mode 100644 packages/host/webserver/src/web-plugins.ts create mode 100644 packages/host/webserver/tests/invariant.spec.ts create mode 100644 packages/host/webserver/tests/web-plugins.spec.ts create mode 100644 packages/host/webserver/tests/webserver.spec.ts create mode 100644 packages/host/webserver/tsconfig.json create mode 100644 packages/ui/user-approval/src/types.ts create mode 100644 packages/ui/user-approval/tsdown.config.ts create mode 100644 packages/ui/user-interaction/src/types.ts create mode 100644 scripts/client-bundle-purity.spec.ts create mode 100644 scripts/verify-client-domain-graph.ts create mode 100644 tsconfig.client.json create mode 100644 tsconfig.vitest.json create mode 100644 vitest.web.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml new file mode 100644 index 0000000000..d3e5608c22 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-gui-layering-and-rpc-protocol.md: ebe21a6060ec69ba9807ab9fbf9906ae24b07823 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 0c256b60ce44a8e16ec6edfba146c776c4ae2129 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md new file mode 100644 index 0000000000..ebe21a6060 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -0,0 +1,253 @@ +# Agent Note: GUI layering and the RPC protocol — host/client layering by capability provider, the four-quadrant message model, and the fetch carrier + +Status: implemented + +English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) + +> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation (HTTP+SSE) is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md). + +## Problem + +We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities: + +- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation) +- Launching inside Electron with the same Web technology shape as `dsh web` + +That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. + +At the same time the physical channels differ per consumer (HTTP/SSE, in-process direct calls, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. + +## Decision + +### Layering + +Directories layer as follows: + +- `packages/host/*`: packages provide host-side capability only (representing the Node.js engineering core built on the existing harness plugin system), and additionally + - the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below +- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Two kinds live here: + - **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`): ordinary root-index packages, statically bundled into the shell and seeded into the browser plugin loader's module table. + - **dshClient plugin packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the entire implementation and its types live under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle), and cross-package consumption imports the `/client` form. `runtime` additionally exports `./loader` (the shell-held browser bundle loader — a loader cannot load itself). +- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. + - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP. + - A future Electron shape reuses the same web client packages over an IPC fetch carrier. + +``` +apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch) + │ consume + ▼ +packages/host/* packages/client/* + apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives + runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + webserver web-shape HTTP carriage client half = src/client/) + │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths + ▼ │ (type-only + the client base class) +harness core packages ──────────────────┘ (types reach the browser via import type) +``` + +Direction discipline (every rule auditable from package deps): + +- `runtime → apiproxy` is one-way; apiproxy depends only on type definitions. +- Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`). +- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency. +- Cross-package client imports use the `/client` subpath for plugin packages (a bare package name would inline a second runtime instance into a browser bundle; the tsdown purity gate rewrites or rejects it). + +TypeScript checks in **two aggregate programs** (`tsconfig.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs. + +On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect). + +#### Layer roles + +| Layer | Package | Responsibility | Key discipline | +|---|---|---|---| +| Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | +| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | +| Carrier layer | `dsh-host-webserver` | Web-shape HTTP: static serving + `/api/*`→handler forwarding + SSE write-out + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | +| Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | +| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture RFC | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | +| Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app | + +#### Naming rule + +Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the plugin packages' `/client` (and runtime's `/loader`) subpaths so source-level resolution matches the exports map. + +#### How to integrate a new shape (operational checklist) + +1. **Pick a fetch impersonation**: browser same-origin HTTP / in-process `host.handler.fetch` injection / your own transport-aspect subclass (e.g. future Electron IPC, see the "Subclass table" below). +2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. +3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. + +The two existing shapes are the template: `apps/cli/src/web.ts` (startHost + dist location + startWebServer + signal shutdown) and `headless.ts` (startHost + InProcessApiClient isomorphic direct calls, zero HTTP zero ports). ACP-class protocol bridges do not follow this checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch. + +## Message protocol + +The sections from here down are the protocol body carried by the front layer (`dsh-host-apiproxy`). The wire has exactly four message kinds (the four quadrants) — the Web carriage in the right column is only an example; swapping the carrier (in-process/IPC) leaves the quadrants unchanged: + +``` + client 发起 server 发起 + request ① ClientRequest ③ ServerRequest + (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + response ② ServerResponse ④ ClientResponse + (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) +``` + +### Wire full forms: a four-member named discriminated union (`api/rpc.ts`) + +| Type | Discriminant tag | Fields | rpcId ownership | Web carriage | +|---|---|---|---|---| +| `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mints | `POST /api/` body | +| `ServerResponse` | `'server-response'` | `rpcId` `result` | echoes ① | that POST's response body (always HTTP 200) | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | SSE `data:` line | +| `ClientResponse` | `'client-response'` | `rpcId` `result` | echoes ③ | `POST /api/respond` body | + +`RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`, narrowed via `switch (message.type)`. + +**rpcId discipline** (`RpcId` is a branded string with constructor `RpcId()`): + +- Whoever initiates mints; a response always echoes the corresponding request's rpcId and **never mints a new id**. +- server-requests split into two kinds, distinguished statically by `method` (= the frame type), with **no third kind**: answerable frames (`approval/requested`, `question/requested`) carry a stable logical request id (minted once on acceptance, reused verbatim on baseline replay, echoed by the client's answer); pure-push frames (`session/event` etc.) carry an rpcId identifying that one push (freshly minted each time). +- Business code never mints: unary minting funnels into the client base class `callUnary`, frame minting funnels into the host side. + +### Signature narrow forms and carrier completion + +Domain interface signatures perceive only the narrow forms: `RpcRequest

= { rpcId, payload }`, `RpcResponse = { rpcId, result: RpcResult }`. The carrier layer completes narrow forms into full forms (adding the `type` tag and `method`); direction is never inferred from the channel. `RpcResult = { ok: true; value } | { ok: false; error: RpcError }` — methods do not throw business errors. + +### RpcReceipt: the carrier receipt + +The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence surface is the `*/resolved` frames. + +## The type system: signatures are the source of truth + +### RpcMethodMap and derived generics (`api/rpc-map.ts`) + +Method parameter/return structures **live only in the interface method signatures**; the map registers the methods themselves; every other position (handler, client, store, tests) references the derived generics — copying literals or introducing flat named types is banned: + +```ts ignore-check +export interface RpcMethodMap { + 'session.list': SessionsApi['list'] // map key 即 wire 路径段 + // …其余方法同形登记,全集见 api/rpc-map.ts +} +// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束) +export type RequestPayload = Parameters[0]['payload'] +export type ResponseValue = + Awaited> extends RpcResponse ? T : never +``` + +Stream methods (`events.mux`/`events.host`) stay out of the map (not unary); `respond` stays out of the map (it is a client-response, not a method call). + +### The error model (`RpcErrorDetailsMap`) + +One example row of an error code: + +| code | details | when | +|---|---|---| +| `bad-request` | `{ issues: ZodIssue[] }` | wire/payload zod validation failed | + +The full code set is `RpcErrorDetailsMap` in `api/rpc.ts`. `RpcError` is the distributive union expanded from the map: `code` discriminates, `details` narrows automatically after a `switch`; **details is required** — a new code = one map row + one error-schema branch, and omission is a compile error. Transport failures (network down, host not up) are thrown by the carrier as exceptions; the two layers never mix. + +### Bidirectional zod validation and anchoring + +- **Two-level parse**: the full-form schema once (type/rpcId/method structure + the handler checking path==method) → the business payload dispatched by method/frame type for a second parse; rejection = `bad-request`. +- **Anchoring**: schemas uniformly `satisfies z.ZodType>` (`api/rpc.schema.ts`). `Wire` is a deep "| undefined" widening — the repo enables `exactOptionalPropertyTypes` while zod `.optional()` outputs `T | undefined`, so anchoring the original type is unusable across the board; on the JSON wire, absence and undefined are indistinguishable, so the widening loses no validation semantics. Passthrough wide branches (`SessionEvent`/`ContentBlock`/frame unions/`RpcError`) and brand-id schemas use explicit casts with comments. +- Brand casts have one point each: every schema file funnels its id cast into one place (`rpcIdSchema` is the only cast point in rpc.schema.ts). + +## The contract face (ApiProxy) + +The root interface is `ApiProxy = { sessions, host, events, respond }` (`api/index.ts`). A new client-request domain = one new file pair (`.ts` + `.schema.ts`) + one root-interface field + one map row. + +### The unary method table + +One example row (the table structure is the reading key): + +| method key | request payload | return value | semantics | +|---|---|---|---| +| `session.list` | `{ cursor?: string }` (cursor is a reserved seat, unimplemented) | `{ items: SessionSummary[] }` | persisted sessions, updatedAt descending; v1 builds no index | + +The remaining methods (`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`. + +### Frames (server→client, named unions) + +Two SSE streams: the mux stream (`GET /api/events.mux`, all-session aggregate) and the host stream (`GET /api/events.host`, host-level events). One example frame row: + +| frame type | payload | when | +|---|---|---| +| `session/event` | `{ sessionId; event: SessionEvent }` | core passthrough: core events pass verbatim, `assistant/chunk` IS the token stream, no separate delta frame | + +The remaining frame types are not re-copied here; the full unions are `MuxFrame`/`HostFrame` in `api/events.ts`. Three semantic points to know: `session/subscribed` carries lastSeq for history seam-race detection; the `approval/question` requested frames are answerable (stable rpcId) and the resolved frames are the convergence surface; `host/agent-error` is the only outlet for live failures with no turn position. + +**Passthrough discipline**: events/messages/content blocks on the wire ARE the core types (`SessionEvent`/`ContentBlock`) — no second DTO set; types reach the browser through the `import type` dependency chain. `SessionEventMap` is merge-extensible: the client applies its documented default (ignore) to unknown types, and the event schema keeps a "valid envelope + unknown type" branch — the envelope stays strict; this is not field-level passthrough. + +### Session semantics (impl-side commitments) + +- **History = event replay**: one fold (client side); history pagination and live increments share one code path; the server maintains no second materialized-snapshot system. History **page boundaries align to message boundaries** (never cut mid-message; chunks group with their finalized message), and the tail page includes the in-flight partial's chunks. +- **Prompt correlation**: the prompt's rpcId rides MessageSource (`'user-rpc'`) into the `user/message` event; the client uses it to promote the optimistic echo. +- **Reconnect = rebuild**: no resume cursor (`mux`'s `since` signature is a reserved seat, ignored if passed); on disconnect reopen the stream + refetch history; compare `subscribed.lastSeq` with the history tail seq and backfill once if there is a seam. +- **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it). +- **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only. +- **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears. +- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. + +## The client carrier: the AbstractApiClient class family (`fetch/client.ts`) + +**Protocol invariants live in the base class; platform differences are two aspects**: the abstract method `doFetch(url, init)` (transport) + the overridable `onEnvelope` (observation). + +### IApiClient: the caller view + +The same domain tree as `ApiProxy`, but unary methods **take the business payload directly** — the carrier mints the rpcId and wraps the envelope; business code never mints, and code needing this call's rpcId reads it from the returned `RpcResponse` echo. `ApiProxy` is the narrow-form signature contract the impl side implements; `IApiClient` is the payload-direct view clients consume; `AbstractApiClient` bridges the two. Methods derive per key from `RpcMethodMap` — a map row addition updates them mechanically. + +### Protocol paths held by the base class + +| Path | Content | +|---|---| +| `callUnary` | mint → tap → POST full form → `serverResponseSchema` parse → **rpcId echo check** (mismatch throws) → tap → emit narrow form | +| `readSse` | streaming fetch (not EventSource), `\n\n` framing, `data:` concatenation, ServerRequest full-form parse, tap, emit narrow `RpcRequest` | +| `respond` | client-response passthrough (rpcId is an echo — never minted here); response body parsed by `rpcReceiptSchema` | +| unary timeout | `AbortSignal.timeout` (default 30s, constructor-tunable); streams have no timeout (long-lived by nature) | +| `resolveBase` | browser = same-origin origin; no-location environment (Node) = the `http://dsh.internal` fake authority | + +### The instance-level envelope observation aspect + +All four quadrant full forms pass through `onEnvelope`; the base implementation is an **instance-owned microtask-batched buffer** (frame storms must not disturb consumers per frame; module-level state would leak across instances/tests, hence instance-owned). Observers subscribe via `subscribeEnvelopes(listener)` (receiving whole batches as `readonly RpcMessage[]`, returning an unsubscribe function); a listener throw is isolated (observation must never bite the carrier). With no subscribers the buffering costs nothing. No shipped consumer subscribes today — the aspect is the designated seat for wire diagnostics (the retired RPC debug panel was its first consumer, and a future one plugs in without touching the carrier). + +### The subclass table (transport carriage) + +| Subclass | Package | doFetch | Purpose | +|---|---|---|---| +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` (same-origin `/api/*`) | the browser shape; HTTP+SSE carriage details in the web client architecture RFC | +| `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | +| (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | + +## How to extend (operational checklists) + +**Add a unary method (5 steps)**: ① add the method signature to the domain interface (parameters/return inline — this is the single source of truth); ② add one `RpcMethodMap` row; ③ add the request/value schema pair in `.schema.ts` (anchored `Wire>`); ④ add one handler `UNARY_ROUTES` row (the handler's Web carriage is in the web client architecture RFC); ⑤ implement in the impl (echo `request.rpcId`). On the client side, add the passthrough row to the `IApiClient`/`AbstractApiClient` domain method tables. + +**Add a frame type (3 steps)**: ① add a branch to the `MuxFrame`/`HostFrame` union (answerable frames must note the stable-rpcId semantics); ② add a frame-schema branch; ③ the consumers' fold/routing documented-default already covers unknown types — add an explicit branch as needed. + +**Add an error code (2 steps)**: ① add one `RpcErrorDetailsMap` row (details required); ② add one `rpcErrorSchema` discriminatedUnion branch. + +**Plug in a new carrier**: subclass `AbstractApiClient` implementing only `doFetch`; to intercept at the protocol layer (like the fixture), override the `callUnary`/`openMux`/`openHost` virtuals instead. Contract and base class stay unchanged. + +**Promote a reserved seam**: copy the reserved signature into the domain interface → add the map row → add the schema pair → add the UNARY_ROUTES row → implement. + +## Consequences + +Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. The accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. + +## Alternatives considered + +| Rejected | One-line reason | +|---|---| +| Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages | +| A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable | +| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | A second command plane bypasses the contract, losing wire validation/observability/multi-client consistency; ctx keeps exactly two formal uses — front doors and headless event subscription | +| webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer | +| Package names without the group prefix (continuing dsh-) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package | +| Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention | +| A three-envelope model (Request/Response/Frame envelopes, signatures direction-blind) | rpcId correlation is logical-layer; frame and response direction semantics inferred from the channel break the moment the carrier changes | +| Named Request/Response type pairs as the source of truth (map registering type pairs) | Flat named types are a second name for the same fact; signature inference makes adding a method a one-place change | +| REST-style paths | The consumer is our own client with no third-party REST expectations; RPC mapping straight onto the method table is more mechanical | +| A DTO layer (a second wire-only structure set) | Core types reach the browser type-only at zero cost; a DTO is a permanent two-way synchronization tax | +| Cursor resumption (implementing mux since) | Reconnect = rebuild (opencode-style) covers all v1 needs; the signature keeps the seat, implementation waits for a real consumer | +| A createApiClient factory function (the original implementation) | Platform differences (transport/observation) are inheritance aspects, not parameters; the class family lets the fixture substitute at the protocol layer instead of wrapping a fake envelope | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md new file mode 100644 index 0000000000..0c256b60ce --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -0,0 +1,251 @@ +# RFC: GUI 分层与 RPC 协议——host/client 按能力支持方分层、四象限消息模型与 fetch 载体 + +Status: implemented + +[English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 + +> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现(HTTP+SSE)见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。 + +## Problem + +需要提供 UI 对接层,除已有 ACP/stdio基础版本外,还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持: +- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留) +- 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动 + +那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 + +同时各消费端的物理通道不同(HTTP/SSE、进程内直调、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 + +## Decision + +### 分层 + +目录按照如下分层: +- `packages/host/*`: 包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含 + - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 +- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住两类包: + - **纯库**(`ui-slots`、`web-react`、`ui-primitives`):普通根入口包,静态打包进壳,并播种进浏览器插件 loader 的模块表。 + - **dshClient 插件包**(`connection`、`runtime`、`ui-theme`、`i18n`、`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现与类型全部住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle),跨包消费一律 import `/client` 形式。`runtime` 额外导出 `./loader`(壳持有的浏览器 bundle loader——loader 加载不了自己)。 +- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 + - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。 + - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 + +``` +apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch) + │ consume + ▼ +packages/host/* packages/client/* + apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives + runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + webserver web-shape HTTP carriage client half = src/client/) + │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths + ▼ │ (type-only + the client base class) +harness core packages ──────────────────┘ (types reach the browser via import type) +``` + +方向纪律(每条都由包 deps 可核): + +- `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。 +- client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。 +- `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。 +- client 侧跨包 import 插件包一律走 `/client` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundle;tsdown 纯度门禁会改写或拒收)。 + +TypeScript 以**两个聚合 program** 检查(`tsconfig.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用。 + +协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 + +#### 分层角色 + +| 层 | 包 | 职责 | 关键纪律 | +|---|---|---|---| +| 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | +| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | +| 承载层 | `dsh-host-webserver` | Web 形态 HTTP:静态服务 + `/api/*`→handler 转发 + SSE 写出 + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | +| client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | +| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构 RFC | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | +| 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app | + +#### 命名规则 + +`packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且插件包的 `/client`(以及 runtime 的 `/loader`)子路径要单列条目,使源码级解析与 exports map 一致。 + +#### 怎么接入一个新形态(操作清单) + +1. **选 fetch 伪造方式**:浏览器同源 HTTP / 进程内 `host.handler.fetch` 注入 / 自写传输切面子类(如将来 Electron IPC,见下文「子类表」)。 +2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 +3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 + +现有两形态即模板:`apps/cli/src/web.ts`(startHost + dist 定位 + startWebServer + 信号停机)与 `headless.ts`(startHost + InProcessApiClient 同构直调,零 HTTP 零端口)。ACP 类协议桥不走本清单:它把 core 暴露给外部生态,直接 `ctx.plugin(前门插件)` 挂载、不套 fetch。 + +## 消息协议 + +以下各节是前置层(`dsh-host-apiproxy`)承载的协议本体。wire 上只有四种消息(四象限)——右列的 Web 承载只是示例,换载体(进程内/IPC)时四象限不变: + +``` + client 发起 server 发起 + request ① ClientRequest ③ ServerRequest + (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + response ② ServerResponse ④ ClientResponse + (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) +``` + +### wire 全形:四具名判别 union(`api/rpc.ts`) + +| 类型 | 判别 tag | 字段 | rpcId 归属 | Web 承载 | +|---|---|---|---|---| +| `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mint | `POST /api/` body | +| `ServerResponse` | `'server-response'` | `rpcId` `result` | 回填 ① | 该 POST 的应答体(恒 HTTP 200) | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | SSE `data:` 行 | +| `ClientResponse` | `'client-response'` | `rpcId` `result` | 回填 ③ | `POST /api/respond` body | + +`RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`,`switch (message.type)` 窄化。 + +**rpcId 纪律**(`RpcId` 是 branded string,构造函数 `RpcId()`): + +- 谁发起谁 mint;应答一律回填对应 request 的 rpcId,**绝不 mint 新 id**。 +- server-request 分两类,静态按 `method`(=帧 type)区分,**不设第三种 kind**:可应答帧(`approval/requested`、`question/requested`)的 rpcId 是稳定逻辑请求 id(受理时 mint 一次、基线重放原样复用、client 以它回填应答);纯推送帧(`session/event` 等)的 rpcId 标识该次推送(每次新 mint)。 +- 业务代码不 mint:unary 的 mint 收口在客户端基类 `callUnary`,帧的 mint 收口在 host 侧。 + +### 签名窄形与载体补全 + +域接口签名只感知窄形:`RpcRequest

= { rpcId, payload }`、`RpcResponse = { rpcId, result: RpcResult }`。载体层把窄形补全为全形(补 `type` tag 与 `method`),方向不靠通道推断。`RpcResult = { ok: true; value } | { ok: false; error: RpcError }`——方法不 throw 业务错误。 + +### RpcReceipt:载体回执 + +`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛面是 `*/resolved` 帧。 + +## 类型体系:函数签名即事实源 + +### RpcMethodMap 与派生泛型(`api/rpc-map.ts`) + +方法的参数/返回结构**只住在接口方法签名里**;map 登记方法本身;其余一切位置(handler、client、store、测试)引用派生泛型,禁止复写字面量或另起平铺具名类型: + +```ts ignore-check +export interface RpcMethodMap { + 'session.list': SessionsApi['list'] // map key 即 wire 路径段 + // …其余方法同形登记,全集见 api/rpc-map.ts +} +// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束) +export type RequestPayload = Parameters[0]['payload'] +export type ResponseValue = + Awaited> extends RpcResponse ? T : never +``` + +流方法(`events.mux`/`events.host`)不进 map(不是 unary);`respond` 不进 map(是 client-response 不是方法调用)。 + +### 错误模型(`RpcErrorDetailsMap`) + +错误码示例一行: + +| code | details | 何时 | +|---|---|---| +| `bad-request` | `{ issues: ZodIssue[] }` | wire/payload zod 校验失败 | + +码全集见 `api/rpc.ts` 的 `RpcErrorDetailsMap`。`RpcError` 是 map 展开的分布式 union:`code` 判别、`switch` 后 `details` 自动窄化;**details 必填**——新码=map 加一行+错误 schema 加一支,漏填是编译错误。transport 故障(断网、host 没起)由载体抛异常,与业务错误两层不混。 + +### zod 双向校验与锚定 + +- **两级 parse**:全形 schema 一次(type/rpcId/method 结构 + handler 校验 path==method)→ 业务 payload 按 method/帧型分派二次 parse;拒收 = `bad-request`。 +- **锚定**:schema 统一 `satisfies z.ZodType>`(`api/rpc.schema.ts`)。`Wire` 是深度「| undefined」宽化——仓库开 `exactOptionalPropertyTypes` 而 zod `.optional()` 输出 `T | undefined`,直接锚原类型全线不可用;JSON wire 上缺席与 undefined 同形,宽化不损失校验语义。透传宽分支(`SessionEvent`/`ContentBlock`/帧 union/`RpcError`)与 brand id schema 用显式 cast + 注释。 +- brand cast 单点:每个 schema 文件的 id cast 收口一处(`rpcIdSchema` 是 rpc.schema.ts 唯一 cast 点)。 + +## 契约面(ApiProxy) + +根接口 `ApiProxy = { sessions, host, events, respond }`(`api/index.ts`)。新 client-request 域 = 新的一对文件(`<域>.ts` + `<域>.schema.ts`)+ 根接口一个字段 + map 加行。 + +### unary 方法表 + +方法示例一行(表结构即读法): + +| method key | 请求 payload | 返回 value | 语义 | +|---|---|---|---| +| `session.list` | `{ cursor?: string }`(cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 session,updatedAt 倒序;v1 不建索引 | + +其余方法(`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。 + +### 帧(server→client,具名 union) + +两条 SSE 流:mux 流(`GET /api/events.mux`,全 session 聚合)与 host 流(`GET /api/events.host`,host 级事件)。帧示例一行: + +| 帧 type | 载荷 | 何时发 | +|---|---|---| +| `session/event` | `{ sessionId; event: SessionEvent }` | 核心透传:core 事件原样过,`assistant/chunk` 即 token 流,无独立 delta 帧 | + +其余帧型不在此复写,union 全集见 `api/events.ts` 的 `MuxFrame`/`HostFrame`。语义上须知三点:`session/subscribed` 的 lastSeq 供 history 补缝竞态检测;`approval/question` 的 requested 帧可应答(rpcId 稳定)、resolved 帧是收敛面;`host/agent-error` 是无 turn 位置 live 失败的唯一出口。 + +**透传纪律**:wire 上的事件/消息/内容块就是 core 类型(`SessionEvent`/`ContentBlock`),不造第二套 DTO;类型经 `import type` 依赖链直达浏览器。`SessionEventMap` merge-extensible:client 对未知 type documented-default(忽略),事件 schema 留「合法信封+未知类型」分支——信封仍严格,不是字段级 passthrough。 + +### 会话语义(impl 侧承诺) + +- **历史 = 事件重放**:一套 fold(client 侧),历史分页与 live 增量同一条代码路径;server 不做物化快照第二套。history **页边界对齐消息边界**(绝不从消息中间截断;chunk 随定稿消息归组),尾页含进行中 partial 的 chunk。 +- **prompt 关联**:prompt 的 rpcId 经 MessageSource(`'user-rpc'`)透传进 `user/message` 事件,client 以此把乐观回显转正。 +- **重连 = 重建**:不做续传 cursor(`mux` 的 `since` 签名留座、传了忽略);断线重开流 + 重拉 history;`subscribed.lastSeq` 与 history 尾 seq 比对,有缝再补拉一次。 +- **冷 session 隐式 resume**:`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume,并发触发用在途表去重;attach 与否不对客暴露(`running` 已覆盖)。 +- **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。 +- **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。 +- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。 + +## 客户端载体:AbstractApiClient 类体系(`fetch/client.ts`) + +**协议不变量住基类,平台差异是两个切面**:抽象方法 `doFetch(url, init)`(传输)+ 可覆写 `onEnvelope`(观测)。 + +### IApiClient:caller 视图 + +与 `ApiProxy` 同域树,但 unary 方法**收业务 payload 直传**——载体 mint rpcId 并包信封,业务代码永不 mint;需要本次调用 rpcId 的从返回的 `RpcResponse` 回显里读。`ApiProxy` 是 impl 侧实现的窄形签名契约,`IApiClient` 是 client 侧消费的 payload 直传视图,`AbstractApiClient` 桥接两者。方法逐 key 从 `RpcMethodMap` 派生——map 加行即机械更新。 + +### 基类持有的协议路径 + +| 路径 | 内容 | +|---|---| +| `callUnary` | mint → tap → POST 全形 → `serverResponseSchema` parse → **rpcId 回显校验**(不符即 throw)→ tap → 吐窄形 | +| `readSse` | streaming fetch(非 EventSource)、`\n\n` 分帧、`data:` 拼接、ServerRequest 全形 parse、tap、吐窄形 `RpcRequest<帧>` | +| `respond` | client-response 透传(rpcId 是回填,此处不 mint);应答体 `rpcReceiptSchema` parse | +| unary 超时 | `AbortSignal.timeout`(默认 30s,构造参数可调);流不设超时(长连接本性) | +| `resolveBase` | 浏览器=同源 origin;无 location 环境(Node)=`http://dsh.internal` 假 authority | + +### 实例级 envelope 观测切面 + +四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费者;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费者订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费者,将来的诊断消费者接入时不动载体)。 + +### 子类表(传输承载) + +| 子类 | 所在包 | doFetch | 用途 | +|---|---|---|---| +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch`(同源 `/api/*`) | 浏览器形态;HTTP+SSE 承载落地见 Web 客户端架构 RFC | +| `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | +| (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 | + +## 怎么扩展(操作清单) + +**加一个 unary 方法(5 步)**:①域接口加方法签名(参数/返回内联,这是唯一事实源);②`RpcMethodMap` 加一行;③`<域>.schema.ts` 加 request/value schema 对(锚 `Wire>`);④handler `UNARY_ROUTES` 加一行(handler 的 Web 承载见 Web 客户端架构 RFC);⑤impl 实现(回显 `request.rpcId`)。client 侧 `IApiClient`/`AbstractApiClient` 的域方法表同步加一行透传。 + +**加一个帧型(3 步)**:①`MuxFrame`/`HostFrame` union 加一支(可应答帧须注明 rpcId 稳定语义);②帧 schema 加一支;③消费端 fold/路由的 documented-default 已兜底未知型,按需加显式分支。 + +**加一个错误码(2 步)**:①`RpcErrorDetailsMap` 加一行(details 必填);②`rpcErrorSchema` discriminatedUnion 加一支。 + +**接一种新载体**:继承 `AbstractApiClient` 只实现 `doFetch`;需要拦截协议层(如 fixture)再覆写 `callUnary`/`openMux`/`openHost` 虚方法。契约与基类零改。 + +**升格一个预留接缝**:把预留签名抄进域接口 → map 加行 → schema 加对 → UNARY_ROUTES 加行 → impl 实现。 + +## Consequences + +所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 + +## Alternatives considered + +| 放弃项 | 一句话理由 | +|---|---| +| 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 | +| 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | +| 消费型 client 直连 ctx(省 apiproxy 一层) | 第二命令面绕开契约,wire 校验/观测/多端一致性全失;ctx 只留给前门与 headless 事件订阅两个正式用途 | +| webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | +| 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | +| 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、契约双份人肉对齐、命名无 convention 自然漂移 | +| 三信封模型(Request/Response/Frame 各一信封,签名不感知方向) | rpcId 是逻辑层关联,帧与应答的方向语义靠通道推断在换载体时即失效 | +| 具名 Request/Response 类型对为事实源(map 登记类型对) | 平铺具名类型是同一事实的第二个名字;签名 infer 反推让加方法只改一处 | +| REST 风格路径 | 消费者是自家 client,无第三方 REST 体验诉求;RPC 直映方法表更机械 | +| DTO 层(wire 专用第二套结构) | core 类型 type-only 直达浏览器零成本;DTO 是永久的双向同步税 | +| cursor 续传(mux since 实装) | 重连=重建(opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者 | +| createApiClient 工厂函数(原实现) | 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封 | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml new file mode 100644 index 0000000000..91abd32a3e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-gui-web-client-architecture.md: 58320570f752d4259004172d3b4527172c2cc646 +2026-07-19-gui-web-client-architecture.zh.md: 744fdaa4b89a01e2710f85b177228713189e3025 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md new file mode 100644 index 0000000000..58320570f7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -0,0 +1,148 @@ +# Agent Note: Web client architecture — the client cordis plugin tree, the slot system, and the React-free object layer + +Status: implemented + +English | [中文](2026-07-19-gui-web-client-architecture.zh.md) + +> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol RFC](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. + +## Problem + +Two forces shape the browser client. First, streaming: in an event-driven conversation UI, if business state (the event window, streaming accumulation, pending interactions, the connection state machine) scatters across React components and a global store, every token chunk shakes the render tree, and swapping the UI library means rewriting the business logic. Second, modularity: UI features (layout, sidebar, conversation, theme, locale) must be independently loadable plugins — composed at runtime from a host-served manifest, not compiled into one bundle — without giving up compile-time type safety across plugin boundaries. + +## Decision + +Both ends run cordis. The host is a cordis plugin tree; the browser runs a second, client-side cordis tree whose every UI capability is a plugin loaded dynamically by a shell-held loader. Inside that tree, cordis ctx hosts all runtime facts (services, stores, session scopes) and React is pure projection: components import nothing from the framework, receive everything through props, and subscribe to immutable snapshots via `useSyncExternalStore` (uSES below). + +``` +┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐ +│ sessions/agents/SessionLog │ │ client cordis root ctx │ +│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ loader(壳静态持有,不能经自己装载) │ +│ webserver: │ │ ├ immediately 先行组: connection/runtime/ │ +│ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(动态 bundle,并行先装) │ +│ └ GET / 注入 __DSH_BOOT__ │ │ ├ 后续组: layout/sidebar/conversation/trajectory │ +└────────────────────────────────┘ │ └ session scope ×N(观看驱动,惰性建) │ + │ React: loading 页 → settled → 整 UI 一次成型 │ + └────────────────────────────────────────────────────┘ +``` + +## The client cordis tree and the loading chain + +Every UI plugin is simultaneously a host plugin (dual-entry package): the node half sits in the host's plugin tree so the host Loader governs its lifecycle, and the browser half is a tsdown closure bundle under the package's `exports["./client"]`. The host webserver derives the boot manifest from loaded plugins carrying a `dshClient` manifest field and injects it into the page as `window.__DSH_BOOT__` — the HTML alone tells the browser everything to fetch, zero extra round trips. + +The loading chain, end to end: + +1. `GET /` → the shell boots, mounts `ctx.loader` (the loader mechanism is held statically by the shell — a loader cannot load itself; its code home is `packages/client/runtime/src/client/loader/`, imported through the `./loader` subpath so the shell bundle does not swallow the rest of the runtime package), seeds the require module table with the pure-library instances (react, react-dom, cordis, ui-slots, web-react, ui-primitives), and renders a plugin-independent loading page. +2. `loader.start()` reads `__DSH_BOOT__`. Entries flagged `immediately` form the early-load group (connection, runtime, ui-theme, i18n): fetched in parallel, applied in intra-group `inject` topological order, and **the whole group must land before anything else loads**. Remaining plugins then load in inject order. +3. Each bundle executes `window.DSHClientProxy.loadPlugin({ id, factory })`. The loader calls `factory(require)` — bundles are closure factories whose external dependencies arrive through the injected `require`, resolved against the module table (no globals, no import maps; an unresolvable specifier fails loud). The factory returns its module export surface (including the cordis `apply`); the loader runs `ctx.plugin(apply)`, then **registers that export surface into the module table under the package name**, so inject topology guarantees later plugins can `require` earlier ones. Plugin CSS is inlined in the bundle and injected as `