Merge remote-tracking branch 'origin/master' into xtr/sidebar-workspace-controls

This commit is contained in:
_Kerman
2026-08-11 18:59:11 +08:00
436 changed files with 2466 additions and 15774 deletions

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
2026-07-10-single-file-executable-sdk-runtime-distribution.md: fd39d8c10d7f76003c93b6fa45a3b0211cead23e
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7758a77083e07b1d2cac99ae2be3f15e6edd2dc
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 01081f1e0b8027420fedbc599f99c67e67a4639b
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: fadfc8cdcb0651665b965126ab0b25ed4218a533

View File

@@ -23,11 +23,11 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h
Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former.
### The serving interface is a plugin: the two packages ui/jsonrpc + examples/jsonrpc-demo
### The serving interface is a plugin: the two packages sdk/server + examples/jsonrpc-demo
The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin:
- [`packages/scaffold/server`](../../../../packages/scaffold/server/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
- [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130).
Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic.

View File

@@ -23,11 +23,11 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)vercel/pkg 归档后
术语提醒pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」ACP 回放预期输出、`$DSH_SNAPSHOT`无关本文用「VFS」指前者。
### 对外服务接口也是插件:ui/jsonrpc + examples/jsonrpc-demo 两个包
### 对外服务接口也是插件:sdk/server + examples/jsonrpc-demo 两个包
确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件:
- [`packages/scaffold/server`](../../../../packages/scaffold/server/README.md)`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose资源释放让待处理的持久化操作完成再调用 `exit(0)`HMR 式卸载只停止服务,不退出进程)。
- [`packages/sdk/server`](../../../../packages/sdk/server/README.md)`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose资源释放让待处理的持久化操作完成再调用 `exit(0)`HMR 式卸载只停止服务,不退出进程)。
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)`@deepseek-ai/dsh-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()``boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有stdin EOF/SIGTERM → dispose 后返回 0SIGINT → 130
配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
2026-07-23-client-plugin-loading-model.md: 860186294059facf17d1eba38423092acf7a4a7d
2026-07-23-client-plugin-loading-model.zh.md: 68f2e70253485d4e215c981d3b338d5046390247
2026-07-23-client-plugin-loading-model.md: 3347bdac95eb8e06be3e7c20d24319103f738149
2026-07-23-client-plugin-loading-model.zh.md: d52409e167b536162a8efb9a9ecc3092f6e5d1d2

View File

@@ -31,7 +31,7 @@ What makes a package a plugin? One rule: **a package is a plugin package once it
- **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots, web-react, ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph.
- **Plugin packages** are everything else. Each one carries a `dsh.client` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory.
The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster and the `--dev` switch.
The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster.
To add a plugin package: declare `dsh.client`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands.
@@ -66,7 +66,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
**Host side — compose the graph.**
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is served as a script resource at `/plugins/<id>/client.js?rev=…`, with its source map at the same path plus `.map`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
@@ -84,7 +84,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
### Hot reload: one driver plugin, self-watched bundles
Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither.
Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
@@ -117,12 +117,12 @@ The support boundary, stated honestly. Reload is coarse by design: fresh fiber,
| `dsh-client-runtime` | session object layer + slots service + store engine | plugin, declares `immediately` | keeps shrinking toward a pure session object layer |
| `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`, plus the `./styles/*` source channel | Theme Registry (separate ruling) |
| `dsh-client-i18n` | I18nService | plugin, declares `immediately` | per-deployment locale composition |
| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately`; dev graphs only | rollback; reconnect handshake |
| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately` | rollback; reconnect handshake |
| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI features | plugins, on-demand | conversation domain split; trajectory real implementation |
## Consequences
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster and the `--dev` switch. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch appears at the settled sweep, not at graph validation; the three not-yet-promoted libraries keep their static-import exports until their DI conversions land; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch.

View File

@@ -31,7 +31,7 @@ host 侧cordis 插件装载站在 Node 的模块机制之上——require cac
- **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库react 家族、cordis、`@deepseek-ai/dsh-client-modules`模块系统本身——它永远不可能是插件因为模块先于一切模块、web 壳内核以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。
- **插件包**是其余一切。每个都携带 `dsh.client` manifest元数据清单声明`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js``exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括connection、runtime、ui-theme、i18n、hmr仅进 dev 图、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。
manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy。负责组合的 app 只拥有名册`--dev` 开关
manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy。负责组合的 app 只拥有名册。
新增一个插件包:声明 `dsh.client`,经共享预设产出 `./client` bundle把包名加进负责组合的 app 的名册。除此之外无需任何交接。
@@ -66,7 +66,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
**host 侧——组合这张图。**
1. 负责组合的 app`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr`,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack[host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
1. 负责组合的 app`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack[host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__``{ rev, entries: [{ id, url, rev, inject?, immediately? }] }``inject` 边与 `immediately` 标记都来自 manifest永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下畸形声明字段同样会让激活失败host 检查会从 FAILED fiber 报告这两类错误。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries包元数据含「非 client 包」的否定结论按名永久缓存bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都作为脚本资源供给:`/plugins/<id>/client.js?rev=…`,对应 sourcemap 位于同一路径加 `.map`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知它是朴素路由注册插件bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
@@ -84,7 +84,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
### 热重载:一个驱动插件,自行监视的 bundle
热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSEServer-Sent Events通道prod 组合不挂载,两者皆无
热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSEServer-Sent Events通道没有重建 watcher 改写客户端 bundle 时链路保持空闲。不得暴露它的组合可在 patch 层禁用该行
重建好的 bundle 怎么变成重载信号hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire永不进会话日志。轮询是刻意选择inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500msdispose资源释放会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
@@ -117,12 +117,12 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
| `dsh-client-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 |
| `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry另行裁定 |
| `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 |
| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately`;仅进 dev 图 | 回滚;重连握手 |
| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately` | 回滚;重连握手 |
| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI 功能 | 插件,按需到达 | conversation 域拆分trajectory 真实现 |
## Consequences
wire 两侧跑着同一份治理实现浏览器特有层只包含一套模块系统和一个重载插件。插件包只有一种形态纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住负责组合的 app 只握名册`--dev` 开关。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
wire 两侧跑着同一份治理实现浏览器特有层只包含一套模块系统和一个重载插件。插件包只有一种形态纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住负责组合的 app 只握名册。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
接受的代价vendored Loader 在浏览器里背着闲置机件EntryTree 持久化是 no-op分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面;每个 bundle 多出一份 sourcemap 产物,外部脚本失败也只能给出粗粒度的 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md
2026-07-24-single-harness-home-resolver.md: 1e128911dce0b50dc95722c6edd82cb322d18f3d
2026-07-24-single-harness-home-resolver.zh.md: 258a16e6ca139da191db564aae40d23d05ca46b7
2026-07-24-single-harness-home-resolver.md: 27db7e23b1ae3d2c9a05259bfb3047591589627d
2026-07-24-single-harness-home-resolver.zh.md: 5eec850c9c7ecc70b2327eacca422662ec39a9cc

View File

@@ -6,13 +6,12 @@ English | [中文](2026-07-24-single-harness-home-resolver.zh.md)
## Problem
The harness had three inconsistent conventions for "where does DeepSeek Harness user data live":
The harness had two inconsistent conventions for "where does DeepSeek Harness user data live":
- `@deepseek-ai/dsh-home` resolved `configured ?? $DSH_HOME ?? ~/.dsh`.
- `@deepseek-ai/dsh-paths` shipped a **second** `resolveDshHome` with the same precedence plus tilde expansion — a near-duplicate of `dsh-home` that no gate flagged because the two lived in different packages and had already drifted (only one expanded tildes).
- `@deepseek-ai/dsh-telemetry`'s `globalConfigDir` used a *different* policy entirely: `DSH_CONFIG_HOME > $XDG_CONFIG_HOME/deepseek-harness > %APPDATA%/deepseek-harness > ~/.config/deepseek-harness`.
So most of the product parked everything under one `~/.dsh` root while telemetry alone stored its anonymous id elsewhere, under a `deepseek-harness` namespace that contradicts the repo-wide `dsh` shorthand (`DSH_HOME`, `@deepseek-ai/dsh-*`, `~/.dsh`). Two resolvers plus a divergent third policy means no single home fact.
Two resolvers for the same cross-cutting fact meant there was no single home policy.
## Decision
@@ -22,20 +21,18 @@ One resolver owns the harness home, in `@deepseek-ai/dsh-paths`, single-root:
explicit configured path > $DSH_HOME > ~/.dsh
```
An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check.
An empty or whitespace-only `$DSH_HOME` is treated as unset; otherwise `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check.
`@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) now import `resolveDshHome` from `dsh-paths`. `dsh-telemetry`'s `globalConfigDir` delegates to `resolveDshHome`, dropping its second resolver, the `DSH_CONFIG_HOME` override, the XDG/`%APPDATA%` branches, and the `deepseek-harness` namespace; the anonymous id now lives directly under the harness home.
`@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) import `resolveDshHome` from `dsh-paths`.
`dsh-telemetry` and its separate home policy are absent under the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md), leaving this resolver as the sole home policy.
## Alternatives considered
**Leave the two `resolveDshHome` copies in place.** They had already drifted (one expands tildes, one didn't) and encode the same cross-cutting fact twice. Consolidation is the point of the `util/` layer; a duplicate resolver is a latent divergence bug.
**Adopt XDG (honor `$XDG_CONFIG_HOME`, or split config/data/cache into separate trees).** Considered and dropped in favor of one obvious root. A single `$DSH_HOME || ~/.dsh` ground truth matches `~/.claude` / `~/.aws`, needs no per-kind reclassification of every `~/.dsh` consumer, and leaves no resolver asymmetry to reconcile. Telemetry aligning onto the same root — rather than keeping its own XDG path — is precisely the divergence this removes.
**Keep telemetry's own config dir.** Its `deepseek-harness` namespace and separate XDG policy were the lone exception to the `dsh`/`~/.dsh` convention. Folding it onto the shared resolver is what makes "one home fact" true. The cost is that the anonymous id becomes scoped to `$DSH_HOME` rather than the machine: a project that points `DSH_HOME` at a repo-local path (or a command that loads a project `.env` before telemetry) gets a home-local id, so the id counts harness homes, not machines. This is accepted as the intended meaning of single-root — a relocated `$DSH_HOME` moves *all* harness state, telemetry identity included — and the module contract is stated as per-harness-home rather than per-machine. A machine-global identity that ignored `$DSH_HOME` would reintroduce exactly the second home policy this decision removes.
**Adopt XDG (honor `$XDG_CONFIG_HOME`, or split config/data/cache into separate trees).** Considered and dropped in favor of one obvious root. A single `$DSH_HOME || ~/.dsh` ground truth matches `~/.claude` / `~/.aws`, needs no per-kind reclassification of every `~/.dsh` consumer, and leaves no resolver asymmetry to reconcile.
## Consequences
- One home fact, one resolver. `dsh-paths` is the sole owner; the `util/` group loses the `home` package.
- Telemetry's anonymous id moves from `~/.config/deepseek-harness/telemetry.json` to the harness home (`~/.dsh/telemetry.json` by default). Under the pre-release "backends reject old formats" stance this needs no migration: an orphaned old id simply regenerates once, and the id is anonymous by construction.
- Telemetry drops Windows `%APPDATA%` handling. `resolveDshHome` uses `os.homedir()`, which is correct on Windows; the harness does not special-case `%APPDATA%` for its single root.

View File

@@ -6,13 +6,12 @@ Status: implemented
## 问题
对于"DeepSeek Harness 用户数据存放在哪里"harness 里存在套互不一致的约定:
对于"DeepSeek Harness 用户数据存放在哪里"harness 里存在套互不一致的约定:
- `@deepseek-ai/dsh-home``configured ?? $DSH_HOME ?? ~/.dsh` 解析。
- `@deepseek-ai/dsh-paths` 又提供了**第二个** `resolveDshHome`,优先级相同但额外做了波浪号展开——它几乎是 `dsh-home` 的重复实现,却没有任何门禁发现,因为两者分属不同的包,而且早已漂移(只有一个会展开波浪号)。
- `@deepseek-ai/dsh-telemetry``globalConfigDir` 采用了*完全不同*的策略:`DSH_CONFIG_HOME > $XDG_CONFIG_HOME/deepseek-harness > %APPDATA%/deepseek-harness > ~/.config/deepseek-harness`
于是产品的大部分内容都停放在同一个 `~/.dsh` 根目录下,唯独 telemetry 把匿名 id 存到别处,落在一个 `deepseek-harness` 命名空间里,这与全仓库通行的 `dsh` 简写(`DSH_HOME``@deepseek-ai/dsh-*``~/.dsh`)相冲突。两个解析器再加上一个各行其是的第三套策略,意味着不存在单一的 home 事实
同一条横切事实有两个解析器,意味着不存在单一的 home 策略
## 决策
@@ -22,20 +21,18 @@ Status: implemented
explicit configured path > $DSH_HOME > ~/.dsh
```
空或仅含空白的 `$DSH_HOME` 被当作未设置处理,这与 telemetry 旧解析器所带的保护一致:若无此保护`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions``storages``dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。
空或仅含空白的 `$DSH_HOME` 被当作未设置处理;否则`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions``storages``dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。
`@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash``dsh-skill-local``dsh-agent-spine-demo`现在`dsh-paths` 导入 `resolveDshHome``dsh-telemetry``globalConfigDir` 转而委托给 `resolveDshHome`,去掉了它的第二个解析器、`DSH_CONFIG_HOME` 覆盖项、XDG/`%APPDATA%` 分支以及 `deepseek-harness` 命名空间;匿名 id 现在直接存放在 harness home 之下。
`@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash``dsh-skill-local``dsh-agent-spine-demo`)从 `dsh-paths` 导入 `resolveDshHome`
`dsh-telemetry` 及其独立 home 策略已随 [SDK 项目工具链移除](../simplification/2026-08-11-remove-sdk-project-toolchain.md)一并消失,因此该解析器是唯一的 home 策略。
## 备选方案
**保留两份 `resolveDshHome` 副本。** 它们早已漂移(一个展开波浪号,一个不展开),并把同一条横切事实编码了两遍。`util/` 层的意义正是在于合并,重复的解析器是一个潜在的分歧 bug。
**采用 XDG遵从 `$XDG_CONFIG_HOME`,或把 config/data/cache 拆分到各自的目录树)。** 经过考虑后放弃,转而采用一个显而易见的根目录。单一的 `$DSH_HOME || ~/.dsh` 基准事实与 `~/.claude` / `~/.aws` 一致,无需对每个 `~/.dsh` 消费方按类别重新归类,也不留下任何需要协调的解析器不对称。telemetry 对齐到同一根目录——而不是保留自己的 XDG 路径——正是本决策所要消除的那种分歧。
**保留 telemetry 自己的 config 目录。** 它的 `deepseek-harness` 命名空间和独立的 XDG 策略是唯一违背 `dsh`/`~/.dsh` 约定的例外。把它折叠到共享解析器上,才让"单一 home 事实"成真。代价是匿名 id 的作用域从机器变成了 `$DSH_HOME`:若某个项目把 `DSH_HOME` 指向仓库本地路径(或某条命令在 telemetry 之前加载了项目的 `.env`),得到的就是 home 本地的 id因此该 id 统计的是 harness home而非机器。这被接受为单一根目录的应有含义——重定位 `$DSH_HOME` 会移动*全部* harness 状态telemetry 身份也在其中——模块约定据此表述为 per-harness-home 而非 per-machine。一个忽略 `$DSH_HOME` 的机器级全局身份,恰恰会重新引入本决策所要消除的那第二套 home 策略。
**采用 XDG遵从 `$XDG_CONFIG_HOME`,或把 config/data/cache 拆分到各自的目录树)。** 经过考虑后放弃,转而采用一个显而易见的根目录。单一的 `$DSH_HOME || ~/.dsh` 基准事实与 `~/.claude` / `~/.aws` 一致,无需对每个 `~/.dsh` 消费方按类别重新归类,也不留下任何需要协调的解析器不对称。
## 影响
- 单一 home 事实,单一解析器。`dsh-paths` 是唯一归属方;`util/` 组失去了 `home` 包。
- telemetry 的匿名 id 从 `~/.config/deepseek-harness/telemetry.json` 移到 harness home默认为 `~/.dsh/telemetry.json`)。在预发布的"后端拒绝旧格式"立场下,这无需迁移:一个遗留的旧 id 只会重新生成一次,而且该 id 本就是匿名构造的。
- telemetry 去掉了 Windows `%APPDATA%` 处理。`resolveDshHome` 使用 `os.homedir()`,这在 Windows 上是正确的harness 不会为它的单一根目录对 `%APPDATA%` 做特殊处理。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
2026-07-24-web-config-tree-boot-and-transport-layering.md: a730a14ac29b84c97c3f9c5b122eb907def4d389
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 4fe315bb7673ba219286b176123ccbbe08f02f0d
2026-07-24-web-config-tree-boot-and-transport-layering.md: be0a75d98c3d0004e49ad574e7e0e37ba35259bb
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 316cb0bc2ef23b440f52b35774c38a47f1e0570b

View File

@@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
## Decision
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). The `dsh-client-hmr` row is an ordinary always-on bundle row (originally appended in code by `--dev`; the flag is retired). Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 1025% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.

View File

@@ -12,7 +12,7 @@ Status: implemented
## 决策
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 rostermodules 行同时是 host 行)。不做 spine bundle每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay[共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer而不会让一棵配置树横跨两个 Loader 实现。
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 rostermodules 行同时是 host 行)。不做 spine bundle每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay[共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`dsh-client-hmr` 行是普通的常开组合包行(最初由 `--dev` 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer而不会让一棵配置树横跨两个 Loader 实现。
**boot 胶水由两个类组成。** `AppCLIEntry`apps/cli`AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西argv 事实、合成的 patch 集、解析出的 boot manifest元数据清单、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 envambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`双视角npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require不受 fiber inject 等待保护i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 1025% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md
2026-07-26-subprocess-seam.md: 7899af56259d39a4e3f924bcba673c8efa99c682
2026-07-26-subprocess-seam.zh.md: d9cbc0ebf33bc785984b0a9bd58fa8ebc6aab436
2026-07-26-subprocess-seam.md: f43b55b0b760c2aabe317abacdb800b755de059b
2026-07-26-subprocess-seam.zh.md: df8c3ddab80933882bf3587a9f01e019eb721a87

View File

@@ -17,11 +17,11 @@ A new `subprocess/` capability family owns "run and manage a process"; the bash
- **`dsh-bash-local` (Consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path.
- **`dsh-bash` (Service Definition)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash Consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned.
Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs).
Every composition that loads a bash executor also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, the Python bundled runtime, and inline test configs).
Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral contract shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta.
Observed stream and lifecycle needs then moved the eligible process consumers onto the seam: LSP uses piped protocol streams plus a collected stderr tail; the ACP backend uses piped ndjson, inherited stderr, and a consumer-owned stdin-EOF disposal ladder; PTY uses `spawnTerminal()` while keeping readiness and terminal policy. `dsh-subagent-subprocess` and the private LSP tree helpers were deleted. MCP transport spawning, the SDK package-manager runner, synchronous TUI Git probing, and dependency-light test-support launchers remain outside by ownership or execution shape; their production callers share the scrub where applicable.
Observed stream and lifecycle needs then moved the eligible process consumers onto the seam: LSP uses piped protocol streams plus a collected stderr tail; the ACP backend uses piped ndjson, inherited stderr, and a consumer-owned stdin-EOF disposal ladder; PTY uses `spawnTerminal()` while keeping readiness and terminal policy. `dsh-subagent-subprocess` and the private LSP tree helpers were deleted. MCP transport spawning and dependency-light test-support launchers remain outside by ownership or execution shape; their production callers share the scrub where applicable.
## Alternatives considered
@@ -31,7 +31,7 @@ Observed stream and lifecycle needs then moved the eligible process consumers on
**Use one `stdio: 'pipe' | 'inherit' | 'collect'` mode for all streams.** Rejected because real consumers mix modes per stream: LSP uses pipe/pipe/collect, ACP uses pipe/pipe/inherit, and Bash uses data/collect/collect.
**Route every process launch through `ctx.subprocess`.** Rejected because the MCP SDK owns its transport spawn, the SDK wizard has no Cordis context and needs inherited redirection, the TUI probe is synchronous, and support launchers deliberately stay independent of product seams. PTY allocation did move behind `spawnTerminal()` because the provider, not the consumer, owns that substrate-specific primitive.
**Route every process launch through `ctx.subprocess`.** Rejected because the MCP SDK owns its transport spawn and support launchers deliberately stay independent of product seams. PTY allocation did move behind `spawnTerminal()` because the provider, not the consumer, owns that substrate-specific primitive.
**Put `run_in_background`/task semantics into the subprocess capability seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The subprocess seam sits *below* the bash executor, not beside the task registry.

View File

@@ -17,11 +17,11 @@ Status: implemented
- **`dsh-bash-local`Consumer**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec``['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。
- **`dsh-bash`Service Definition**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash Consumer 需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。
如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`CLI命令行界面、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。
每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`CLI命令行界面、各示例、Python 捆绑运行时以及各内联测试配置。
后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose仍是先终止再等待退出的边界。一条行为约定随之挪动后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr对一个从未真正运行的进程管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。
基于已观察到的流与生命周期需求,具备条件的进程消费方随后迁到该 seamLSP 使用管道化协议流加收集式 stderr 尾部ACPAgent Client Protocol后端使用管道化 ndjson、继承式 stderr 和消费方拥有的 stdin-EOF dispose 阶梯PTY 使用 `spawnTerminal()`,同时保留就绪与终端策略。`dsh-subagent-subprocess` 与 LSP 私有进程树辅助函数均被删除。MCP 传输 spawn、SDK 包管理器运行器、同步 TUI Git 探测和刻意保持轻依赖的 test-support 启动器因所有权或执行形状仍留在外部;适用的生产调用方共享凭据清除。
基于已观察到的流与生命周期需求,具备条件的进程消费方随后迁到该 seamLSP 使用管道化协议流加收集式 stderr 尾部ACPAgent Client Protocol后端使用管道化 ndjson、继承式 stderr 和消费方拥有的 stdin-EOF dispose 阶梯PTY 使用 `spawnTerminal()`,同时保留就绪与终端策略。`dsh-subagent-subprocess` 与 LSP 私有进程树辅助函数均被删除。MCP 传输 spawn 和刻意保持轻依赖的 test-support 启动器因所有权或执行形状仍留在外部;适用的生产调用方共享凭据清除。
## 曾考虑的替代方案
@@ -31,7 +31,7 @@ Status: implemented
**用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式统一全部流。**否决真实消费方按流混用模式——LSP 使用 pipe/pipe/collectACP 使用 pipe/pipe/inheritBash 使用 data/collect/collect。
**把每一次进程启动都路由到 `ctx.subprocess`。**否决MCP SDK 拥有其传输 spawnSDK 向导没有 Cordis 上下文且需要继承式重定向TUI 探测是同步的,support 启动器则刻意独立于产品 seam。PTY 分配迁到 `spawnTerminal()`,因为这项底层专用原语归提供方而非消费方所有。
**把每一次进程启动都路由到 `ctx.subprocess`。**否决MCP SDK 拥有其传输 spawnsupport 启动器则刻意独立于产品 seam。PTY 分配迁到 `spawnTerminal()`,因为这项底层专用原语归提供方而非消费方所有。
**改把 `run_in_background`/任务语义放进 subprocess 能力 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知bash 工具则把 `BashProcess` 适配成任务钩子。subprocess seam 位于 bash 执行器*之下*,而不是与任务注册表并列。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-07-29-package-regrouping.md
2026-07-29-package-regrouping.md: 30fc45a122263350b4a2ad1998850f631c20f9b8
2026-07-29-package-regrouping.zh.md: a3a9a11ec71b7f894dcea7c733eb39a80b71ac50
2026-07-29-package-regrouping.md: 6b85fafaebd75b051d239966a30bb81bdaf5522f
2026-07-29-package-regrouping.zh.md: 06eb210e318833d9651af2c4f0e4d792799a2018

View File

@@ -10,29 +10,26 @@ The two-level `packages/<group>/<pkg>` hierarchy ([original decision](../../arch
- `ui/` mixed four unrelated planes: the human terminal channel (`tui`), the SDK's JSON-RPC server half (`jsonrpc`, whose peer dependency on `dsh-sdk-protocol` binds it to the SDK wire stack), the human-interaction seams (`user-interaction`, `user-approval`, `permission`, `tool-ask-user`, `commands`), and channel-neutral boot glue (`app-boot`). Its own README narrated the mixture instead of stating a role.
- The session family was fragmented across five groups — `session-persistence/`, `session-projection/`, `session-query/`, `session-title/`, and `telemetry/` — although the measured dependency edges tie them together (query → persistence, title → projection, projection → persistence; see [docs/module-graph.md](../../../../docs/module-graph.md)).
- Two group names collided with unrelated packages: `telemetry/` (session reporting) vs `dsh-telemetry` (launcher-side SDK telemetry), and `timeout/` (a tool-call guard) vs `util/timeout` (the generic promise utility).
- The `timeout/` group for a tool-call guard collided with `util/timeout`, the generic promise utility.
- `cordis/` named its group after the framework every package is built on, so the name discriminated nothing; its single package `tool-cordis` is the runtime self-modification toolset.
- The old `sdk/` folder names were inconsistent: `sdk/sdk-client` and `sdk/sdk-protocol` repeated the group name while `sdk/telemetry`, `sdk/helper`, and `sdk/scripts` did not.
The north star for the regrouping: **closely clustered packages share a group.** A cluster is measured — peer-dependency edges and co-change — not thematic. An isolated seam family may stand alone as a small group; the failure mode to avoid is the grab-bag whose name describes no single role.
## Decision
Six groups are recomposed; every other group keeps its prior boundary and contents (the dependency analysis confirmed the capability families — `bash/`, `pty/`, `code-runtime/`, `sandbox/`, `subprocess/`, `fs/`, `lsp/`, `web/`, `skill/`, and the rest — were already drawn correctly). npm package names did not change; the folder tree carries the whole change.
Five regrouping decisions remain current; every other group keeps its prior boundary and contents (the dependency analysis confirmed the capability families — `bash/`, `pty/`, `code-runtime/`, `sandbox/`, `subprocess/`, `fs/`, `lsp/`, `web/`, `skill/`, and the rest — were already drawn correctly). The original sixth decision collected the SDK project initializer, launcher tooling, and runtime JSON-RPC packages under `scaffold/`; [removing that unreleased toolchain](../simplification/2026-08-11-remove-sdk-project-toolchain.md) superseded it by deleting the project tooling and moving the surviving runtime trio to `sdk/`.
| Group | Members (folder names) | From |
|---|---|---|
| `session/` | session-persistence, session-persistence-jsonl, session-persistence-sqlite, session-checkpoint-policy, session-projection, session-projection-cache, session-title, session-title-llm, session-title-first-message-llm, session-title-all-messages-llm, session-telemetry, session-telemetry-otel | `session-persistence/` + `session-projection/` + `session-title/` + `telemetry/` |
| `interaction/` | user-interaction, user-approval, permission, tool-ask-user, commands, tui | `ui/` |
| `boot/` | app-boot | `ui/` |
| `scaffold/` | helper, scripts, create-sdk, protocol, client, server, telemetry | `sdk/` + `ui/jsonrpc` |
| `guard/` | repeat-tool-guard, timeout-policy | `guard/` + `timeout/` |
| `self-modification/` | tool-cordis | `cordis/` |
- **`session/`** is the durable session data plane: the persistence seam with its backends and checkpoint policy, the projection fold that serves whole values from that log, log-backed titles, and OTel reporting. The title fold is itself load-bearing for the read side (`session-query` peer-depends on `dsh-session-title`), so titles belong with the data plane, not in a derived-services annex. The plain name is deliberate (prefer names a human would say); the nearby `core/session` package remains the live in-memory service, while this group is the durable family around it. `session-query/` stays a standalone group — the read/tool surface has its own model tools and SQLite FTS backend and is consumed independently of persistence internals. Absorbing `telemetry/` ended the group-name collision with `dsh-telemetry`.
- **`session/`** is the durable session data plane: the persistence seam with its backends and checkpoint policy, the projection fold that serves whole values from that log, log-backed titles, and OTel reporting. The title fold is itself load-bearing for the read side (`session-query` peer-depends on `dsh-session-title`), so titles belong with the data plane, not in a derived-services annex. The plain name is deliberate (prefer names a human would say); the nearby `core/session` package remains the live in-memory service, while this group is the durable family around it. `session-query/` stays a standalone group — the read/tool surface has its own model tools and SQLite FTS backend and is consumed independently of persistence internals.
- **`interaction/`** is the human-collaboration plane plus the terminal channel that answers it: the question/approval seams, the permission preset, the model-facing `ask_user_question` tool, the human-command registry (`plan-mode` and `command-goal` already consume `commands` together with the interaction seams), and `tui` — the interactive channel is the plane's richest provider and consumer (peer edges to `commands` and `user-interaction`), and a one-package `tui/` group would spend a top-level name on one plugin.
- **`boot/`** is a role-complete single-package group: the shared bin boot glue that belongs to no channel and no assembly (consumed by `apps/cli`, the `scaffold/` launcher, and the `examples/` demo bins).
- **`scaffold/`** is the developer-tooling family: project helper, launcher, initializer, wire protocol with both ends (`server` is the former `ui/jsonrpc`), and launcher telemetry. Renamed from `sdk/`: the whole `packages/` tree *is* the SDK, so a group named `sdk/` inside it said nothing; `scaffold/` names the create/launch/drive-a-project role. Folders drop the legacy `sdk-` prefix (`protocol`, `client`, `server`), matching the `client/`/`host/` role-named folder style; the three affected npm names are mapped explicitly beside the group wildcard in `tsconfig.base.json` until the deferred renames land.
- **`boot/`** is a role-complete single-package group: the shared bin boot glue that belongs to no channel and no assembly (consumed by `apps/cli` and the `examples/` demo bins).
- **`guard/`** keeps its documented role, loop-hygiene guards, and gains the tool-call timeout enforcer, dissolving the one-package `timeout/` group whose name collided with `util/timeout`.
- **`self-modification/`** names the role `cordis/` obscured: the toolset with which the agent inspects and mounts plugins in its own live runtime, and the landing zone for future self-modification packages.
@@ -40,21 +37,16 @@ Six groups are recomposed; every other group keeps its prior boundary and conten
## Deferred renames (FIXME markers)
Five npm names should eventually change, but renaming inside the reorganization would have turned a pure-move PR into an import-churn PR. Instead, each affected package's module JSDoc carries a `FIXME` naming the intended new name. `FIXME` blocks a tagged release ([marker semantics](../../../../docs/development.md)), which is the wanted forcing function: these renames are only free while nothing external consumes the packages.
Two npm names should eventually change, but renaming inside the reorganization would have turned a pure-move PR into an import-churn PR. Each affected package's module JSDoc carries a `FIXME` naming the intended new name. `FIXME` blocks a tagged release ([marker semantics](../../../../docs/development.md)), which is the wanted forcing function: these renames are only free while nothing external consumes the packages. Removing the unreleased SDK project toolchain deleted the other three packages and their rename markers instead of preserving names for code that no longer exists.
| Current npm name | Intended name | Why |
|---|---|---|
| `@deepseek-ai/dsh-jsonrpc` | `@deepseek-ai/dsh-sdk-server` | Names the wire encoding, not the role; it is the server half of the SDK protocol |
| `@deepseek-ai/dsh-telemetry` | `@deepseek-ai/dsh-sdk-telemetry` | Collides with the `dsh-session-telemetry` family; it is launcher-side SDK telemetry |
| `@deepseek-ai/dsh-helper` | `@deepseek-ai/dsh-sdk-helper` | Indefensibly generic as a published name |
| `@deepseek-ai/dsh-scripts` | `@deepseek-ai/dsh-sdk-scripts` | Same |
| `@deepseek-ai/dsh-timeout-policy` | `@deepseek-ai/dsh-timeout-guard` | Suggestion, not settled: aligns the name with its `guard/` home; decide at resolution time |
The first four are settled intent; resolving them converges the SDK wire stack's npm names on `dsh-sdk-*` (the npm prefix names the product stack; the `scaffold/` folder names the role). `@deepseek-ai/create-sdk` keeps its documented npm-initializer exception.
## What the move touched
The moves landed as pure `git mv` moves, so rename detection carries the history. A group move touched: the moved package's `tsconfig.json` relative `references` and every dependent's entry (including the `apps/cli` project references), the tsconfig aggregate and path maps, group READMEs (five new bilingual triplets, deletions for dissolved groups, the [packages/README.md](../../../../packages/README.md) hierarchy table, the root `AGENTS.md` layout map), regenerated artifacts (`docs/module-graph.md`, path-embedding catalogs, the lockfile's importer keys), and root-relative `packages/...` citations in prose and gate scripts. Remaining group-path referents (workspace configs, test globs, lint keys) were found mechanically by the acceptance gates failing loud — the repository's own misconfiguration rule.
The moves landed as pure `git mv` moves, so rename detection carries the history. A group move touched: the moved package's `tsconfig.json` relative `references` and every dependent's entry (including the `apps/cli` project references), the tsconfig aggregate and path maps, group READMEs, the [packages/README.md](../../../../packages/README.md) hierarchy table, the root `AGENTS.md` layout map, regenerated artifacts (`docs/module-graph.md`, path-embedding catalogs, and the lockfile's importer keys), and root-relative `packages/...` citations in prose and gate scripts. Remaining group-path referents (workspace configs, test globs, lint keys) were found mechanically by the acceptance gates failing loud — the repository's own misconfiguration rule.
A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot fixtures, the `pnpm-workspace.yaml`/`tsdown` globs (both `packages/*/*`), or the Python runtime manifest — all reference packages by npm name.
@@ -62,13 +54,13 @@ A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot f
## Alternatives considered
**Coarse domain buckets** (`exec/` = subprocess+sandbox+bash+pty+code-runtime, `workspace/` = fs+lsp+workspace, `orchestration/` = subagent+workflow+tasks, `knowledge/` = web+skill, `collab/` = plan+todo+goal; ~16 groups). Rejected: the measured graph contradicts the merges. `sandbox` and `subprocess` are shared infrastructure consumed across families (bash ×5, fs ×5, pty, lsp, mcp, subagent, scaffold edges), `web``skill` have zero edges, and a large bucket reproduces the `ui/` grab-bag at a larger scale.
**Coarse domain buckets** (`exec/` = subprocess+sandbox+bash+pty+code-runtime, `workspace/` = fs+lsp+workspace, `orchestration/` = subagent+workflow+tasks, `knowledge/` = web+skill, `collab/` = plan+todo+goal; ~16 groups). Rejected: the measured graph contradicts the merges. `sandbox` and `subprocess` are shared infrastructure consumed across families (bash ×5, fs ×5, pty, lsp, mcp, and subagent edges), `web``skill` have zero edges, and a large bucket reproduces the `ui/` grab-bag at a larger scale.
**Abstract layer names** (`capability/`, `policy/`, `extension/`, `provider/`). Rejected: they describe every plugin equally badly, and a `capability/` bucket would hold ~50 packages.
**A full npm rename sweep** (`dsh-<group>-<pkg>` for every package). Rejected: npm names are flat, so group-prefixing adds churn across imports, configs, and fixtures with no disambiguation gain; targeted FIXME-tracked renames cover the actual collisions.
**Performing the five renames inside the reorganization.** Rejected: renames multiply open-PR conflicts and destroy the pure-move review property. The FIXME markers keep them visible release blockers to resolve as small follow-up PRs.
**Performing the deferred renames inside the reorganization.** Rejected: renames multiply open-PR conflicts and destroy the pure-move review property. The remaining FIXME markers keep them visible release blockers to resolve as small follow-up PRs.
**A two-way session split** (`session-core/` + `session-utils/`). Rejected: query belongs to neither side cleanly, and `session-core` invites confusion with `core/session` (`dsh-session`, the live in-memory service, which stays in `core/`).
@@ -78,9 +70,7 @@ A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot f
**A standalone one-package `tui/` group.** Rejected: `tui` is the interaction plane's primary provider/consumer (peer edges to `commands`, `user-interaction`), and a top-level name spent on one plugin adds a group without adding information; it folds into `interaction/`.
**Keeping the group name `sdk/`.** Rejected: the whole `packages/` tree is the SDK, so an `sdk/` group inside it discriminates nothing — the same disease as `cordis/`. `scaffold/` names the actual role (create, launch, and drive projects from outside).
**Moving `app-boot` to `apps/`.** Rejected: `apps/` is the assembly tier over the package tier, and `dsh-app-boot` is a library that package-tier code imports (`scaffold/scripts`' launcher peer-depends on it) — placing it in `apps/` would invert the tiers and put a workspace library outside the `packages/*/*` build globs. It stays a package; `boot/` is its role-complete home.
**Moving `app-boot` to `apps/`.** Rejected: `apps/` is the assembly tier over the package tier, and `dsh-app-boot` is a package-tier library — placing it in `apps/` would invert the tiers and put a workspace library outside the `packages/*/*` build globs. It stays a package; `boot/` is its role-complete home.
**Moving `tool-cordis` into `core/`.** Rejected: self-modification is its own product seam, expected to grow; the spine stays minimal. The group was first named `self-evolve/`; the name settled on `self-modification/` as the plainer term.
@@ -88,9 +78,9 @@ A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot f
## Consequences
- The tree matches the map: the six recomposed groups hold exactly the listed members; the groups `ui/`, `sdk/`, `telemetry/`, `timeout/`, `cordis/`, `session-persistence/`, `session-projection/`, and `session-title/` no longer exist; every other group's contents are unchanged. The workspace package-name set is identical before and after (zero npm renames), and the five FIXME markers pin the deferred ones. A FIXME that later proves wrong must be removed explicitly with rationale, never silently dropped.
- The five still-current regrouped families hold the listed members; the groups `ui/`, `telemetry/`, `timeout/`, `cordis/`, `session-persistence/`, `session-projection/`, and `session-title/` no longer exist. The regrouping itself changed no npm names. The later SDK toolchain removal intentionally changed the package set and restored `sdk/` as the precise home of the runtime SDK trio. Two FIXME markers pin the remaining deferred renames; a FIXME that later proves wrong must be removed explicitly with rationale, never silently dropped.
- What pins the result: `pnpm run typecheck`, the unit suites of every moved group, `verify-package-paths`, `verify-md-links`, and the corpus-wide translation pairing all pass on the moved tree; the group-scoped test globs in `vitest.snapshot.config.ts` were rewritten with the moves so the suites collect the same test files as before (a fail-open glob would silently drop coverage).
- Every open PR touching a moved file rebases across the move once; rename detection resolves most hunks mechanically.
- Single-package groups remain (`boot/`, `self-modification/`, and existing ones such as `acp/`). Accepted deliberately: each is role-complete rather than a fragment of a family, and a truthful small group beats a nominal merge.
- The `scaffold/` folders diverge from their npm names until the deferred renames land — the one transitional asymmetry, carried by three explicit `paths` entries in `tsconfig.base.json` and resolved by the FIXME renames.
- The `sdk/` role folders map explicitly to their npm names in `tsconfig.base.json`; the `server/` mapping remains transitional until `dsh-jsonrpc` is renamed.
- What this gave up: nothing functional — the change is navigational. Muscle memory and external links to old GitHub paths break, which is acceptable pre-release with no external consumers.

View File

@@ -10,29 +10,26 @@ Status: implemented
- `ui/` 混杂了四个互不相关的平面:人类终端通道(`tui`、SDK 的 JSON-RPC 服务端一半(`jsonrpc`,它对 `dsh-sdk-protocol` 的对等依赖peer dependency把它绑在 SDK 通信栈上)、人机交互 seam`user-interaction``user-approval``permission``tool-ask-user``commands`),以及与通道无关的 boot 胶水(`app-boot`)。它自己的 README 只能逐一叙述这堆混杂,说不出一个统一职责。
- 会话家族被割裂在五个组里——`session-persistence/``session-projection/``session-query/``session-title/``telemetry/`——而实测依赖边明明把它们连成一体query → persistence、title → projection、projection → persistence见 [docs/module-graph.md](../../../../docs/module-graph.md))。
- 两个组名与不相干的包撞名:`telemetry/`(会话上报)撞上 `dsh-telemetry`(启动器侧 SDK telemetry`timeout/`(一个工具调用守卫)撞上 `util/timeout`(通用 promise 工具)
- 用于工具调用守卫的 `timeout/` 组与通用 promise 工具 `util/timeout` 撞名
- `cordis/` 拿所有包共同依托的框架给自己的组命名,这个名字因此毫无区分度;组里唯一的包 `tool-cordis` 是运行时自我修改工具集。
-`sdk/` 的目录命名不一致:`sdk/sdk-client``sdk/sdk-protocol` 重复了组名,而 `sdk/telemetry``sdk/helper``sdk/scripts` 没有。
这次重新分组的指导准则:**聚类紧密的包同处一组。**聚类以实测为准(对等依赖边与 co-change而非按主题归类。孤立的 seam 家族可以自成一个小组;要避免的失败形态,是名字概括不出单一职责的大杂烩组。
## Decision
重组六个组;其余每个组都保持先前的边界与内容不变(依赖分析确认各能力家族——`bash/``pty/``code-runtime/``sandbox/``subprocess/``fs/``lsp/``web/``skill/` 及其余——本来就划得正确)。npm 包名一个未改;整个变更全部由目录树承载
五项重组决策仍然有效;其余每个组都保持先前的边界与内容不变(依赖分析确认各能力家族——`bash/``pty/``code-runtime/``sandbox/``subprocess/``fs/``lsp/``web/``skill/` 及其余——本来就划得正确)。原本的第六项决策把 SDK 项目初始化器、启动器工具与运行时 JSON-RPC 包汇集到 `scaffold/`[移除这套未发布工具链](../simplification/2026-08-11-remove-sdk-project-toolchain.md)的决策删除了项目工具,并将存留的运行时三包移到 `sdk/`,从而取代了该项决策
| 组 | 成员(目录名) | 来源 |
|---|---|---|
| `session/` | session-persistence、session-persistence-jsonl、session-persistence-sqlite、session-checkpoint-policy、session-projection、session-projection-cache、session-title、session-title-llm、session-title-first-message-llm、session-title-all-messages-llm、session-telemetry、session-telemetry-otel | `session-persistence/` + `session-projection/` + `session-title/` + `telemetry/` |
| `interaction/` | user-interaction、user-approval、permission、tool-ask-user、commands、tui | `ui/` |
| `boot/` | app-boot | `ui/` |
| `scaffold/` | helper、scripts、create-sdk、protocol、client、server、telemetry | `sdk/` + `ui/jsonrpc` |
| `guard/` | repeat-tool-guard、timeout-policy | `guard/` + `timeout/` |
| `self-modification/` | tool-cordis | `cordis/` |
- **`session/`** 是持久会话数据平面:持久化 seam 连同其各后端与检查点策略、从该日志折叠fold出全量值对外供值的投影、日志兜底的标题以及 OTel 上报。标题折叠本身就是读取侧的承重构件(`session-query``dsh-session-title` 声明对等依赖),所以标题属于数据平面,而非某个「派生服务」附属区。用这个朴素的名字是有意为之(名字要像人起的);旁边的 `core/session` 包仍是常驻内存的实时服务,本组则是围绕它的持久家族。`session-query/` 保持独立成组:这个读取/工具面自带模型工具和 SQLite FTS 后端,其消费不依赖持久化内部实现。吸收 `telemetry/` 之后,与 `dsh-telemetry` 的组名冲突就此终结。
- **`session/`** 是持久会话数据平面:持久化 seam 连同其各后端与检查点策略、从该日志折叠fold出全量值对外供值的投影、日志兜底的标题以及 OTel 上报。标题折叠本身就是读取侧的承重构件(`session-query``dsh-session-title` 声明对等依赖),所以标题属于数据平面,而非某个「派生服务」附属区。用这个朴素的名字是有意为之(名字要像人起的);旁边的 `core/session` 包仍是常驻内存的实时服务,本组则是围绕它的持久家族。`session-query/` 保持独立成组:这个读取/工具面自带模型工具和 SQLite FTS 后端,其消费不依赖持久化内部实现。
- **`interaction/`** 是人机协作平面加上应答它的终端通道:提问/批准 seam、权限预设、面向模型的 `ask_user_question` 工具、人类命令注册表(`plan-mode``command-goal` 已经把 `commands` 和各交互 seam 放在一起消费),以及 `tui`——这个交互通道是该平面最重的提供方与消费方(对 `commands``user-interaction` 均有对等依赖边),而一个单包 `tui/` 组会把一个顶层名字花在一个插件上。
- **`boot/`** 是角色完备的单包组:不归属任何通道也不归属任何组装的共享 bin boot 胶水(被 `apps/cli``scaffold/` 的启动器和 `examples/` 各演示 bin 消费)。
- **`scaffold/`** 是开发者工具家族:项目 helper、启动器、初始化器、连同两端的通信协议`server` 即原先的 `ui/jsonrpc`),以及启动器侧 telemetry。从 `sdk/` 改名:整个 `packages/` 树本身就是 SDK树里再放一个叫 `sdk/` 的组等于什么都没说;`scaffold/` 说出了「创建/启动/驱动项目」这一实际角色。目录去掉遗留的 `sdk-` 前缀(`protocol``client``server`),与 `client/`/`host/` 的角色命名风格一致;在推迟的改名落地之前,受影响的三个 npm 名在 `tsconfig.base.json` 里于组通配符旁显式映射。
- **`boot/`** 是角色完备的单包组:不归属任何通道也不归属任何组装的共享 bin boot 胶水(被 `apps/cli` `examples/` 各演示 bin 消费)。
- **`guard/`** 保留其文档记载的角色(循环卫生守卫),并新纳入强制执行工具调用超时的包;那个与 `util/timeout` 撞名的单包组 `timeout/` 随之解散。
- **`self-modification/`** 把 `cordis/` 遮蔽掉的角色说了出来:它是 agent智能体检查并挂载自身实时运行时中插件所用的工具集也是未来自我修改类包的落点。
@@ -40,21 +37,16 @@ Status: implemented
## Deferred renames (FIXME markers)
个 npm 名最终应当改掉,但在这次重组内部改名,会把一个纯移动的 PRPull Request变成大量翻改 import 的 PR。因此每个受影响包的模块 JSDoc 里带有一条 `FIXME`,写明意图中的新名字。`FIXME` 会阻塞打 tag 的发布([标记语义](../../../../docs/development.md)),这正是想要的倒逼机制:只有趁还没有外部消费方使用这些包时,这些改名才是零成本的。
个 npm 名最终应当改掉,但在这次重组内部改名,会把一个纯移动的 PRPull Request变成大量翻改 import 的 PR。每个受影响包的模块 JSDoc 里带有一条 `FIXME`,写明意图中的新名字。`FIXME` 会阻塞打 tag 的发布([标记语义](../../../../docs/development.md)),这正是想要的倒逼机制:只有趁还没有外部消费方使用这些包时,这些改名才是零成本的。移除未发布的 SDK 项目工具链时,另外三个包及其改名标记一并删除,没有为已经不存在的代码保留包名。
| 当前 npm 名 | 目标名 | 原因 |
|---|---|---|
| `@deepseek-ai/dsh-jsonrpc` | `@deepseek-ai/dsh-sdk-server` | 名字说的是协议编码而非角色;它是 SDK 协议的服务端一半 |
| `@deepseek-ai/dsh-telemetry` | `@deepseek-ai/dsh-sdk-telemetry` | 与 `dsh-session-telemetry` 家族撞名;它是启动器侧 SDK telemetry |
| `@deepseek-ai/dsh-helper` | `@deepseek-ai/dsh-sdk-helper` | 作为公开发布名空泛得站不住脚 |
| `@deepseek-ai/dsh-scripts` | `@deepseek-ai/dsh-sdk-scripts` | 同上 |
| `@deepseek-ai/dsh-timeout-policy` | `@deepseek-ai/dsh-timeout-guard` | 仅为建议、尚未定案:使名字与其 `guard/` 归属对齐;到解决时再定 |
前四个是已定的意图兑现之后SDK 通信栈的 npm 名随之收敛为 `dsh-sdk-*`npm 前缀指产品栈,`scaffold/` 目录名指角色)。`@deepseek-ai/create-sdk` 保留其文档记载的 npm 初始化器特例。
## What the move touched
移动以纯 `git mv` 形式落地,历史由重命名检测承载。组移动触及了:被移动包的 `tsconfig.json` 相对 `references` 及每个依赖方的对应条目(含 `apps/cli` 的 project referencestsconfig 聚合与路径映射;各组 README(五组新的双语三文件配对、被解散组的 README 删除、[packages/README.md](../../../../packages/README.md) 的层级结构表`AGENTS.md` 的布局图;重新生成的产物(`docs/module-graph.md`、内嵌路径的目录锁文件的 importer 键);以及散文与门禁脚本中以仓库根为基准的 `packages/...` 引用。其余每一处组路径引用workspace 配置、测试 glob、lint 键)都由验收门禁的响亮失败机械地找了出来——这正是本仓库自己的「配置错误必须响亮失败」规则。
移动以纯 `git mv` 形式落地,历史由重命名检测承载。组移动触及了:被移动包的 `tsconfig.json` 相对 `references` 及每个依赖方的对应条目(含 `apps/cli` 的 project referencestsconfig 聚合与路径映射;各组 README[packages/README.md](../../../../packages/README.md) 的层级结构表`AGENTS.md` 的布局图;重新生成的产物(`docs/module-graph.md`、内嵌路径的目录以及锁文件的 importer 键);以及散文与门禁脚本中以仓库根为基准的 `packages/...` 引用。其余每一处组路径引用workspace 配置、测试 glob、lint 键)都由验收门禁的响亮失败机械地找了出来——这正是本仓库自己的「配置错误必须响亮失败」规则。
组移动未触及npm 包名、import、`cordis.yml` 配置、快照 fixture测试前置数据`pnpm-workspace.yaml``tsdown` 的 glob都是 `packages/*/*`),以及 Python 运行时 manifest元数据清单——它们全部按 npm 包名引用包。
@@ -62,13 +54,13 @@ Status: implemented
## Alternatives considered
**粗粒度领域桶**`exec/` = subprocess+sandbox+bash+pty+code-runtime`workspace/` = fs+lsp+workspace`orchestration/` = subagent+workflow+tasks`knowledge/` = web+skill`collab/` = plan+todo+goal约 16 个组)。不予采纳:实测依赖图与这些合并相矛盾。`sandbox``subprocess` 是被各家族跨界消费的共享基础设施(与 bash ×5、fs ×5、pty、lsp、mcpsubagent、scaffold 均有依赖边),`web``skill` 之间零依赖边,而大桶只会在更大尺度上复现 `ui/` 式大杂烩。
**粗粒度领域桶**`exec/` = subprocess+sandbox+bash+pty+code-runtime`workspace/` = fs+lsp+workspace`orchestration/` = subagent+workflow+tasks`knowledge/` = web+skill`collab/` = plan+todo+goal约 16 个组)。不予采纳:实测依赖图与这些合并相矛盾。`sandbox``subprocess` 是被各家族跨界消费的共享基础设施(与 bash ×5、fs ×5、pty、lsp、mcpsubagent 均有依赖边),`web``skill` 之间零依赖边,而大桶只会在更大尺度上复现 `ui/` 式大杂烩。
**抽象分层名**`capability/``policy/``extension/``provider/`)。不予采纳:这些名字对每个插件都同样地不达意,而且一个 `capability/` 桶会装下约 50 个包。
**一轮全量 npm 重命名**(每个包都改为 `dsh-<group>-<pkg>`。不予采纳npm 包名是扁平的,加组前缀只会在 import、配置和 fixture 之间制造改动,却换不来任何消歧收益;用 FIXME 跟踪的定点改名足以覆盖真正的撞名。
**在重组内部一并完成那五个改名。** 不予采纳:改名会成倍放大开放 PR 的冲突并破坏纯移动的评审属性。FIXME 标记让这些改名保持为可见的发布阻塞项,留待以小型后续 PR 逐一解决。
**在重组内部一并完成推迟的改名。** 不予采纳:改名会成倍放大开放 PR 的冲突,并破坏纯移动的评审属性。剩余的 FIXME 标记让这些改名保持为可见的发布阻塞项,留待以小型后续 PR 逐一解决。
**会话两分法**`session-core/` + `session-utils/`。不予采纳query 放哪一侧都不干净,而且 `session-core` 容易与 `core/session` 混淆(后者是 `dsh-session`,常驻内存的实时服务,留在 `core/` 不动)。
@@ -78,9 +70,7 @@ Status: implemented
**独立的单包 `tui/` 组。** 不予采纳:`tui` 是交互平面最重的提供方/消费方(对 `commands``user-interaction` 有对等依赖边),把一个顶层名字花在一个插件上只添组不添信息;它折入 `interaction/`
**保留组名 `sdk/`。** 不予采纳:整个 `packages/` 树本身就是 SDK树里的 `sdk/` 组毫无区分度——与 `cordis/` 同病。`scaffold/` 说出了实际角色(从外部创建、启动、驱动项目)
**把 `app-boot` 挪到 `apps/`。** 不予采纳:`apps/` 是包层之上的组装层,而 `dsh-app-boot` 是被包层代码 import 的库(`scaffold/scripts` 的启动器对它声明对等依赖)——放进 `apps/` 会颠倒层级,并把一个 workspace 库放到 `packages/*/*` 构建 glob 之外。它仍是一个包;`boot/` 是它角色完备的家。
**把 `app-boot` 挪到 `apps/`。** 不予采纳:`apps/` 是包层之上的组装层,而 `dsh-app-boot` 是包层的库——放进 `apps/` 会颠倒层级,并把一个 workspace 库放到 `packages/*/*` 构建 glob 之外。它仍是一个包;`boot/` 是它角色完备的家
**把 `tool-cordis` 挪进 `core/`。** 不予采纳:自我修改是独立的产品 seam预期还会生长主干保持精简。该组最初命名为 `self-evolve/`;名字最终定为更朴素的 `self-modification/`
@@ -88,9 +78,9 @@ Status: implemented
## Consequences
- 目录树与映射表一致:重组的六个组恰好持有所列成员;`ui/``sdk/``telemetry/``timeout/``cordis/``session-persistence/``session-projection/``session-title/` 这些组不复存在其余每个组的内容不变。workspace 的包名集合在前后完全相同npm 改名为零),五条 FIXME 标记钉住推迟改名日后若某条 FIXME 被证明不对,必须连同理由显式移除,绝不允许无声消失。
- 五个仍然有效的重组家族持有所列成员;`ui/``telemetry/``timeout/``cordis/``session-persistence/``session-projection/``session-title/` 这些组不复存在。重组本身没有更改 npm 名。后续移除 SDK 工具链的决策有意改变包集合,并恢复 `sdk/` 作为运行时 SDK 三包的精确归属。两条 FIXME 标记钉住剩余的推迟改名日后若某条 FIXME 被证明不对,必须连同理由显式移除,绝不允许无声消失。
- 结果由以下检查钉住:`pnpm run typecheck`、每个被移动组的单元测试套件、`verify-package-paths``verify-md-links` 与全语料翻译配对在移动后的树上全部通过;`vitest.snapshot.config.ts` 中按组划定的测试 glob 随移动一并改写套件收集到与移动前相同的测试文件glob 匹配为空会无声地丢失覆盖)。
- 每个触碰被移动文件的开放 PR 都跨过这次移动做一次变基;重命名检测可机械化解决大多数改动块。
- 单包组依然存在(`boot/``self-modification/`,以及 `acp/` 等既有单包组)。这是有意接受的:每个都是角色完备的整体而非某个家族的碎片,一个名实相符的小组胜过一次徒有其名的合并。
- 在推迟的改名落地之前,`scaffold/` 的目录名与其 npm 名并不一致——这是唯一的过渡性不对称,由 `tsconfig.base.json` 里三条显式 `paths` 映射承载,并由 FIXME 改名最终消除
- `sdk/` 的角色目录在 `tsconfig.base.json` 中显式映射到各自的 npm 名;在 `dsh-jsonrpc` 完成改名之前,`server/` 的映射仍是过渡性的
- **这次变更放弃了什么:** 功能上一无所失——变更只关乎导航。肌肉记忆和指向旧 GitHub 路径的外部链接会失效;在 pre-release、尚无外部消费者的前提下这可以接受。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md
2026-07-30-followup-enqueue-and-owned-runs.md: e0561f8cfc975dd60e69307b160070c292efff8e
2026-07-30-followup-enqueue-and-owned-runs.zh.md: a807b3ebe47399cae95c38bc2232e20e00e839f5
2026-07-30-followup-enqueue-and-owned-runs.md: 9978b3a8ab8678fe98e000476505cee9dcaa1bc6
2026-07-30-followup-enqueue-and-owned-runs.zh.md: 6501266d32b5ae884a478bfae9e4f1e3d5d85575

View File

@@ -16,7 +16,7 @@ Keep `Agent.followup(message): void` as an enqueue-only operation. `Agent.whenId
The low-level SDK protocol answers `session/prompt` as soon as enqueue succeeds with `{ messageId }`. It streams durable facts through `session.event`, publishes whole-agent transitions through `session.status`, and has no `session.finished`. A low-level client may observe that receipt and later idleness, but receives no prompt result.
High-level automation APIs return a `RunResult` only when they explicitly own an activity interval. The TypeScript and Python SDK `run()` methods collect from the submitted message's durable inbox receipt through the next whole-agent `idle`; their `finalResponse` is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may report a result because its caller owns the complete child lifecycle and any steering belongs to that run.
High-level automation APIs return a `RunResult` only when they explicitly own an activity interval. The TypeScript and Python SDK `run()` methods collect from the submitted message's durable inbox receipt through the next whole-agent `idle`; their final response is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The Python SDK also reports the last root turn's reason kind as the run-level [`finish_reason`](../bug-fix/2026-08-11-owned-run-finish-reason.md), without attributing it to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may report a result because its caller owns the complete child lifecycle and any steering belongs to that run.
ACP must return a protocol `stopReason`. Its bridge serializes one in-flight prompt per ACP session, waits for whole-agent idle, and otherwise reports the generic `end_turn`. Token-limit endings are not attributed to the prompt: they settle as `end_turn`. A model error on the prompt's correlated turn does reject the prompt immediately (the error is attributed by its owning turn), and a turnless slot (admission discarded the prompt) settles as `cancelled` at idle alongside explicit ACP cancellation or disposal.
@@ -33,10 +33,10 @@ Goal continuation retains `MessageId` only to recognize its durable queued and a
## Verification
- Agent and inbox tests pin enqueue-only follow-up, durable admission or cancellation, and whole-agent idle observation.
- SDK protocol, TypeScript SDK, and Python SDK tests pin the `{ messageId }` receipt, `session.status`, the absence of `session.finished`, and receipt-to-idle `RunResult` collection without prompt-level `status` or `reason`.
- SDK protocol, TypeScript SDK, and Python SDK tests pin the `{ messageId }` receipt, `session.status`, the absence of `session.finished`, and receipt-to-idle `RunResult` collection without prompt-level `status` or `reason`; Python SDK tests separately pin its run-level `finish_reason` observation.
- ACP, one-shot CLI, goal continuation, and subagent tests pin the distinct activity ownership each integration possesses.
- Consumer tests pin that no production integration derives a follow-up result by correlating `MessageId` with `turn/end`.
## Consequences
An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications disappear from SDK and ACP results; callers that need those facts must inspect the durable event stream without claiming causal attribution. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-prompt result.
An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response, finish reason, and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications remain absent from SDK and ACP results; callers may inspect run-level or durable event facts without claiming causal attribution. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-prompt result.

View File

@@ -16,7 +16,7 @@ Status: implemented
底层 SDK 协议在入队成功后立即以 `{ messageId }` 响应 `session/prompt`。它通过 `session.event` 流式传输持久事实,通过 `session.status` 发布整个 agent 的状态转换,且不包含 `session.finished`。底层客户端可以观察该回执和之后的 idle但不会收到提示词结果。
只有明确拥有一个活动区间时,高层自动化 API 才返回 `RunResult`。TypeScript 和 Python SDK 的 `run()` 方法从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 `idle`;其 `finalResponse` 是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。单次 CLI命令行界面拥有相应的 idle 到 idle 区间。隔离的子 agent 运行可以报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。
只有明确拥有一个活动区间时,高层自动化 API 才返回 `RunResult`。TypeScript 和 Python SDK 的 `run()` 方法从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 `idle`;其最终响应是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。Python SDK 还把根会话最后一个轮次的结束原因 kind 作为运行级 [`finish_reason`](../bug-fix/2026-08-11-owned-run-finish-reason.md) 返回,但不会将其归因于已提交的提示词。单次 CLI命令行界面拥有相应的 idle 到 idle 区间。隔离的子 agent 运行可以报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。
ACPAgent Client Protocol必须返回协议规定的 `stopReason`。其桥接层串行处理每个 ACP 会话中唯一一个正在处理的提示词,等待整个 agent 进入 idle其他情况均报告通用的 `end_turn`。token 上限的轮次结束不归因于提示词:它们以 `end_turn` 结算。与该提示词关联的轮次上的模型错误会立即以该错误拒绝提示词(错误按其所属轮次归因),而 turnless 槽位(准入已丢弃提示词)会在 idle 时以 `cancelled` 结算,与显式 ACP 取消或 dispose资源释放并列。
@@ -33,10 +33,10 @@ Goal 续行只保留 `MessageId`,用于识别持久排队和已准入的 goal
## 验证
- Agent 与 inbox 测试固定 follow-up 仅入队、持久准入或取消以及整个 agent 的 idle 观测。
- SDK 协议、TypeScript SDK 和 Python SDK 测试固定 `{ messageId }` 回执、`session.status`、不存在 `session.finished`,以及不含提示词级 `status``reason` 的回执到 idle `RunResult` 收集。
- SDK 协议、TypeScript SDK 和 Python SDK 测试固定 `{ messageId }` 回执、`session.status`、不存在 `session.finished`,以及不含提示词级 `status``reason` 的回执到 idle `RunResult` 收集Python SDK 测试另行固定其运行级 `finish_reason` 观测
- ACP、单次 CLI、goal 续行和 subagent 测试固定各集成实际拥有的不同活动边界。
- 消费方测试固定生产集成都不会通过关联 `MessageId``turn/end` 来推导 follow-up 结果。
## 后果
自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作因此其最终响应和事件有意比初始消息涵盖更广。SDK 和 ACP 结果不包含提示词级模型错误和 token 上限分类;需要这些事实的调用方必须检查持久事件,但不能声称这些事实具有因果归属。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的按提示词结果。
自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作因此其最终响应、结束原因和事件有意比初始消息涵盖更广。SDK 和 ACP 结果不包含提示词级模型错误和 token 上限分类;调用方可以检查运行级或持久事件事实,但不能声称这些事实具有因果归属。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的按提示词结果。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
2026-08-05-profile-plugin-bundles.md: 54626e3f48a2ba7db19813e6e883f0e77499d0e2
2026-08-05-profile-plugin-bundles.zh.md: 357e0f63d4eba0f0985c9e14aad54595c7b41c77
2026-08-05-profile-plugin-bundles.md: ffef7b67a11617599674e0515e16bfd5283d8445
2026-08-05-profile-plugin-bundles.zh.md: b190d5e834ba3ce8b719a4a3e61f256eb2c0d82b

View File

@@ -22,7 +22,7 @@ Two supporting refactors: the webserver's built-in static dist serving became th
- **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan.
- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony.
- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-provided host slots (`ctx.cmdlineArgs`, `ctx.appExit`, and the environment snapshot) are provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
- **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file.
## Consequences

View File

@@ -22,7 +22,7 @@ Status: implemented
- **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式没有暗中扫描。
- **内置组合包使用 `link:` 条目**pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。
- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dumpmanifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot在任何配置树条目挂载之前,于 `boot()``prepare` 钩子中提供。
- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dumpmanifest 保持纯数据。启动器提供的宿主 slot`ctx.cmdlineArgs``ctx.appExit` 与环境快照)在任何配置树条目挂载之前,于 `boot()``prepare` 钩子中提供。
- **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。
## Consequences

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md
2026-08-06-app-owned-command-line.md: 4a05cac5ed7f44fb55c2d4498bf28a43befdb073
2026-08-06-app-owned-command-line.zh.md: 86a37f416d17c4615152b29d73f171803f24c4c3
2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920
2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b

View File

@@ -16,18 +16,17 @@ The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `p
The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset.
The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change.
The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family, and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change.
Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup.
## Why Loader owns the ordering
Four framework facts shape the mechanism:
Three framework facts shape the mechanism:
- **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context.
- **A profile's rows arrive inside the root include's `patches` option.** Include declares the `EntryGroup.key` tree-carrier marker (as Group does), so Loader keeps its config — entry and patch lists, including Include's own `path` — literal instead of recursively evaluating nested `!!js` nodes in the Include context; each expression resolves in its target row's fiber.
- **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services.
- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services.
- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain). Enablement is an in-memory Loader override rather than an options rewrite, so Include reapplication cannot silently disable it. The Web bundle also starts client discovery only after enabling the optional row, ensuring the first browser graph already contains its HMR receiver.
This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services.
@@ -43,7 +42,7 @@ This leaves dependency ordering in Cordis activation and Loader interpolation, w
## Consequences
- An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change.
- The launcher still recognizes the headless runner for one-shot process lifetime and the telemetry row for its environment switch; neither path interprets app arguments.
- The launcher recognizes no app row at all: the telemetry row remains its only composition probe (for the environment switch), SIGTERM exits 0 on every surface, every boot watches its user patch layers, and the one-shot runner exits through `ctx.appExit` like any other app.
- `--help` leaves every row that depends on the provider's service pending and requests bounded exit; unrelated rows may activate concurrently before teardown.
- An app-owned service has no statically declared provider: a bundle shipping consumer rows without that provider fails at settlement with pending entries naming the service, not at load.
- A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row.

View File

@@ -16,18 +16,17 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍
boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。
已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行)`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag启动器毫无改动。
已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族,`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag启动器毫无改动。
还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web``dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。
## 为什么由 Loader 持有顺序
条框架事实塑造了这套机制:
条框架事实塑造了这套机制:
- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值
- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 声明了 `EntryGroup.key` 树载体标记(与 Group 相同),因此 Loader 让它的配置——条目与 patch 列表,包括 Include 自己的 `path`——保持字面值,而不是在 Include 上下文中递归求值嵌套的 `!!js` 节点;每个表达式都在其目标行的 fiber 中解析
- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfallCordis 快照注入服务之后Loader 的监听器再插值原始配置。
- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfallHMR 会把原始配置带给替换 fiber而待处理行可以接受选项变更不会针对缺失服务提前求值表达式。
- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路)。启用采用 Loader 的内存覆盖而非改写选项,因此 Include 重新应用配置时不会悄然将其禁用。Web 组合包还会在启用可选行之后才启动客户端发现,确保首份浏览器图中已经包含 HMR 接收端。
这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。
@@ -43,7 +42,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活Lo
## 后果
- 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。
- 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数
- 启动器完全不识别任何应用行telemetry 行仍是它唯一的组合探测用于环境开关SIGTERM 在所有 surface 上以 0 退出,每次启动都监视用户 patch 层,一次性 runner 像任何应用一样经 `ctx.appExit` 退出
- `--help` 会让所有依赖提供方服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。
- 应用自有服务没有静态声明的提供方:交付了消费行却缺少对应提供方的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。
- 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md
2026-08-07-shared-feedback-telemetry-user-id.md: 6d4020828cb1f2ab3de0328c8959a18a0fcfe6c4
2026-08-07-shared-feedback-telemetry-user-id.zh.md: 892fa0f848d656609885d008ab36e3ebbe09b992
2026-08-07-shared-feedback-telemetry-user-id.md: 5ef487646c94808177e1263b94b8c23a9a045d97
2026-08-07-shared-feedback-telemetry-user-id.zh.md: 1ee433396f83a8caf5e35c9cfd1b4f3c04d6c59f

View File

@@ -14,7 +14,7 @@ The earlier [anonymous-user-id decision](../feature/2026-07-31-telemetry-anonymo
`@deepseek-ai/dsh-user-id` owns `getOrCreateAnonymousUserId()` and the `$DSH_HOME/.userid` storage contract. `session-telemetry-otel` uses the returned id as OpenTelemetry Resource `user.id`; the `/feedback` success acknowledgement reports `Feedback recorded for session {sessionId}` followed by `User: {userId}` on a second line, which keeps both identifiers available through the generic command row's expandable body. Invalid feedback is rejected before resolving the id, so an empty command does not create `.userid`.
The extraction preserves the existing random UUID, home resolution, process memo, exclusive-create concurrency, corruption replacement, and best-effort write semantics. It does not unify the dsh-sdk launcher's separate `telemetry.json` identity.
The extraction preserves the existing random UUID, home resolution, process memo, exclusive-create concurrency, corruption replacement, and best-effort write semantics.
## Alternatives considered
@@ -23,7 +23,6 @@ The extraction preserves the existing random UUID, home resolution, process memo
| Import the helper from `session-telemetry-otel` | Couples feedback to an optional exporter backend and forms a reverse dependency cycle once telemetry exports feedback |
| Duplicate the persistence helper in feedback | Two implementations of one file contract can drift and race with different validation or failure semantics |
| Generate a separate feedback user id | The acknowledgement could not correlate with the OTel Resource and would not satisfy the reporting purpose |
| Move the launcher telemetry id too | The launcher feed is not a consumer of `.userid`; unifying unrelated stores remains out of scope |
## Consequences

View File

@@ -14,7 +14,7 @@ OpenTelemetry 后端已在 `$DSH_HOME/.userid` 中持久化一个匿名 UUID。`
`@deepseek-ai/dsh-user-id` 负责 `getOrCreateAnonymousUserId()``$DSH_HOME/.userid` 存储契约。`session-telemetry-otel` 将返回的 id 用作 OpenTelemetry Resource 的 `user.id``/feedback` 的成功确认先报告 `Feedback recorded for session {sessionId}`,再在第二行显示 `User: {userId}`,使两个标识符都可通过通用命令行的可展开正文查看。系统在获取 id 前拒绝无效反馈,因此空命令不会创建 `.userid`
此次抽取保留既有的随机 UUID、home 解析、进程内缓存、独占创建并发、损坏文件替换与 best-effort 写入语义。它不会统一 dsh-sdk launcher 独立的 `telemetry.json` 身份。
此次抽取保留既有的随机 UUID、home 解析、进程内缓存、独占创建并发、损坏文件替换与 best-effort 写入语义。
## 考虑过的替代方案
@@ -23,7 +23,6 @@ OpenTelemetry 后端已在 `$DSH_HOME/.userid` 中持久化一个匿名 UUID。`
| 从 `session-telemetry-otel` 导入辅助函数 | 使反馈耦合到可选的导出后端,并在遥测导出反馈后形成反向依赖环 |
| 在反馈中复制持久化辅助函数 | 同一文件契约的两份实现可能发生偏差,并因校验或失败语义不同而产生竞态 |
| 生成独立的反馈用户 id | 确认文本无法与 OTel Resource 相关联,因而不能达到报告目的 |
| 同时移动 launcher telemetry id | launcher 回流不是 `.userid` 的消费方;统一无关存储仍不在范围内 |
## 后果

View File

@@ -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 .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md
2026-08-11-cmdline-seam-trim.md: d7908d2c80552f13500ccd36c59a249f1374cbb1
2026-08-11-cmdline-seam-trim.zh.md: 275a32b584afdbcc77d2356775f35c74786445ab

View File

@@ -0,0 +1,31 @@
# Agent Note: Trim the command-line seams to existing interfaces
Status: implemented
English | [中文](2026-08-11-cmdline-seam-trim.zh.md)
## Problem
The app-owned command line ([note](2026-08-06-app-owned-command-line.md)) shipped with three seams that were wider than their consumers needed: a vendored in-memory row-activation state machine (`Entry.enableRuntime` plus `enableRow` exported from `dsh-cmdline`, a command-line package owning a Loader concept) whose only purpose was the `--dev` conditional reload row, a vendored `EntryConfigResolver` protocol symbol whose only implementer was Include, and a launcher that still recognized the `headless-runner` row to pick SIGTERM exit codes, gate user-patch watching, and provide a `headlessIo` seam duplicating `ctx.appExit`.
## Decision
Express all three with interfaces that already exist:
- **No conditional dev row.** The reload chain stops being conditional: `dsh-web-app` mounts the `client-hmr` row unconditionally and `--dev` is deleted, along with the web runtime's `mode` config, the mode-forked prompt contract, and the `DSH_WEB_MODE` bash variable. Without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the chain polls unchanged files and stays idle, so the always-on row costs one stat-poll interval and an SSE route. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted with nothing replacing them.
- **Tree-carrier config.** Include declares the existing `EntryGroup.key` marker instead of implementing `EntryConfigResolver`; the Loader hook keeps every tree carrier's config literal. Include's own `path` loses `!!js` support — no configuration ever used it, and the pinning test now asserts the literal tree-carrier contract instead.
- **Launcher app-knowledge.** The launcher recognizes no app row. SIGTERM is a supervisor's ordinary stop request and exits 0 on every surface (SIGINT stays 130); the launcher cannot know whether the app considered its work complete, and the previous 143 depended on naming the headless row. Every boot watches its user patch layers — a one-shot surface exits through bounded shutdown, which disposes the watchers before the loop drains. The headless runner exits through `ctx.appExit` like any other app; its output streams are a package-internal `internals` test seam, and `ctx.headlessIo` is deleted.
## Alternatives considered
- **Keeping `enableRuntime` but moving `enableRow` out of `dsh-cmdline`**: relocation fixes the package boundary but keeps the vendored state machine whose semantics (survives reapplication, rollback on failure) must be re-derived at every upstream sync.
- **`entry.update({ disabled: null })`**: mutates the entry's serialized options, so the next include reapplication restores `disabled: true` and unmounts the row mid-session.
- **SIGTERM 143 for one-shot surfaces via an app-registered signal handler**: the launcher's own handler races it for the exit code; winning that race needs a new launcher interface, which is the cost this change removes.
- **Keeping `--dev` with the row created at runtime**: an interim state of this change; it still needed a mode fork in the prompt contract, a `DSH_WEB_MODE` variable, and creation-versus-user-row arbitration, all to avoid an idle poll whose cost is negligible.
## Consequences
- A deployment that supervises `dsh --profile headless` with SIGTERM now observes exit 0 instead of 143; the caller sent the signal and sees no answer on stdout.
- The reload chain runs in every `dsh web` process; a deployment that must not expose `/plugins/events` disables the `client-hmr` row in its patch layer.
- One-shot runs mount the config-watch rows they previously skipped, costing a few milliseconds of startup.
- The vendored Loader/Include divergence shrinks by one protocol symbol and one state machine, and `rescope-vendor:check` passes again (the modification log's rescope entry is restored to the position its exact-edit anchor requires).

View File

@@ -0,0 +1,31 @@
# Agent Note把命令行接缝收窄到既有接口
Status: implemented
[English](2026-08-11-cmdline-seam-trim.md) | 中文
## 问题
应用自有命令行([笔记](2026-08-06-app-owned-command-line.md))交付时带着三条比其消费者所需更宽的接缝:一台 vendored 的内存行激活状态机(`Entry.enableRuntime`,外加从 `dsh-cmdline` 导出的 `enableRow` —— 一个命令行包拥有了 Loader 概念),其唯一用途是 `--dev` 条件重载行、一个只有 Include 一个实现者的 vendored `EntryConfigResolver` 协议符号,以及仍然识别 `headless-runner` 行的启动器 —— 用它选择 SIGTERM 退出码、门控用户 patch 监视,并提供与 `ctx.appExit` 重复的 `headlessIo` 接缝。
## 决策
三者全部改用已经存在的接口表达:
- **不再有条件 dev 行。** 重载链不再是条件性的:`dsh-web-app` 无条件挂载 `client-hmr` 行,`--dev` 连同 web runtime 的 `mode` 配置、按模式分叉的提示词约定和 `DSH_WEB_MODE` bash 变量一并删除。没有重建 watcher`pnpm run dev:web`)改写客户端 bundle 时,链路轮询到的文件从不变化、保持空闲,因此常开的行只花费一个 stat 轮询间隔和一条 SSE 路由。`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 删除后无任何替代物。
- **树载体配置。** Include 改为声明已有的 `EntryGroup.key` 标记,不再实现 `EntryConfigResolver`Loader 钩子让每个树载体的配置保持字面值。Include 自己的 `path` 失去 `!!js` 支持 —— 从未有配置用过它,固定该行为的测试改为断言字面值树载体约定。
- **启动器的应用知识。** 启动器不再识别任何应用行。SIGTERM 是监督进程的普通停止请求,在所有 surface 上以 0 退出SIGINT 仍为 130启动器无从知道应用是否认为工作已完成而之前的 143 依赖于点名 headless 行。每次启动都监视用户 patch 层 —— 一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器再排空事件循环。headless runner 像任何应用一样经 `ctx.appExit` 退出;其输出流是包内 `internals` 测试接缝,`ctx.headlessIo` 删除。
## 考虑过的替代方案
- **保留 `enableRuntime` 但把 `enableRow` 移出 `dsh-cmdline`**:搬迁修正了包边界,却保留了 vendored 状态机,其语义(在重新应用后仍生效、失败时回滚)在每次上游同步时都要重新推导。
- **`entry.update({ disabled: null })`**:改写条目的序列化选项,下一次 include 重新应用会恢复 `disabled: true` 并在会话中途卸载该行。
- **通过应用注册的信号处理器为一次性 surface 保留 SIGTERM 143**:启动器自己的处理器会与它竞争退出码;要赢得竞争需要新的启动器接口,而这正是本次变更要移除的成本。
- **保留 `--dev`、改为运行时创建该行**:本次变更的中间形态;它仍需要提示词约定里的模式分叉、`DSH_WEB_MODE` 变量,以及创建与用户自有行之间的仲裁,而这一切只为省下一个成本可忽略的空闲轮询。
## 后果
- 用 SIGTERM 监督 `dsh --profile headless` 的部署现在观察到退出码 0 而非 143信号是调用方自己发的且 stdout 上没有答案。
- 重载链在每个 `dsh web` 进程中运行;不得暴露 `/plugins/events` 的部署应在其 patch 层禁用 `client-hmr` 行。
- 一次性运行会挂载之前跳过的配置监视行,启动多花几毫秒。
- vendored Loader/Include 偏差减少一个协议符号和一台状态机,`rescope-vendor:check` 重新通过(修改日志的 rescope 条目回到其精确编辑锚点要求的位置)。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md
2026-07-28-web-gui-feedback-loop.md: aa488e1df087722c072d98c67d47cdf63a42f6b8
2026-07-28-web-gui-feedback-loop.zh.md: 9b6954092b737920ed18bc412037e917512e40dc
2026-07-28-web-gui-feedback-loop.md: fa7fcee80dc91ad7ec4a9a994927daa2cc293baa
2026-07-28-web-gui-feedback-loop.zh.md: ea83441efa83ad0c93e8cb1f0daf71ecfeabb69e

View File

@@ -12,9 +12,9 @@ The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedba
## Decision
The ordinary `dsh web` composition mounts the Web bundle's `web-runtime` plugin, which publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. A complete-prompt profile sets the row's `surfaceContext` to false and receives neither the prompt section nor the managed variables; the Web launcher uses the same setting to suppress its source-checkout prompt section.
The ordinary `dsh web` composition mounts the Web bundle's `web-runtime` plugin, which publishes one canonical loopback URL as both model-visible orientation and a managed shell fact. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` carries the same fact into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. A complete-prompt profile sets the row's `surfaceContext` to false and receives neither the prompt section nor the managed variable; the Web launcher uses the same setting to suppress its source-checkout prompt section.
The mode-specific prompt makes the agent, rather than the user, own the hidden startup contract. Production mode defines acceptance as rebuilding the affected artifacts and refreshing the existing URL. Development mode states that `dsh web --dev` activates only the HMR receiver: automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuild plus refresh. An agent in production mode explains both commands when a user requests no-refresh updates; it does not launch a replacement GUI unless asked.
The prompt makes the agent, rather than the user, own the hidden startup contract. The client-plugin HMR receiver is always mounted, but automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuilding the affected artifacts and refreshing the existing URL. The agent does not launch a replacement GUI unless asked.
The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged.
@@ -22,7 +22,7 @@ No server restart or replacement is required merely because static artifacts cha
## Verification
The keyless fresh-round-trip browser scenario boots the shipped production Web composition, drives a real replayed session, snapshots the URL/mode-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` and `$DSH_WEB_MODE` match the actual bound runtime. The real CLI smoke launches `dsh web --dev` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web --dev`, changes an initial production-roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement.
The keyless fresh-round-trip browser scenario boots the shipped Web composition, drives a real replayed session, snapshots the URL-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` matches the actual bound runtime. The real CLI smoke launches `dsh web` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web`, changes an initial roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement.
## Alternatives considered
@@ -30,10 +30,10 @@ The keyless fresh-round-trip browser scenario boots the shipped production Web c
**Remove the `apps/web` development script without guarding Vite.** Rejected because `npx vite`, the exact incident command, bypasses package scripts. Serve mode itself must fail.
**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and plugin HMR has a separate explicit `dsh web --dev` composition.
**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and client-plugin reload is owned by the always-mounted HMR chain plus the `pnpm run dev:web` watcher.
**Send DOM, route, or screenshots with each request.** Deferred to a separate logged-input design. Stable URL identity closes this feedback loop without claiming browser state the host does not receive.
## Consequences
Ordinary Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Their Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one. Profiles that disable `surfaceContext` also give up this feedback-loop guidance and shell context.
Ordinary Web prompts gain a dynamic URL paragraph, so provider prefix reuse now varies by bound port. Their Bash processes gain one non-secret managed environment variable. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one. Profiles that disable `surfaceContext` also give up this feedback-loop guidance and shell context.

View File

@@ -12,9 +12,9 @@ Web agent智能体既无法识别承载当前会话的 GUI也不知道
## 决策
常规 `dsh web` 组合会挂载 Web 组合包的 `web-runtime` 插件,由它发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI并给出 URL`DSH_WEB_URL``DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界也不声称局域网别名等于浏览器中的实际地址。拥有完整提示词的 profile 会把该配置行的 `surfaceContext` 设为 false并且不会收到该提示词段和这些受管变量中的任何一个Web 启动器也会使用同一项设置来抑制其源码 checkout 提示词段。
常规 `dsh web` 组合会挂载 Web 组合包的 `web-runtime` 插件,由它发布一个规范的回环 URL同时将作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI并给出 URL`DSH_WEB_URL` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界也不声称局域网别名等于浏览器中的实际地址。拥有完整提示词的 profile 会把该配置行的 `surfaceContext` 设为 false并且不会收到该提示词段和受管变量Web 启动器也会使用同一项设置来抑制其源码 checkout 提示词段。
按模式区分的提示词让 agent 而非用户负责隐藏的启动约定。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明`dsh web --dev` 只会启用 HMR热模块替换接收端客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。
提示词让 agent 而非用户负责隐藏的启动约定。HMR热模块替换接收端始终挂载,但客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建受影响的产物并刷新现有 URL。除非用户要求,agent 不会启动替代 GUI。
`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR 入口路径。Vite 构建模式保持不变。
@@ -22,7 +22,7 @@ Web agent智能体既无法识别承载当前会话的 GUI也不知道
## 验证
无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL``$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发约定。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。
无密钥的 fresh-round-trip 浏览器场景会启动已交付的 Web 组合,驱动真实的回放会话,对包含 URL 的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web` 并捕获模型提供方请求,从而固定完整的双命令开发约定。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle浏览器 HMR 场景会启动 `dsh web`,修改初始 roster 中的 bundle并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。
## 考虑过的替代方案
@@ -30,10 +30,10 @@ Web agent智能体既无法识别承载当前会话的 GUI也不知道
**删除 `apps/web` 开发脚本,但不为 Vite 添加防护。** 不予采纳,因为事故中实际使用的命令 `npx vite` 会绕过包脚本。服务模式本身必须失败。
**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而插件 HMR 已有独立且显式的 `dsh web --dev` 组合
**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而客户端插件重载由始终挂载的 HMR 链路加 `pnpm run dev:web` watcher 负责
**每次请求都发送 DOM、路由或截图。** 推迟到另行设计的已记录输入机制。稳定的 URL 身份足以闭合本次反馈循环,同时不会声称宿主掌握其未接收的浏览器状态。
## 影响
常规 Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。相应的 Bash 进程会增加个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱开发者应改用完整宿主或构建模式。作为交换GUI 工作有了一个可由机制观察的唯一目标agent 可以向用户说明实际承载其会话的进程究竟如何更新不受支持的启动路径也会在出现白屏前失败。URL/模式约定会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。禁用 `surfaceContext` 的 profile 也会放弃这项反馈闭环指引与 shell 上下文。
常规 Web 提示词会增加一个动态 URL 段落,因此模型提供方的前缀复用会随绑定端口变化。相应的 Bash 进程会增加个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱开发者应改用完整宿主或构建模式。作为交换GUI 工作有了一个可由机制观察的唯一目标agent 可以向用户说明实际承载其会话的进程究竟如何更新不受支持的启动路径也会在出现白屏前失败。URL 约定会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。禁用 `surfaceContext` 的 profile 也会放弃这项反馈闭环指引与 shell 上下文。

View File

@@ -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 .agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md
2026-08-10-pre-plugin-theme-bootstrap.md: 276d564dcef60e5130b4b2d0ef7cbc12eeb8b6d6
2026-08-10-pre-plugin-theme-bootstrap.zh.md: 7162b2d851b50f75b3af6320ad9348383933e50f

View File

@@ -0,0 +1,37 @@
# Agent Note: Pre-Plugin Theme Bootstrap
Status: implemented
English | [中文](2026-08-10-pre-plugin-theme-bootstrap.zh.md)
## Problem
The web shell renders `Loading plugins…` before the browser-side plugin tree activates. The theme tokens are already loaded with the shell styles, but `color-scheme` and `body[data-ds-dark-theme]` are not written until ui-theme's ThemeService and ui-layout's ThemePresenter activate; with a persisted dark preference, the loading page therefore renders first with the light palette and then switches to dark.
`dshClient.immediately` only includes the bundle in first-stage prefetching; it does not cause the plugin to execute before HTML parsing or the shell's initial render. Changing only the client plugin's loading tier cannot close this window.
## Decision
ui-theme's host half transforms each index HTML document through `ctx.httpServer.tapIndex()`, inserting a synchronous inline script immediately after the opening `<body>` tag. The transform registers under an optional `httpServer` injection, so compositions without that service still activate ui-theme and install no transform. When the HTML parser executes the script, the body exists, but the shell's module script and React root have not yet run.
The host half registers the [`ui-theme.preference` settings section](2026-08-06-host-backed-web-preferences.md) when a settings provider exists. For each index response, it embeds that schema-validated built-in preference in the inline script; without a settings provider or active registration, it embeds the `system` default. The browser resolves `system` through `prefers-color-scheme`, falling back to light when `matchMedia` is unavailable. It writes only the two pieces of DOM state that ThemePresenter later owns: `document.documentElement.style.colorScheme` and `body[data-ds-dark-theme]`.
The bootstrap logic recognizes only the built-in `light`, `dark`, and `system` semantics. It registers no listeners and does not resolve third-party themes or token overrides. After the browser-side plugin tree activates, ThemeService remains authoritative for theme state, and ThemePresenter writes the complete resolved result back to the same DOM state and owns subsequent updates and disposal.
## Verification
ui-theme's unit tests cover activation without either optional Host service, the script position, Host-setting precedence, the OS preference, missing `matchMedia`, input without a body, live settings reads, and disposal of the Host registrations with the plugin fiber. A Chromium scenario for the real web composition selects the durable dark preference, holds the plugin bundle request open to keep the loading page observable, then asserts that the index response produces a dark background, the body attribute, and the root element's `color-scheme`. The change does not alter the accessibility tree, so it produces no new page golden.
## Alternatives considered
**Hard-code the logic in `apps/web/index.html`.** This would run at the same point, but static HTML cannot embed the current Host setting and would duplicate the preference resolution and DOM fields owned by ui-theme. The Host transform follows the theme plugin's lifecycle and keeps the application shell unaware of the theme domain.
**Make the ui-theme client bundle synchronous or activate it earlier.** `immediately` controls only prefetching; plugin instantiation still occurs after the shell starts running. Blocking the initial render until ThemeService activates would delay the visible loading and error screens and make the shell depend on the plugin tree it monitors to render failures.
**Rely only on CSS `prefers-color-scheme`.** Media queries cannot read an explicit persisted choice, so a user who selects dark while the operating system uses light would still see a flash.
**Run in `<head>` and add a temporary class to html.** The body does not exist yet, and this would require a set of temporary selectors separate from the final palette attributes. Immediately after `<body>` is the earliest parse position that can write the final DOM fields directly.
## Consequences
The loading page's first frame matches the durable built-in preference and defaults to the OS preference when no settings provider is composed. The index transform reads Host settings for every response, while the inline script contains only the selected built-in value and `system` resolution. Changes to the built-in preference semantics or ThemePresenter DOM fields must update both the script and ThemeService. A custom theme still applies fully only after the browser plugins activate; during the loading interval, the page uses the light or dark base palette to which that theme resolves.

View File

@@ -0,0 +1,37 @@
# Agent Note: 插件激活前的主题引导
Status: implemented
[English](2026-08-10-pre-plugin-theme-bootstrap.md) | 中文
## Problem
Web 壳在浏览器侧插件树激活前呈现 `Loading plugins…`。主题 token 已随壳样式加载,但 `color-scheme``body[data-ds-dark-theme]` 要等 ui-theme 的 ThemeService 与 ui-layout 的 ThemePresenter 激活后才写入;持久化偏好为深色时,加载页因此先按浅色调色板绘制,再切为深色。
`dshClient.immediately` 只把 bundle 纳入第一阶段预取,不会让插件在 HTML 解析或壳首次渲染前执行。仅调整客户端插件的加载档位无法关闭这段时间窗口。
## Decision
ui-theme 的主机侧通过 `ctx.httpServer.tapIndex()` 转换每份 index HTML`<body>` 起始标签后紧接一段同步内联脚本。该转换通过可选的 `httpServer` 注入注册,因此不含该服务的组合仍会激活 ui-theme但不会安装转换。HTML 解析器执行该脚本时body 已存在,而壳的模块脚本与 React 根节点尚未运行。
settings provider 存在时,主机侧会注册 [`ui-theme.preference` settings 分节](2026-08-06-host-backed-web-preferences.md)。它为每份 index 响应把经过 schema 校验的内建偏好嵌入内联脚本;不存在 settings provider 或有效注册时则嵌入默认值 `system`。浏览器通过 `prefers-color-scheme` 解析 `system`,不支持 `matchMedia` 时回退为浅色。脚本只写 ThemePresenter 后续拥有的两项 DOM 状态:`document.documentElement.style.colorScheme``body[data-ds-dark-theme]`
引导逻辑只认识内建的 `light``dark``system` 语义,不注册监听器,也不解析第三方主题或 token 覆盖。浏览器侧插件树激活后ThemeService 仍是主题状态的权威来源ThemePresenter 会把完整解析结果重新写入同一组 DOM 状态并负责后续更新与释放。
## Verification
ui-theme 的单元测试覆盖不含任一可选 Host 服务时的激活、脚本位置、Host 设置优先级、系统偏好、缺少 `matchMedia`、不含 body 的输入、实时读取 settings以及 Host 注册随插件 fiber 一同释放。真实 Web 组合的 Chromium 场景会选择持久化深色偏好并拦住插件 bundle 请求,使加载页保持可观察,再断言 index 响应产生了深色背景、body 属性和根元素 `color-scheme`。该变化不改变可访问性树,因此不产生新的页面 golden。
## Alternatives considered
**把逻辑固定写进 `apps/web/index.html`。** 这样能在相同时机执行,但静态 HTML 无法嵌入当前 Host 设置,还会复制 ui-theme 拥有的偏好解析和 DOM 字段Host 转换会跟随主题插件的生命周期,并让应用壳无需了解主题领域。
**让 ui-theme 客户端 bundle 同步或更早激活。** `immediately` 只控制预取,插件实例化仍发生在壳开始运行之后;把首次渲染阻塞到 ThemeService 激活会延后可见的加载与报错界面,也会让壳的故障呈现依赖被它监测的插件树。
**只依赖 `prefers-color-scheme` 的 CSS。** 媒体查询无法读取显式持久化选择,因此操作系统为浅色而用户选择深色时仍会闪烁。
**在 `<head>` 中执行并给 html 添加临时类。** body 此时尚不存在,还需要一套与正式调色板属性不同的临时选择器。紧接 `<body>` 是能够直接写正式 DOM 字段的最早解析位置。
## Consequences
加载页首帧与持久化内建偏好一致;未组合 settings provider 时则默认采用系统偏好。index 转换会为每份响应读取 Host settings而内联脚本只包含选定的内建值与 `system` 解析逻辑。内建偏好语义或 ThemePresenter DOM 字段变化时,必须同时更新脚本与 ThemeService。自定义主题仍会在浏览器插件激活后才完整应用加载期间页面使用该主题解析后的浅色或深色基础调色板。

View File

@@ -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 .agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.md
2026-08-11-owned-run-finish-reason.md: 87a158c7fedc8c6447306c489ba9f969d95dc412
2026-08-11-owned-run-finish-reason.zh.md: d75b7d4f0c932ec2d3665b8911835e3b081af129

View File

@@ -0,0 +1,33 @@
# Agent Note: Owned-run finish reason reporting
Status: implemented
English | [中文](2026-08-11-owned-run-finish-reason.zh.md)
## Problem
Python SDK consumers need a concise classification of how an owned activity interval reached idle. Requiring each consumer to scan raw `turn/end` events duplicates protocol knowledge, while a generic success status loses token-limit and model-error distinctions.
## Decision
`RunResult.finish_reason` is the string `kind` from the last root-session `turn/end` collected between the submitted message's durable inbox receipt and the next whole-agent idle. It is `None` when the interval contains no `turn/end`. A `turn/end` without a string `data.reason.kind` raises `SdkProtocolError` instead of being reported as an interval without a turn ending. The field describes the owned run interval; it does not assign that ending to the submitted prompt. The [owned-run boundary decision](../architecture/2026-07-30-followup-enqueue-and-owned-runs.md) continues to prohibit prompt-level result attribution.
The field exposes only the kind because callers need a stable classification and the complete structured reason remains available in `RunResult.events`. Transport loss, timeout, and protocol failures still raise instead of producing a finish reason.
## Alternatives considered
**Restore `status`.** A deployment-mapped `ok` or `error` status conflates distinct durable endings and resembles transport success, so it does not answer why the interval finished.
**Expose a model `FinishReason`.** A run may contain multiple model steps, and intermediate `tool-calls` endings do not finish the run. The agent's last `turn/end` is the relevant run-level observation.
**Call the field `stop_reason`.** ACP and subagent seams map turn-ending reasons into their own `stopReason` value sets. The Python field preserves the raw agent reason kind, so sharing their name would imply a mapping this interface does not perform.
**Expose the complete structured turn reason.** The raw event stream already preserves error and cancellation details. Duplicating that object on `RunResult` would create two representations that Python callers must reconcile.
## Verification
Python SDK tests cover selection of the last turn ending, an interval without a turn ending, and rejection of a malformed turn-ending reason. The SDK README documents the field's values, `None` case, failure behavior, and run-level scope.
## Consequences
Callers can branch on `completed`, `max-tokens`, `error`, and future reason kinds without parsing the event list. The field may describe steering, injected context, or queued work that joined the interval, so it must not be presented as the initiating prompt's causal outcome. The in-repo TypeScript SDK exposes the finish-reason observation only through its typed events; its callers can read it directly from `SessionEvent[]`.

View File

@@ -0,0 +1,33 @@
# Agent Note: 自有运行的结束原因报告
Status: implemented
[English](2026-08-11-owned-run-finish-reason.md) | 中文
## 问题
Python SDK 消费方需要简洁地判断自有活动区间如何进入 idle。要求每个消费方扫描原始 `turn/end` 事件会重复协议知识,而通用的成功状态会丢失 token 上限与模型错误之间的区别。
## 决策
`RunResult.finish_reason` 是从已提交消息进入持久 inbox 的回执开始、到整个 agent 下一次进入 idle 为止所收集的根会话最后一个 `turn/end` 的字符串 `kind`。如果该区间没有 `turn/end`,字段为 `None`。缺少字符串 `data.reason.kind``turn/end` 会抛出 `SdkProtocolError`,而不会报告为区间内没有轮次结束。该字段描述自有运行区间;它不会把这个结束原因归属于已提交的提示词。[自有运行边界决策](../architecture/2026-07-30-followup-enqueue-and-owned-runs.md)仍禁止提示词级结果归因。
该字段只公开 kind因为调用方需要稳定的分类完整的结构化原因仍可从 `RunResult.events` 取得。传输丢失、超时和协议故障仍会抛出异常,而不会生成结束原因。
## 考虑过的替代方案
**恢复 `status`。** 由部署映射的 `ok``error` 状态会混淆不同的持久结束情况,而且看起来像传输成功状态,因此无法回答区间为何结束。
**公开模型 `FinishReason`。** 一次运行可能包含多个模型步骤,中间的 `tool-calls` 结束并不代表运行结束。agent 最后一个 `turn/end` 才是相关的运行级观测。
**将字段命名为 `stop_reason`。** ACP 和 subagent seam 会把轮次结束原因映射到各自的 `stopReason` 取值集合。Python 字段保留原始的 agent 原因 kind因此沿用它们的名称会让人误以为该接口也执行了这种映射。
**公开完整的结构化轮次原因。** 原始事件流已经保留错误与取消的详细信息。在 `RunResult` 上复制这个对象会产生两种需要 Python 调用方协调的表示。
## 验证
Python SDK 测试覆盖选择最后一个轮次结束、区间内没有轮次结束以及拒绝畸形轮次结束原因。SDK README 记录字段取值、`None` 情况、失败行为和运行级范围。
## 后果
调用方无需解析事件列表,即可按 `completed``max-tokens``error` 和未来的原因 kind 分支。该字段可能描述区间内加入的 steering、注入上下文或排队工作因此不能将其表述为初始提示词的因果结果。仓库内的 TypeScript SDK 只通过类型化事件提供结束原因观测;其调用方可以直接从 `SessionEvent[]` 读取该观测。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md
2026-07-12-subagent-persona-tool-filter-and-depth.md: 2c5eaf8829e01536a2de34cc190573ff35e4ab77
2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: ae64e26d95e42ca6c933892348e22d3cbf0dc722
2026-07-12-subagent-persona-tool-filter-and-depth.md: b5b38836b987ab2ef2ceebf3a1efa7b6211b1b0e
2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 1dd5725309159a70dab0f3cd669a79460c3b1cd2

View File

@@ -53,7 +53,7 @@ The depth limit bounds recursive delegation independently of tool visibility. A
The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count.
Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations: the [SDK helper's generated subagent entries](../../../../packages/scaffold/helper/src/features/builtin/index.ts) and [JSON-RPC example](../../../../examples/jsonrpc-agent/cordis.yml) use that general policy, while the shipped interactive ACP, headless, and REPL examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`.
Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations: the [JSON-RPC example](../../../../examples/jsonrpc-agent/cordis.yml) uses that general policy, while the ACP and headless examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`.
A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior.

View File

@@ -53,7 +53,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma
有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在会话 header 中,恢复时会重新载入该 header因此重启无法降低递归计数。
每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[SDK 辅助函数生成的 subagent 条目](../../../../packages/scaffold/helper/src/features/builtin/index.ts)和 [JSON-RPC 示例](../../../../examples/jsonrpc-agent/cordis.yml)采用这项通用策略,而已交付的交互式 ACPheadless 和 REPL 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。
每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[JSON-RPC 示例](../../../../examples/jsonrpc-agent/cordis.yml)采用这项通用策略,而 ACPheadless 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。
部署可以组合深度与过滤,但数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。可见性策略固定的部署可以另外在子 agent 中 deny 委派工具。两种选择都不改变提供方的对话历史行为。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md
2026-07-21-continuable-background-subagents.md: 470b2328fd5c27e85ac2ca08b53f353f41b54fc9
2026-07-21-continuable-background-subagents.zh.md: 7288fbdf916700f054451ce6a40eca2bd90f0866
2026-07-21-continuable-background-subagents.md: 06095e644f8c7e33ce262e2cb62c7f8622b4f405
2026-07-21-continuable-background-subagents.zh.md: bcdfb67553ef7452e25fe56246f854092f720092

View File

@@ -37,7 +37,7 @@ Every later turn creates another Task. Its producer resources cover only that ac
Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the continuation manager. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. Human interaction is therefore permitted only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle.
`TaskService.start()` rejects producers when no task controller is attached. A human-facing adapter that accepts child input must therefore attach a task controller, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks-local` and `@deepseek-ai/dsh-tool-tasks` with the subagent tools. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations.
`TaskService.start()` rejects producers when no task controller is attached. A human-facing adapter that accepts child input must therefore attach a task controller, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations.
Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. `followup()` requires the caller's signal, and abort while live steering awaits request admission aborts the activation-owned controller so the provider discards the pending message and the call settles only after the child reaches quiescence. Independent cancellation requires a later message to start a separate turn instead of steering the current one.
@@ -111,8 +111,7 @@ Task records and active-run associations are process-local. Persistence makes th
- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: an absent or detached flush listener and a permanent listener failure reject with `DURABILITY_FAILED`, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is pre-turn, model-hidden, versioned, durable under the service-allocated child id, and survives blocked or throwing initial prompt admission; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above.
- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal.
- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume.
- `packages/scaffold/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions.
- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume; it also pins Task service integration and the model-facing Task controls.
- The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable.
## Consequences

View File

@@ -37,7 +37,7 @@ durable child Session
用户界面适配器打开 child 会话时,只读取持久化 transcript不会仅为展示而恢复 agent。用户输入通过继续执行管理器启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。
如果没有附加任务控制器,`TaskService.start()` 会拒绝 producer。因此接受 child 输入的用户界面适配器必须附加任务控制器,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 工具时,也会挂载 `@deepseek-ai/dsh-tasks-local``@deepseek-ai/dsh-tool-tasks`这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。
如果没有附加任务控制器,`TaskService.start()` 会拒绝 producer。因此接受 child 输入的用户界面适配器必须附加任务控制器,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。
取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次任一调用方发起取消都会中止该轮次、dispose 其 run并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`followup()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。
@@ -111,8 +111,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可
- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝循环检查点的瞬时失败可在最终确认成功后继续完成发生取消时最终检查点无论成功还是失败都由取消优先决定结果resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈agent loop、JSONL 持久化、spawnfork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task并在进入终态前 dispose 各自的 run描述符事件位于轮次前、对模型隐藏、带版本、在服务分配的 child id 下持久化,并在初始 prompt admission 阻止请求或抛出异常时仍保留取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述约定。
- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR热模块替换dispose。
- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id即使提供方可以恢复一次性模式仍保持普通的 Task 确认消息。
- `packages/scaffold/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。
- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id即使提供方可以恢复一次性模式仍保持普通的 Task 确认消息;它还固定 Task 服务集成及面向模型的 Task 控制工具
- 无密钥 ACP 快照场景 `subagent-continuable`examples/acp-agent固定模型可见的 transcript双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。
## 影响

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: e4ceeb6b1bc0b9e10fb5383454cbac1b404d4ad8
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 18e5c891f5810135b1ed5e6cfc27b94ad452c575
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: e5014689bd52992b4897b6f288d4b87dca54898b
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 78a7c23ee5bd34ce11004b49a63765a2ccf25bc4

View File

@@ -12,8 +12,8 @@ The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-jsonrpc`, the [single-exe
Three packages, layered exactly like the existing Python stack, plus one Service provider registration:
- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/scaffold/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message).
- **`@deepseek-ai/dsh-sdk-client`** (`packages/scaffold/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`).
- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message).
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`).
- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`.
- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself.

View File

@@ -12,8 +12,8 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-jsonrpc`,见[单文件
三个包,分层与既有 Python 栈完全一致,外加一个 Service provider 注册:
- **`@deepseek-ai/dsh-sdk-protocol`**`packages/scaffold/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport``dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result``SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data``JsonRpcResponseError` 拒绝Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。
- **`@deepseek-ai/dsh-sdk-client`**`packages/scaffold/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose资源释放阶梯关闭至完全停稳之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize``run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并凭据策略归调用方subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。
- **`@deepseek-ai/dsh-sdk-protocol`**`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport``dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result``SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data``JsonRpcResponseError` 拒绝Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。
- **`@deepseek-ai/dsh-sdk-client`**`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose资源释放阶梯关闭至完全停稳之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize``run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并凭据策略归调用方subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。
- **`@deepseek-ai/dsh-subagent-dsh-sdk`**`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize``env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。
- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制spawn、环境清理、进程树清理属于 `dsh-subprocess` seam`subagent-acp``ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md
2026-07-31-telemetry-anonymous-user-id.md: 75b65e9fd477d19afb3a3a25e424a7f7620099a3
2026-07-31-telemetry-anonymous-user-id.zh.md: 3db5b665f9cbbe6f884a9717afa758f22a419658
2026-07-31-telemetry-anonymous-user-id.md: 4d5d14cb63ebbf9d9f71c729da998ad9804e56bf
2026-07-31-telemetry-anonymous-user-id.zh.md: 69c48076ceb97a4bbc3cc35a2aaa3e632b1b1b06

View File

@@ -6,11 +6,11 @@ English | [中文](2026-07-31-telemetry-anonymous-user-id.zh.md)
## Problem
Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-telemetry-default-mount.md)), but the OTel Resource carried only `service.name`/`service.version` — no user-level identity at all, so the collector could neither aggregate per user nor count active users. The only prior ruling on point was an unimplemented one to derive a user id by hashing the hostname/local IP; the dsh-sdk toolchain keeps its own anonymous id (`$DSH_HOME/telemetry.json`), but that is the launcher feed's private fact, unrelated to the OTel feed. The OTel feed needed an anonymous user identity with clean semantics.
Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-telemetry-default-mount.md)), but the OTel Resource carried only `service.name`/`service.version` — no user-level identity at all, so the collector could neither aggregate per user nor count active users. The only prior ruling on point was an unimplemented one to derive a user id by hashing the hostname/local IP. The OTel feed needed an anonymous user identity with clean semantics.
## Decision
`getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. The original implementation lived inside `session-telemetry-otel` because no second real consumer existed. `/feedback` later became that consumer, so [the shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) moves ownership to `@deepseek-ai/dsh-user-id` without changing the storage, anonymity, concurrency, or loss semantics recorded here. The dsh-sdk launcher telemetry keeps its own anonymous-id store (`telemetry.json`) and remains unrelated.
`getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. The original implementation lived inside `session-telemetry-otel` because no second real consumer existed. `/feedback` later became that consumer, so [the shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) moves ownership to `@deepseek-ai/dsh-user-id` without changing the storage, anonymity, concurrency, or loss semantics recorded here.
| Ruling | Value | Rationale |
|---|---|---|
@@ -32,13 +32,12 @@ Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-tel
| Hostname/IP-hash-derived id (the prior ruling) | Reversible means not anonymous; the random UUID is semantically clean — the user ruled to supersede |
| user.id on every record's attributes (Claude Code's shape) | Touches the session-telemetry seam contract or injects per record, growing the wire; once per batch on the Resource already aggregates |
| A shared package before `/feedback` needed the id (the first cut) | At that time the only real consumer was the OTel backend; extraction became justified only when direct feedback needed the same correlation id |
| Reusing telemetry.json instead of a new file | The file name/JSON format files the identity under the launcher feed's naming; the OTel feed's identity is a standalone fact |
| AppCLIEntry reading the id and injecting via config patch | Every surface entry needs wiring; a runtime fact inside deployment config conflates the two |
| Housing it in `@deepseek-ai/dsh-paths` | paths is pure path computation with zero IO; a persisting identity capability would pollute the package boundary |
## Consequences
- One `$DSH_HOME` is one stable user in the OTel feed; separate homes are separate users by construction, with no cross-home linking mechanism.
- The OTel feed and `/feedback` share `.userid`; the launcher feed still uses `telemetry.json` and cannot be correlated with them.
- The OTel feed and `/feedback` share `.userid`.
- Deleting `.userid` resets the identity (effective next launch); on an unwritable home each process holds its own in-memory id until the home becomes writable.
- The [default-mount Note](2026-07-31-web-telemetry-default-mount.md)'s identity follow-up is closed for the anonymous-user-id part by this decision; hostname/surface dimensions, the redaction rule, and the usage-metrics track remain open.

View File

@@ -6,11 +6,11 @@ Status: implemented
## Problem
session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry-default-mount.md)),但 OTel Resource 只有 `service.name`/`service.version`没有任何用户级标识——接收端无法按用户聚合、无法数活跃用户。此前唯一相关口径是一条未实现的「hostname/本机 IP 哈希派生 user.id」裁定dsh-sdk 工具链另有自用的匿名 id`$DSH_HOME/telemetry.json`),但那是 launcher 回流的私有事实,与 OTel 回流无关。需要给 OTel 回流一个语义干净的匿名用户身份。
session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry-default-mount.md)),但 OTel Resource 只有 `service.name`/`service.version`没有任何用户级标识——接收端无法按用户聚合、无法数活跃用户。此前唯一相关口径是一条未实现的「hostname/本机 IP 哈希派生 user.id」裁定。需要给 OTel 回流一个语义干净的匿名用户身份。
## Decision
`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid``resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;后端构造时把它作为 Resource 的 `user.id`OTel semconv 标准用户属性)随每批导出携带一次。原始实现位于 `session-telemetry-otel`,因为当时不存在第二个真实消费方。`/feedback` 后来成为该消费方,因此[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)将所有权移交给 `@deepseek-ai/dsh-user-id`,但不改变本 Note 记录的存储、匿名、并发与丢失语义。dsh-sdk launcher telemetry 继续使用自己独立的匿名 id 存储(`telemetry.json`),与此身份无关。
`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid``resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;后端构造时把它作为 Resource 的 `user.id`OTel semconv 标准用户属性)随每批导出携带一次。原始实现位于 `session-telemetry-otel`,因为当时不存在第二个真实消费方。`/feedback` 后来成为该消费方,因此[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)将所有权移交给 `@deepseek-ai/dsh-user-id`,但不改变本 Note 记录的存储、匿名、并发与丢失语义。
| 裁定 | 取值 | 理由 |
|---|---|---|
@@ -32,13 +32,12 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry
| hostname/IP 哈希派生 id此前口径 | 可反查即非匿名;随机 UUID 语义干净,用户裁决取代 |
| user.id 放每条 record 的 attributesClaude Code 形态) | 要动 session-telemetry seam 约定或逐条注入wire 体积涨Resource 每批一次已满足聚合 |
| 在 `/feedback` 需要该 id 之前抽取共享包(初版实现) | 当时唯一的真实消费方是 OTel 后端;只有直接反馈需要同一个关联 id 后,抽取才具备依据 |
| 复用 telemetry.json 不新建文件 | 文件名/JSON 格式把身份挂在 launcher 链路命名下OTel 回流身份是独立事实 |
| AppCLIEntry 读好 id 经 config patch 注入 | 每个 surface 入口都要接线config 里传运行时事实与部署配置混淆 |
| 挂进 `@deepseek-ai/dsh-paths` | paths 是纯路径计算零 IO带持久化的身份能力会污染包边界 |
## Consequences
- 一个 `$DSH_HOME` 在 OTel 回流中是一个稳定用户;不同 home 在构造上就是不同用户,无跨 home 关联机制。
- OTel 回流与 `/feedback` 共享 `.userid`launcher 回流仍使用 `telemetry.json`,无法与前两者关联
- OTel 回流与 `/feedback` 共享 `.userid`
- 删除 `.userid` 即重置身份下次启动生效home 不可写时每进程各自持有一个内存 id 直至恢复可写。
- [默认挂载 Note](2026-07-31-web-telemetry-default-mount.md) 的身份 follow-up 中「匿名用户 id」项由本决定关闭hostname/surface 维度与脱敏规则、usage-metrics track 仍是待办。

View File

@@ -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 .agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.md
2026-08-01-goal-command-input-projection.md: 02836de7ea2d79122d7650d1b37406802ee1489f
2026-08-01-goal-command-input-projection.zh.md: 8c28ba1c90be5942aa526fb9d8d2d8b1f014742e

View File

@@ -0,0 +1,39 @@
# Agent Note: Goal command input projection
Status: implemented
English | [中文](2026-08-01-goal-command-input-projection.zh.md)
## Problem
Human commands execute outside the model turn and persist as `command/run` plus `command/done`. The Web transcript rendered only their result row. On a fresh session, `/goal` therefore cleared the composer and completed successfully while the page stayed on the empty hero; its result became visible only after later conversation content activated Chat. Appending an ordinary `user/message` from the handler would change model-visible history and command semantics.
## Decision
The command registry and durable command lifecycle remain unchanged. `command/run` records the parser-owned name, optional verbatim arguments, source, and invocation id; `command/done` records settlement. Neither event carries browser presentation intent.
The `ui-goal` client plugin registers a Goal-owned Conversation Definition beside the generic command Definition. Both match the same `/goal` `command/run`: the generic Definition retains the durable result row, while the Goal Definition builds a separate `command-input` Chat Node at an earlier fractional anchor. The Goal plugin also registers the keyed React renderer for that Node. Its local component copies only the user bubble's right-aligned geometry and semantic tokens, uses 14px/22px monospace text, and mounts no timestamp, copy, or branch actions.
`Session.composerPhase` treats visible non-command Chat Nodes as conversation content, so `command-input` activates the current conversation while a generic command row alone does not. The Host `summary.blank` bit remains turn-based, so list hiding and blank-session reuse do not change.
The Goal Definition derives `/<name><args.trimEnd()>` from the structured run: separator and internal multiline input survive, while the claimed bare form whose arguments contain one space displays `/goal`. A history window containing only `command/done` has no matching Goal Context, so it keeps the generic result row without inventing an input bubble; loading the older run restores both Nodes.
The model boundary is unchanged. The Goal projection creates no `user/message`, `turn/start`, `step/start`, or `request/header`. Accepted goal mutations reach the model only through the goal domain's existing `<goal_state>` snapshot or clear tombstone, independently of the command-input Node.
## Verification
Goal client tests pin the dual Definition output, ordering, other-command exclusion, bare and multiline text, done-only cuts, renderer semantics, disposal, and fresh-session phase selection. The keyless assembled Web scenario submits bare `/goal` in a fresh session with no model adapter, verifies both rows and the absence of model-surface events, then reloads and verifies the persisted transcript.
## Alternatives considered
**Append `user/message` in the `/goal` handler.** Rejected because the command would become model input and could trigger or alter a later request.
**Add presentation intent to the command registry and durable event.** Rejected because one Goal view would widen the generic command interface and make Session, Chat, and every command fixture carry browser presentation state. The existing `command/run` name and arguments already let the composed Goal client reconstruct its own view.
**Teach the generic command renderer about `/goal`.** Rejected because command-specific view construction belongs to the Goal client plugin. Composing that plugin out must remove the bubble without changing command execution or the generic result row.
**Render every command input as a user bubble.** Rejected because existing control commands deliberately leave a fresh session on the hero; changing them would broaden interaction semantics without a feature-owned Conversation Definition.
## Consequences
One durable `/goal` run feeds two independently owned view Contexts without changing the command capability. Composing `ui-goal` out leaves ordinary command execution and its result row intact. Live tabs and cold reloads agree because both views derive from the same run. A page cut that retains only `command/done` temporarily shows only the result row; if that command is the session's only content, the hero hides the row until an older page restores the run. The session remains list-hidden and reusable until a model turn starts because Host blank semantics remain turn-based.

View File

@@ -0,0 +1,39 @@
# Agent Note: Goal 命令输入投影
Status: implemented
[English](2026-08-01-goal-command-input-projection.md) | 中文
## 问题
面向用户的命令在模型轮次之外执行,并持久化为 `command/run``command/done`。Web transcript文本记录此前只渲染结果行。因此在新会话中`/goal` 会清空编辑器并成功完成,但页面仍停留在空白 Hero只有后续对话内容激活 Chat 后,结果才会显示。若处理器追加普通 `user/message`,将改变模型可见历史与命令语义。
## 决策
命令注册表与持久命令生命周期保持不变。`command/run` 记录由解析器提供的名称、可选的原样参数、来源和调用 id`command/done` 记录结算。两条事件都不携带浏览器呈现意图。
`ui-goal` 客户端插件会在通用命令 Definition 之外注册一个归 Goal 所有的 Conversation Definition。两者都匹配同一条 `/goal` `command/run`:通用 Definition 保留持久结果行Goal Definition 则在更早的分数锚点构建独立的 `command-input` Chat Node。Goal 插件还为该 Node 注册 keyed React renderer。它的本地组件只复用用户气泡的右对齐几何形态和语义 token使用 14px/22px 等宽字体文本,并且不挂载时间戳、复制或分支操作。
`Session.composerPhase` 把可见的非命令 Chat Node 视为对话内容,因此 `command-input` 会激活当前对话而仅有通用命令行时不会。Host 的 `summary.blank` 位仍以轮次为基础,因此列表隐藏和空白会话复用保持不变。
Goal Definition 根据结构化 run 派生 `/<name><args.trimEnd()>`:分隔符与内部多行输入保持不变;在已认领的裸命令形式中,参数只有一个空格时显示 `/goal`。仅包含 `command/done` 的历史窗口没有匹配的 Goal Context因此会保留通用结果行而不会虚构输入气泡加载包含更早 run 的页面后,两个 Node 都会恢复。
模型边界保持不变。Goal 投影不会创建 `user/message``turn/start``step/start``request/header`。已接受的 goal 变更只会通过 goal 领域现有的 `<goal_state>` 快照或 clear tombstone 到达模型,与 `command-input` Node 无关。
## 验证
Goal 客户端测试固定双 Definition 输出、顺序、排除其他命令、裸命令与多行文本、仅含 done 的切分窗口、renderer 语义、资源释放和新会话 phase 选择。无密钥的完整组装 Web 场景在不含模型适配器的新会话中提交裸 `/goal`,验证两行都显示且不存在面向模型的事件,然后重新加载并验证持久化后的 transcript。
## 备选方案
**在 `/goal` 处理器中追加 `user/message`。**不予采纳,因为该命令会变成模型输入,并可能触发或改变后续请求。
**向命令注册表与持久事件添加呈现意图。**不予采纳,因为一个 Goal 视图会扩大通用命令接口,并要求 Session、Chat 和每个命令 fixture测试前置数据都携带浏览器呈现状态。现有 `command/run` 的名称和参数已足以让组合后的 Goal 客户端重建自有视图。
**让通用命令 renderer 识别 `/goal`。**不予采纳,因为命令专用视图的构建归 Goal 客户端插件所有。在组合中移除该插件后,气泡必须随之消失,且命令执行和通用结果行不能改变。
**把每条命令输入都渲染为用户气泡。**不予采纳,因为现有控制命令会有意让新会话停留在 Hero这样修改会在没有功能自有 Conversation Definition 的情况下扩大交互语义。
## 后果
一条持久 `/goal` run 会向两个各自独立归属的视图 Context 提供数据,而不改变命令能力。在组合中移除 `ui-goal` 后,普通命令执行及其结果行保持不变。实时标签页与冷重载会得到一致结果,因为两个视图都派生自同一条 run。页面切分只保留 `command/done`会暂时只显示结果行如果该命令是会话中的唯一内容Hero 会隐藏该行,直到加载更早页面恢复 run。由于 Host 的 blank 语义仍以轮次为基础,会话在模型轮次开始前仍从列表中隐藏,并且可以复用。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
2026-08-10-web-session-log-export.md: 427b6478ac44fb28030aa932630f276de7bb2edc
2026-08-10-web-session-log-export.zh.md: 63b9804a54cda7eea4ff793d78a925fe296d06cb
2026-08-10-web-session-log-export.md: 8fa62b877df1be55de2c373d4281672881dc2b9d
2026-08-10-web-session-log-export.zh.md: 3040dda992492187245bfe92d29bc0812ef01ef2

View File

@@ -10,10 +10,10 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw
## Decision
- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line.
- **Error vocabulary is HTTP-native**: missing services → 500, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it.
- **The UI just downloads**: the 导出 button fetches the endpoint and saves the response; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle no longer carries fflate (the earlier browser-entry-alias pitfall is moot).
- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button; a failure surfaces in a visible alert bar under the toolbar.
- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 09 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line.
- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it.
- **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation.
- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser.
## Alternatives considered
@@ -24,7 +24,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw
## Consequences
- Export fidelity: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is `dsh-session-<sanitized-id>.zip` and archive paths sanitize ids before they can shape entries.
- `readRaw` joins the persistence service as a concrete default (`undefined` for backends without a per-session artifact, e.g. SQLite) with a JSONL-backend override that owns the compression decode. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface.
- Fixture mode (no host) answers 404 for the export, so the button's error bar explains the gap instead of hanging; the navigation-panes golden snapshot includes the 导出 button.
- Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative `SessionStore.flush` durability barrier; every exported file is byte-identical to that resulting durable artifact. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is `dsh-session-<sanitized-id>.zip` and archive paths sanitize ids before they can shape entries.
- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface.
- Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button.
- Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap.

View File

@@ -10,10 +10,10 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话
## 决策
- **导出是宿主侧的下载面,不是 RPC**`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`)。压缩在宿主侧用 fflate 流式 `Zip`/`ZipDeflate` API 完成每个条目按有界分块边产出边压缩响应随生成分块写出宿主从不把整个归档放进单个缓冲区除预载的根外最多同时持有一条后代的工件文本且每当响应队列填满时生产会让出慢消费者因此只产生有界的积压fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。
- **错误词汇是 HTTP 原生的**:服务缺失 → 500根会话缺失 → 404者都在任何字节流出前判定),后代缺少存储工件 → 流失败fail-loud绝不静默少导出。载体`toFetchHandler`)已对 `/api` 应用信任围栏GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`host-only、无 wire 信封、不在 `IApiClient` 上)实现。
- **UI 只负责下载**:「导出」按钮 fetch 该端点并保存响应;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不再携带 fflate早先的浏览器入口别名坑随之消失
- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会禁用按钮;失败会在工具栏下方的可见警示条中显示
- **导出是宿主侧的下载面,不是 RPC**`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 09默认 6使部署可以在 CPU延迟与归档大小之间取舍每个条目按有界分块边产出边压缩响应随生成分块写出宿主从不把整个归档放进单个缓冲区除预载的根外最多同时持有一条后代的工件文本。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。
- **错误词汇是 HTTP 原生的**:服务缺失 → 500后端不提供每会话原始工件 → 501根会话缺失 → 404者都在任何字节流出前判定),后代缺少存储工件 → 流失败fail-loud绝不静默少导出请求中止会保持取消语义而不会改写成 500请求取消与响应 Consumer 取消汇合到生产者 signal该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`host-only、无 wire 信封、不在 `IApiClient` 上)实现。
- **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现
- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告
## 考虑过的替代方案
@@ -24,7 +24,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话
## 后果
- 导出保真度:每个导出文件都与读取时刻的后端持久化工件逐字节一致(活跃会话可能在读取后继续追加;导出反映的是读取时的持久化状态)。压缩包名为 `dsh-session-<sanitized-id>.zip`,归档路径在塑造条目前会先净化会话 id。
- `readRaw` 以具体默认(无每会话工件的后端如 SQLite 返回 `undefined`加入持久化服务jsonl 后端覆写并自持压缩解码`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。
- fixture 模式(无宿主)对导出应答 404按钮的错误条会解释这个缺口而非挂起navigation-panes golden 快照包含「导出」按钮。
- 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 `SessionStore.flush` 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 `dsh-session-<sanitized-id>.zip`,归档路径在塑造条目前会先净化会话 id。
- `supportsRawArtifacts` 明确区分后端能力与会话缺失SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。
- fixture 模式(无宿主)对导出应答 404浏览器会将其报告为下载失败navigation-panes golden 快照包含「导出」按钮。
- 暂缓transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md
2026-08-10-vendor-package-rescope.md: f2a142cec4e3c28fae54af8063cd730931fa738b
2026-08-10-vendor-package-rescope.zh.md: 994064fc869e1ccd95609ec67010eae794ed1c48
2026-08-10-vendor-package-rescope.md: 3ad442a681229b7b9df8feb1c487a925b6123f23
2026-08-10-vendor-package-rescope.zh.md: d45fd3c389204e832917f0749c7d5bc360dea3a1

View File

@@ -26,7 +26,7 @@ All nine packages move into the `@deepseek-ai` scope. Directory names, upstream
The rewrite touches only **delimited, complete package-name tokens**: quoted or backticked specifiers (optionally with a `/subpath`), `package.json` names and dependency keys, `cordis.yml` `name:` values, and `tsconfig.base.json` `paths` keys. Identically spelled strings that are not package names therefore stayed as they were: the `cordis.yml` config-file family, the Loader's literal `cordis:` builtin prefix (`cordis:include`, `cordis:group` — see `vendor/loader/src/config/tree.ts`), kind strings like `cordis-config-entry`, `@deepseek-ai/dsh-tool-cordis`, Schemastery's upstream `Symbol.for('schemastery')` and `vendor:` metadata field, the `packages/<group>/` directory names in `GROUP_ORDER` (`scripts/gen-module-graph.ts`, `scripts/gen-doc-graphs.ts`), and the upstream install instructions in `vendor/*/README.md`.
Two classes are invisible to a token rule and were renamed site by site. First, property access and unquoted object keys `manifest.peerDependencies?.cordis`, and the manifest keys the scaffold generates in `npm-dependency-policy.ts` and `local-plugin-blueprint.ts` — where TypeScript cannot catch a stale `Record<string, string>` key. Second, constants that carry the name as data: the vendored set in `check-workspace-constraints.ts`, the group/include names in `verify-cordis-config.ts`, the `declare module` target strings in `cordis-walk.ts`, `gen-scoped-events.ts`, and typert's `analyzer.ts`, and `alwaysBundle` in `app-boot/tsdown.config.ts`.
Two classes are invisible to a token rule and were renamed site by site. First, property access — `manifest.peerDependencies?.cordis` — where TypeScript cannot catch a stale `Record<string, string>` key. Second, constants that carry the name as data: the vendored set in `check-workspace-constraints.ts`, the group/include names in `verify-cordis-config.ts`, the `declare module` target strings in `cordis-walk.ts`, `gen-scoped-events.ts`, and typert's `analyzer.ts`, and `alwaysBundle` in `app-boot/tsdown.config.ts`.
Markdown splits along what a reader does with it. Every fence follows the rename regardless of its info string, because a fence is code they copy or configuration they mount — the `yaml` fences naming Loader plugins and the `ts ignore-check` fences beside compiled ones included. Prose follows it under `docs/`, where a tutorial sentence quoting a name teaches something this repository no longer resolves. Prose elsewhere — `vendor/*/README.md`, package READMEs, and `.agents/notes/` — keeps the names it was written with, both because it records what was true then and because the same spelling can mean something else: the Python SDK's `cordis` option, the unvendored `@cordisjs/plugin-http`, or an agent-preset id.

View File

@@ -26,7 +26,7 @@ Status: implemented
改写只落在**带定界符的完整包名 token** 上:引号或反引号包裹的 specifier可带 `/子路径`)、`package.json``name` 与依赖键、`cordis.yml``name:` 值、`tsconfig.base.json``paths` 键。因此以下同形串一律未改,它们不是包名:`cordis.yml` 及其家族文件名、Loader 的 `cordis:` 内建前缀(`cordis:include``cordis:group`,见 `vendor/loader/src/config/tree.ts`)、`cordis-config-entry` 这类 kind 串、`@deepseek-ai/dsh-tool-cordis`、Schemastery 上游的 `Symbol.for('schemastery')``vendor:` 元数据、`scripts/gen-module-graph.ts``gen-doc-graphs.ts``GROUP_ORDER``packages/<group>/` 目录名,以及 `vendor/*/README.md` 里的上游安装指引。
Token 规则看不见两类点位,它们按名字逐处改:一是属性访问与未加引号的对象键(`manifest.peerDependencies?.cordis`、脚手架 `npm-dependency-policy.ts``local-plugin-blueprint.ts` 生成的清单键)——TypeScript 抓不到过期的 `Record<string, string>` 键;二是把名字当数据的常量(`check-workspace-constraints.ts` 的 vendored 集合、`verify-cordis-config.ts` 的 group/include 名、`cordis-walk.ts``gen-scoped-events.ts` 与 typert `analyzer.ts` 里识别 `declare module` 目标的字符串、`app-boot/tsdown.config.ts``alwaysBundle`)。
Token 规则看不见两类点位,它们按名字逐处改:一是属性访问 `manifest.peerDependencies?.cordis`——TypeScript 抓不到过期的 `Record<string, string>` 键;二是把名字当数据的常量(`check-workspace-constraints.ts` 的 vendored 集合、`verify-cordis-config.ts` 的 group/include 名、`cordis-walk.ts``gen-scoped-events.ts` 与 typert `analyzer.ts` 里识别 `declare module` 目标的字符串、`app-boot/tsdown.config.ts``alwaysBundle`)。
Markdown 按「读者拿它做什么」一分为二。围栏一律跟着改,不看 info string——围栏里是读者要照抄的代码或要挂载的配置包括写着 Loader 插件名的 `yaml` 围栏和紧邻编译围栏的 `ts ignore-check` 围栏。散文只在 `docs/` 下跟着改:教程里引用某个名字的句子,教的是本仓已不解析的东西。`docs/` 之外的散文——`vendor/*/README.md`、各包 README、`.agents/notes/`——保留写作当时的名字:既因为它记录的是当时的事实,也因为同一个拼写可能指别的东西,比如 Python SDK 的 `cordis` 选项、我们没 vendor 的 `@cordisjs/plugin-http`,或某个 agent-preset 的 id。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md
2026-07-20-remove-stdio-and-echo-agents.md: 23fcb90599c2ff96cd7ac0e6f7ee8fd508a6d1ad
2026-07-20-remove-stdio-and-echo-agents.zh.md: 7aabf5612a54245327da9af804f745237472cb88
2026-07-20-remove-stdio-and-echo-agents.md: fcfd0399bc6cf040287a057c56339690c8d92d8a
2026-07-20-remove-stdio-and-echo-agents.zh.md: 6f31d11885aad43b5f2ea236efb95c950290033a

View File

@@ -22,7 +22,7 @@ The remaining application roles are explicit:
- [`dsh --profile headless`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver.
- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations.
The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents.
The SDK project model that carried the `stdio` run-interface option is deleted by the [SDK project toolchain removal](2026-08-11-remove-sdk-project-toolchain.md). Repository-facing demo documentation requires a DeepSeek API key and leads with a current runnable product.
Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapter to exercise a real tool round trip, the `dsh` built-bin suite pins the published one-shot entry and output, the product Headless snapshot pins persistence, and the Headless PTY shutdown e2e pins signal escalation. Package-specific Loader tests keep deterministic adapters beside their scenarios. None is exposed as a runnable mock agent.
@@ -45,5 +45,5 @@ The built `dsh` bin rejects a piped TUI launch before Loader boot and points at
- Interactive and non-interactive product execution each have one owner and one runnable coding leaf.
- The repository has no keyless user-facing agent demo; local agent demos require `DEEPSEEK_API_KEY`.
- CI retains keyless real-entry coverage through test fixtures rather than a product command.
- Existing stdio-agent configurations, Echo commands, and SDK `--interface=stdio` invocations fail instead of being translated.
- Existing stdio-agent configurations and Echo commands fail instead of being translated.
- Piped multi-turn interaction in one process and the readline provider for non-TTY `ask_user_question` are intentionally gone; resume covers durable multi-turn work, and a non-TTY composition must supply its own interaction provider.

View File

@@ -22,7 +22,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个
- [`dsh --profile headless`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。
- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。
SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key并优先引导到真实的 Headless 或 TUI agent
承载 `stdio` 运行接口选项的 SDK 项目模型已由 [SDK 项目工具链移除决策](2026-08-11-remove-sdk-project-toolchain.md)删除。仓库中的演示文档要求 DeepSeek API key并优先引导到当前可运行的产品
无密钥验证由测试负责。Headless Loader 冒烟测试使用 fixture 适配器验证真实工具往返;`dsh` built-bin 测试套件固定已发布的一次性入口和输出;产品 Headless 快照固定持久化Headless PTY 关闭 e2e 固定信号升级。各包专属的 Loader 测试则将确定性适配器放在对应场景旁。其中任何一项都不会作为可运行的 mock agent 对外暴露。
@@ -45,5 +45,5 @@ TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真
- 交互式与非交互式产品执行分别只有一个归属方和一个可运行的 coding 叶节点。
- 仓库没有面向用户的无密钥 agent 演示;本地 agent 演示需要 `DEEPSEEK_API_KEY`
- CI 通过测试 fixture 保留针对真实入口的无密钥覆盖,而不是依赖产品命令。
- 既有 stdio agent 配置Echo 命令和 SDK `--interface=stdio` 调用会直接失败,不会被转换。
- 既有 stdio agent 配置Echo 命令会直接失败,不会被转换。
- 有意移除了单进程内基于管道的多轮交互,以及面向非 TTY `ask_user_question` 的 readline 提供方;恢复会话可以满足持久多轮工作,非 TTY 组装则必须自行提供交互提供方。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md
2026-07-23-acp-automation-only-protocol.md: 56c433acf59d3a0c4b5c8b6605e422d3c4efede2
2026-07-23-acp-automation-only-protocol.zh.md: bf326ce9f6fea8c55b842bc61e3d8521032ea4d1
2026-07-23-acp-automation-only-protocol.md: 56dcaf8b4327a008f26b884264958cac02d6541a
2026-07-23-acp-automation-only-protocol.zh.md: a34e179a1d38c1867ea8165b149a9ec7716c1c0e

View File

@@ -20,7 +20,7 @@ The bridge emits only committed `assistant/message` text. Reasoning, raw chunks,
One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the answerer accepts only an exact agent object in the bridge's live session map, delegates foreign or call-less requests, and maps failed RPCs to the fail-closed unavailable outcome. The client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. Asking policy stays in the approval seam and its producers; [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically.
The app composition contains the agent spine, persistence, checkpoint policy, and ACP transport. It does not mount command, session-query, session-reference, plan-mode, permission-picker, or user-interaction services for ACP. SDK scaffolding likewise treats `ask_user_question` as TUI-only.
The app composition contains the agent spine, persistence, checkpoint policy, and ACP transport. It does not mount command, session-query, session-reference, plan-mode, permission-picker, or user-interaction services for ACP.
The transport programs interface-level agent, session, and approval services rather than the concrete agent loop. Tool execution stays inside the harness; ACP never delegates shell execution to an editor. stdout carries framed JSON-RPC only, so the app mounts no stdout logger and the bridge does not monkey-patch process output.

View File

@@ -20,7 +20,7 @@ ACP 仍有一个有用的职责:另一个 agent智能体或自动化控
保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI应答者只接受桥接层当前会话映射中登记的同一 agent 对象外部请求或缺少调用标识的请求会继续委派RPC 失败则映射为拒绝请求的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。
应用组装包含 agent 主干、持久化、检查点策略和 ACP 传输层。它不会为 ACP 挂载命令、会话查询、会话引用、plan mode、权限选择器或用户交互服务。SDK 脚手架同样将 `ask_user_question` 视为 TUI 专属功能。
应用组装包含 agent 主干、持久化、检查点策略和 ACP 传输层。它不会为 ACP 挂载命令、会话查询、会话引用、plan mode、权限选择器或用户交互服务。
传输层调用 agent、会话和审批的接口服务而不依赖具体的 agent loop。工具执行仍留在 harness 内ACP 绝不会把 shell 执行委派给编辑器。stdout 只承载分帧 JSON-RPC因此 app 不挂载 stdout logger桥接层也不会 monkey-patch 进程输出。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md
2026-08-04-remove-tui-package.md: 19cc7d1a89a55bb57a69b9fce301f48f89384acd
2026-08-04-remove-tui-package.zh.md: be18cbd33cd4a2bb592de4e7986b2786da272d0f
2026-08-04-remove-tui-package.md: 4b89e45f009c2b240f88db40875d77d1a809e721
2026-08-04-remove-tui-package.zh.md: 0ff7fa0b4fd48f51b12e407000ee98fb6a28f476

View File

@@ -14,7 +14,7 @@ The package also made the repository's supported application inventory misleadin
The `packages/ui/tui` package is deleted without a compatibility package or alias. Its source, package tests, terminal snapshots, dependency declarations, patched `pi-tui` artifact, workspace references, generated service catalog entry, and documentation are removed together. Generic host and agent-loop capabilities remain unchanged.
The SDK run-interface union now contains only `acp` and `embed`. `create-sdk` defaults to ACP, generated templates contain no terminal startup, resume, session-environment, or model-argument branch, and the builtin `ask-user` feature is removed because neither remaining generated interface supplies a `UserInteractionProvider`. Host applications may still mount the provider-neutral `dsh-user-interaction`, `dsh-commands`, and presentation services directly.
The SDK project toolchain that remained as the TUI package's final consumer is deleted by the [toolchain removal decision](2026-08-11-remove-sdk-project-toolchain.md). Host applications may still mount the provider-neutral `dsh-user-interaction`, `dsh-commands`, and presentation services directly.
This decision supersedes the reusable-package retention in [the explicit-config `dsh` entrypoint decision](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md) and the current applicability of the archived TUI implementation notes. Their historical records remain frozen, but they are not authority for the supported package or application inventory.
@@ -22,7 +22,7 @@ This note consolidates the deleted package-only records that could not remain cu
## Verification
Repository searches and generated catalogs contain no TUI package, dependency patch, SDK interface option, service key, or package link. Focused SDK tests cover ACP and embedded creation, configuration, templates, and snapshots. The ordinary source build, typecheck, lint, hygiene, documentation gates, and remaining assembled snapshot suites run without the deleted workspace.
Repository searches and generated catalogs contain no TUI package, dependency patch, service key, or package link. The ordinary source build, typecheck, lint, hygiene, documentation gates, and remaining assembled snapshot suites run without the deleted workspace.
## Alternatives considered
@@ -34,6 +34,6 @@ Repository searches and generated catalogs contain no TUI package, dependency pa
## Consequences
DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web entry points.
DeepSeek Harness has no terminal UI package. Existing imports and `cordis.yml` rows that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web entry points.
The provider-neutral command, user-interaction, approval, tool-presentation, PTY, and session-projection capabilities remain available to other hosts. Reintroducing a terminal frontend requires a named product or deployment, an explicit package boundary, a concrete interaction provider, and assembled lifecycle and transcript acceptance for that frontend.

View File

@@ -14,7 +14,7 @@ Status: implemented
删除 `packages/ui/tui` 包,不提供兼容包或别名。其源码、包测试、终端快照、依赖声明、已打补丁的 `pi-tui` 产物、workspace 引用、生成的服务目录条目和文档会一并移除。通用宿主能力与 agent loop智能体循环能力保持不变。
SDK 的运行接口联合类型现在只包含 `acp``embed``create-sdk` 默认使用 ACP生成的模板不再包含终端启动、恢复、会话环境或模型参数分支内置的 `ask-user` 功能也被移除,因为剩余两个生成接口都不提供 `UserInteractionProvider`。宿主应用仍可直接挂载提供方无关的 `dsh-user-interaction``dsh-commands` 和呈现服务。
作为 TUI 包最后消费方的 SDK 项目工具链已由[工具链移除决策](2026-08-11-remove-sdk-project-toolchain.md)删除。宿主应用仍可直接挂载提供方无关的 `dsh-user-interaction``dsh-commands` 和呈现服务。
本决策取代[显式配置 `dsh` 入口决策](../../archived/simplification/2026-08-03-explicit-config-dsh-entrypoint.md)中保留可复用包的决定,也使已归档 TUI 实现记录不再适用于当前状态。这些历史记录继续保持冻结,但不再作为受支持包或应用清单的依据。
@@ -22,7 +22,7 @@ SDK 的运行接口联合类型现在只包含 `acp` 与 `embed`。`create-sdk`
## 验证
仓库搜索结果与生成的服务目录中不再包含 TUI 包、依赖补丁、SDK 接口选项、服务键或包链接。专项 SDK 测试覆盖 ACP 和嵌入式两种模式的创建、配置、模板与快照。常规源码构建、类型检查、lint、hygiene、文档门禁以及其余组装快照测试套件均可在没有已删除 workspace 的情况下运行。
仓库搜索结果与生成的服务目录中不再包含 TUI 包、依赖补丁、服务键或包链接。常规源码构建、类型检查、lint、hygiene、文档门禁以及其余组装快照测试套件均可在没有已删除 workspace 的情况下运行。
## 考虑过的替代方案
@@ -34,6 +34,6 @@ SDK 的运行接口联合类型现在只包含 `acp` 与 `embed`。`create-sdk`
## 后果
DeepSeek Harness 不再提供终端 UI 包或生成的 TUI 应用。现有 import`cordis.yml` 条目、SDK `--interface=tui` 请求以及依赖该包的项目会直接失败不会得到兼容转换。Web 仍是已交付的交互表面ACP、JSON-RPC 与一次性 CLI 仍是 Web 之外的入口。
DeepSeek Harness 不再提供终端 UI 包。现有 import 和依赖该包的 `cordis.yml` 条目会直接失败不会得到兼容转换。Web 仍是已交付的交互表面ACP、JSON-RPC 与一次性 CLI 仍是 Web 之外的入口。
提供方无关的命令、用户交互、审批、工具呈现、PTY 与会话投影能力仍可供其他宿主使用。重新引入终端前端时,必须为其提供具名产品或部署、显式包边界、具体交互提供方,以及组装后的生命周期与 transcript文本记录验收。

View File

@@ -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 .agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md
2026-08-11-remove-sdk-project-toolchain.md: 5c147560a2a9a796d6ca98d1acaf648049ae6a9f
2026-08-11-remove-sdk-project-toolchain.zh.md: 07624052e936d8705958e826c68f39f34dcd146a

View File

@@ -0,0 +1,41 @@
# Agent Note: Remove the SDK project toolchain
Status: implemented
English | [中文](2026-08-11-remove-sdk-project-toolchain.zh.md)
## Problem
The repository carried an unreleased developer-project product with no consumers. `@deepseek-ai/create-sdk` generated an editable Cordis project, `@deepseek-ai/dsh-scripts` supplied its `dsh-sdk` development, build, start, configuration, and plugin-install commands, `@deepseek-ai/dsh-helper` coordinated feature definitions and multi-file project edits, and `@deepseek-ai/dsh-telemetry` reported launcher activity. The design aimed to keep generated projects editable while giving creation and later configuration one definition of dependencies, Cordis entries, environment placeholders, and owned files.
No project was created through a public release, and no current repository or external consumer requires that lifecycle. Keeping it meant maintaining four packages, two interactive command products, project templates, package-manager adapters, configuration reconciliation, launcher telemetry, a repository skill, and their tests and documentation without evidence that the product boundary should exist.
The same `scaffold/` group also contained the independently used SDK protocol, TypeScript client, and JSON-RPC server. Those packages serve the Python SDK, the `dsh-sdk` subagent provider, and the JSON-RPC example; their runtime protocol does not depend on generated projects or the removed launcher.
## Decision
The SDK project toolchain is deleted. The `@deepseek-ai/create-sdk`, `@deepseek-ai/dsh-scripts`, `@deepseek-ai/dsh-helper`, and `@deepseek-ai/dsh-telemetry` packages, their binaries, tests, templates, feature catalog, project-editing model, package-manager support, launcher telemetry, and repository creation skill have no replacement or compatibility layer. Their workspace, build, test, packaging, documentation-generator, vendoring-rescope, and dependency records are removed with them.
The runtime SDK remains. `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-jsonrpc` move unchanged from `packages/scaffold/` to `packages/sdk/`; their npm names and wire behavior do not change. Consumers continue to provide an executable plus an external `cordis.yml`, and the JSON-RPC server remains an ordinary plugin selected by that configuration.
The canceled developer-project, project-editing, and follow-up-capabilities proposals are deleted rather than retained as active or rejected records. This note preserves the motivation they shared, the decision not to ship that product, the capability given up, and the condition for reconsideration. Frozen archived Agent Notes remain historical snapshots and are not edited.
## Verification
The workspace contains none of the four deleted package names or either removed command product. Package aggregates, source path maps, package metadata, test collection, publication constraints, generated catalogs, dependency notices, and the lockfile resolve only the three runtime SDK packages under `packages/sdk/`. The runtime SDK package tests, its built server smoke, TypeScript consumers, repository documentation gates, build, and hygiene checks pin the surviving behavior and the absence of stale package paths.
## Alternatives considered
**Delete only the initializer.** Rejected because `dsh-sdk`, the shared project model, and launcher telemetry existed to operate projects created by that initializer, and there are no existing projects that need them.
**Keep error-only packages or command aliases.** Rejected because none of the commands shipped publicly. A tombstone would preserve package and executable surface area without a compatibility obligation.
**Delete the runtime SDK stack too.** Rejected because the Python SDK, the out-of-process Harness subagent provider, and the JSON-RPC example are current consumers of the protocol, client, and server.
**Leave the runtime stack under `packages/scaffold/`.** Rejected because nothing left in that group scaffolds a project. `packages/sdk/` states the surviving role directly even though the repository as a whole is also an SDK.
## Consequences
DeepSeek Harness no longer creates or manages standalone developer SDK projects. Automatic project generation, feature-tree configuration, local-plugin scaffolding, project-local development/build/start commands, and developer-cycle launcher telemetry are intentionally unavailable; ordinary applications and runtime distributions continue to compose plugins through their owning packages and `cordis.yml` files.
The repository loses the complete support graph rather than carrying dormant abstractions. Reintroducing a project toolchain requires a real consumer and a new proposal grounded in that consumer's workflow; it does not revive these packages or their deleted compatibility-free formats by default.

View File

@@ -0,0 +1,41 @@
# Agent Note: 移除 SDK 项目工具链
Status: implemented
[English](2026-08-11-remove-sdk-project-toolchain.md) | 中文
## 问题
仓库曾包含一套从未发布且没有消费方的开发者项目产品。`@deepseek-ai/create-sdk` 用于生成可编辑的 Cordis 项目;`@deepseek-ai/dsh-scripts` 提供 `dsh-sdk` 的开发、构建、启动、配置和插件安装命令;`@deepseek-ai/dsh-helper` 协调功能定义与多文件项目编辑;`@deepseek-ai/dsh-telemetry` 上报启动器活动。该设计旨在让生成的项目保持可编辑并使项目创建与后续配置对依赖、Cordis 配置项、环境变量占位符和归属文件采用同一套定义。
没有任何项目是通过公开发布版创建的,当前仓库和外部消费方也都不需要这套生命周期。保留它就意味着继续维护 4 个包、2 套交互式命令产品、项目模板、包管理器适配器、配置调和、启动器遥测、1 个仓库 skill技能及其测试和文档却没有证据表明这项产品边界应当存在。
同一 `scaffold/` 分组还包含各自独立使用的 SDK 协议、TypeScript 客户端和 JSON-RPC 服务器。这些包为 Python SDK、`dsh-sdk` subagent 提供方和 JSON-RPC 示例提供支持;其运行时协议不依赖生成的项目或被移除的启动器。
## 决策
删除 SDK 项目工具链。`@deepseek-ai/create-sdk``@deepseek-ai/dsh-scripts``@deepseek-ai/dsh-helper``@deepseek-ai/dsh-telemetry` 包及其二进制文件、测试、模板、功能目录、项目编辑模型、包管理器支持、启动器遥测和仓库项目创建 skill 均不提供替代实现或兼容层。与其对应的 workspace、构建、测试、打包、文档生成器、vendor scope 重写和依赖记录也一并移除。
保留运行时 SDK。`@deepseek-ai/dsh-sdk-client``@deepseek-ai/dsh-sdk-protocol``@deepseek-ai/dsh-jsonrpc` 保持原样,从 `packages/scaffold/` 移至 `packages/sdk/`;其 npm 名称和协议交互行为保持不变。消费方继续提供一个可执行文件和一份外置 `cordis.yml`JSON-RPC 服务器仍是由该配置选择的普通插件。
被取消的开发者项目、项目编辑和后续能力提案予以删除,而不是保留为活跃或已否决记录。本 Agent Note 保留这些提案共有的动机、不交付该产品的决策、放弃的能力,以及重新考虑这一决定的条件。已冻结的归档 Agent Note 仍是历史快照,不作修改。
## 验证
workspace 中不再存在上述 4 个已删除包名或 2 套已移除的命令产品。包聚合配置、源码路径映射、包元数据、测试收集配置、发布约束、生成目录、依赖声明文件和锁文件都只解析 `packages/sdk/` 下的 3 个运行时 SDK 包。运行时 SDK 包测试、已构建服务器的冒烟测试、TypeScript 消费方、仓库文档门禁、构建和 hygiene 检查共同固定了保留的行为,并确保不存在陈旧的包路径。
## 考虑过的替代方案
**只删除初始化器。** 不予采纳,因为 `dsh-sdk`、共享项目模型和启动器遥测都是为了操作该初始化器创建的项目,而现有项目均不需要这些能力。
**保留仅用于报错的包或命令别名。** 不予采纳,因为这些命令都从未公开发布。墓碑会在不存在兼容义务的情况下保留包与可执行文件的接口范围。
**同时删除运行时 SDK 栈。** 不予采纳,因为 Python SDK、进程外 Harness subagent 提供方和 JSON-RPC 示例目前仍是协议、客户端和服务器的消费方。
**将运行时栈继续留在 `packages/scaffold/` 下。** 不予采纳,因为该分组剩余内容均不再负责搭建项目。尽管整个仓库同样是一套 SDK`packages/sdk/` 仍直接说明了保留内容的职责。
## 后果
DeepSeek Harness 不再创建或管理独立的开发者 SDK 项目。自动项目生成、功能树配置、本地插件脚手架、项目本地的开发、构建和启动命令,以及面向开发周期的启动器遥测均有意不再提供;普通应用和运行时分发仍通过各自归属的包和 `cordis.yml` 文件组合插件。
仓库删除完整的支持图,而不是继续保留休眠抽象。重新引入项目工具链必须先有真实消费方,并基于该消费方的工作流提出新提案;默认情况下,不会复活这些包或已删除且不承诺兼容的格式。

View File

@@ -1,6 +0,0 @@
# 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 .agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md
2026-07-15-sdk-project-editing-architecture.md: 2d0f83b9f0eadc5ff22634900f6036e971fc4114
2026-07-15-sdk-project-editing-architecture.zh.md: 4c59ac8cdedf0c7fdd1a2e7994135bc89653d55e

View File

@@ -1,129 +0,0 @@
# Agent Note: SDK project editing architecture
Status: proposed
English | [中文](2026-07-15-sdk-project-editing-architecture.zh.md)
## Problem
[Developer-owned SDK projects](../feature/2026-07-14-sdk-developer-projects.md) are created through create, adjusted through config, and built and run through commands such as start. Initial creation, configuration changes, and build and runtime commands all need to understand features, feature options, npm dependencies, Cordis config entries, environment variables, package managers, local plugins, and several project files. If each project-reading and project-writing workflow uses a separate interpretation protocol, the SDK developer workflows become difficult to maintain.
## Proposal
The SDK uses one shared object-oriented project model. `SdkProject` is a read-only snapshot, and `ProjectEditSession` is the only mutation and commit boundary. Feature objects own their feature options, relationships, resource contributions, and current-state inspection. Create and config orchestrate only their respective user workflows and modify projects through the same domain operations.
Structured files are modified through document objects, while one-shot text artifacts are generated from complete templates. Questions are typed objects presented through clack. Diff calculation may remain an edit-session implementation detail, but it is not a public execution protocol that callers must assemble.
## Terminology
| Term | Usage in this Agent Note | Meaning |
|---|---|---|
| Feature | feature | A product unit curated and managed by the SDK; one feature may contain several feature options and contribute several Cordis config entries, npm dependencies, environment placeholders, and owned files |
| Feature option | feature option | A finite selectable implementation or configuration shape within one feature; feature rules may make options fixed, exclusive, or additive |
| Cordis plugin | Cordis plugin | A plugin implementation loaded by Cordis, usually exported by an npm package; it is not an item in `cordis.yml` |
| Cordis config entry | Cordis config entry | One item in the `cordis.yml` plugin list, identified as an instance by `id` and referring to a Cordis plugin through `name` |
| Cordis plugin config | Cordis plugin config | The configuration object or shape exposed by a Cordis plugin; an individual field owned and updated by a feature is a config key |
| config key | config key | One field in Cordis plugin config; a feature updates only the config keys it declares as owned and preserves unknown config keys |
| npm dependency | npm dependency | A package relationship in `package.json`; literal fields such as `dependencies` and `devDependencies` keep their names |
| Feature requirement | feature requirement | A relationship declared through `requires` by a feature or feature option |
## Package boundaries
| Package | Responsibility | Does not own |
|---|---|---|
| `@deepseek-ai/dsh-helper` | Edit sessions, feature configuration, project-template rendering, package-manager adaptation, and prompt interaction adaptation | Booting Cordis applications or deciding create/config terminal workflows |
| `@deepseek-ai/dsh-scripts` | `dsh-sdk start/dev/build/config`, process lifecycle, project entry loading, the config workflow, and its terminal-copy templates | Interpreting feature definitions directly or modifying YAML/JSON ASTs |
| `@deepseek-ai/create-sdk` | Arguments, question order, initial project creation, installation finish, and terminal-copy templates for `npm create @deepseek-ai/sdk` | Becoming a generated project's runtime npm dependency or providing a library API |
`@deepseek-ai/create-sdk` is the only exception to the repository's `@deepseek-ai/dsh-*` naming rule. npm's scoped-initializer convention requires that package name for `npm create @deepseek-ai/sdk`. The exception is a repository architecture fact and does not add a third developer product entrypoint.
The three packages export only the narrow entrypoints consumed by adjacent layers and provide no `src/*` deep imports. The scripts library entrypoint and build-config subpath serve generated code and project build configuration, while the developer product contract remains the `dsh-sdk` commands.
## Project aggregate and edit session
`SdkProject.create(root, request)` constructs a new project snapshot that has not been written, while `SdkProject.open(root)` loads an existing project. Open requires only readable root `package.json` and `cordis.yml` files; every other file is an optional resource. Both paths return the same read-only aggregate and distinguish their source through explicit origin state.
`project.edit()` clones project documents into a working copy. Domain commands such as install, configure, enable, disable, and addPlugin modify only the working copy. Each command immediately re-inspects its owning feature, and the final commit checks all relationships and files again.
```text
validate feature requirements and resource ownership
-> validate every affected document
-> compute changed and removed paths
-> compare existing files with the session's original text
-> write through one commit boundary
-> return a new SdkProject snapshot and ChangeSet
```
Validation failure or an external edit causes zero writes. “One commit” means only zero pre-write side effects and one write entrypoint. `ChangeSet` describes final feature, plugin, and file changes for Review & Apply and create completion.
## Features and resource ownership
A feature is a first-class behavior object. Shallow base classes implement install, configure, enable, disable, required/requires validation, and common state inspection. Features with fixed, exclusive, or additive feature options share these lifecycles. Only features whose resource contributions depend on project context or require custom round-tripping use dedicated behavior classes; other features declare their actual differences through standardized data.
Each feature contributes stable-keyed Cordis config entries, npm dependencies, environment placeholders, and owned files. The registry rejects two features that declare the same resource key during initialization. Different feature options within one feature may share resources, which that feature resolves from the final option set.
A Cordis config entry anchors feature installation. The npm package name assigns the entry to a feature, and the entry ID distinguishes several instances of one plugin package. An npm dependency without a feature-owned Cordis config entry leaves the feature uninstalled. Once a Cordis config entry exists, a missing npm dependency, unreadable Cordis plugin config, or resource conflict puts the feature into an inconsistent state; the config command shows diagnostics and refuses speculative modification.
Configuring the same feature option updates only its owned config keys and preserves unknown keys. Replacing a feature option removes old resources that are exclusive and still confirmable. If an old resource cannot be confirmed or an owned file was modified by the developer, the whole operation fails.
## Questions and workflows
TypeScript `Question<T>` objects keep defaults, validation, applicability, and types together. `PromptPort` is the only interface between the domain layer and the terminal library, and helper provides one thin `ClackPromptPort`. Create and config inject their own command-line input and output streams and retain ownership of cancellation, return, and completion semantics in their workflows.
Create keeps its stateful question order in one wizard, while config keeps final-state selection in one workflow. Both use the same feature configurator for feature options and dedicated inputs, so adding an ordinary feature, feature option, or parameter does not require changes to both entrypoints.
## Project documents and templates
Only structured files that helper reads or modifies have concrete document objects: `package.json`, `cordis.yml`, `.env`, `.env.example`, the root `tsconfig.json`, and the pnpm workspace file. Document objects own parsing, cloning, validation, and serialization. Concrete classes and modules use `*File` and `*-file.ts` names respectively. Business code does not manipulate YAML/JSON ASTs directly, and malformed shapes fail loudly at the owning document boundary.
README, entrypoint code, build configuration, `.gitignore`, and other one-shot text artifacts use one complete template per real file. Complete product copy such as CLI usage, creation and recovery messages, installation and retry guidance, and the default persona also comes from package-local templates owned by the package that presents it.
Helper provides the generic typed `TextTemplate` renderer, and caller packages load their own templates through package-local asset URLs.
Templates use Handlebars strict mode and `noEscape` without custom processing. File owners encode typed values for the target language. Template source escapes interpolation as `\{{model}}` when it must emit the downstream literal unchanged.
## Command and runtime boundary
Scripts supports `dsh-sdk start/dev/build/config`. Start dynamically loads a module target and calls its named entrypoint. Dev adds TypeScript and local-workspace source resolution before following the same path. Build invokes the project's installed tsdown. Config opens one edit session and commits after Review & Apply. Generated projects run `tsc -b` directly for typechecking.
HMR is an explicit Cordis config entry loaded by dev and start. Its required `node-addon-require-builtin` package is supplied transitively by the scripts package and is absent from the generated project's `package.json`.
Dev and start execute the developer entrypoint, where developer code handles command-line arguments and cwd. Developers pass `--model=<name>` and `--resume=<session-id>` to start the standard flow.
## Repository live-link mode
Create-sdk retains a hidden `--link-workspace` option for Harness repository development and e2e. The parser accepts it, but help, public flag lists, and ordinary user documentation omit it. It accepts no repository-path parameter; the repository root is derived upward from the executing create-sdk module.
Link mode preserves the ordinary project file shape. `@deepseek-ai/*` points into `packages/`, Cordis-related npm dependencies point into `vendor/`, and shared lower-level packages resolve to the same physical copy used by the repository so Cordis type merging cannot produce multiple module type definitions. npm uses `file:`, pnpm uses `link:` with automatic peer installation disabled, and Yarn uses `portal:` plus resolutions. Repository packages must be built first.
## Future work
- **Replaceable required spine roles.** The current `spine` owns the full implementation set, including SystemPrompt and LLMService, through one fixed feature option. Developers cannot replace or switch these roles and must edit Cordis config entries manually.
- **Service contracts and package declarations.** When replacing a builtin service, a Cordis plugin currently cannot declare the services it provides through `provides` metadata, so the SDK cannot assist configuration during development or check compatibility at runtime. A corresponding protocol remains to be designed.
- **Feature parameter descriptions.** Feature-specific inputs currently require handwritten declarations. The SDK cannot derive interactive parameters automatically from arbitrary Cordis plugin config or npm package.json information. Future declarative metadata may expose a limited parameter set without turning arbitrary Cordis plugin config into a generic form.
- **SDK application-level configuration.** The current project resource model describes Cordis config entries and config keys owned by individual Cordis plugins, so every SDK-managed setting must belong to one plugin. Cross-plugin or whole-application settings have no independent persistence location. Future work must define an application-level configuration document and its ownership, read, and mutation boundaries.
## Alternatives considered
**Keep the static Catalog and central engine.** This minimizes the initial rewrite, but feature parameters, round-tripping, owned files, and create/config reuse continue to accumulate in one coordinator. Splitting files shortens the file without consolidating responsibility.
**Use `wizard.json` and a generic Questionnaire.** Static forms cannot directly express feature requirements, option switches, existing-value refill, and project-resource changes. Types, gates, and dynamic options still connect through string registries and a procedural `run()`, creating another internal DSL.
**Expose the live-link flag.** The mode depends on Harness monorepo layout and unpublished packages and serves repository development only. Making it public would create a project-creation contract that the SDK cannot support outside the repository.
## Acceptance criteria
- Create and config modify projects only through `SdkProject` and `ProjectEditSession`; any business, document, or concurrency validation failure before writing leaves the filesystem unchanged
- Adding an ordinary feature, feature option, or parameter extends only its typed spec or owning behavior object, without adding a central switch to create or config workflows
- Helper owns the feature model, npm dependency and other resource configuration, and inconsistent-state detection
- Structured files change through `*File` document objects; one-shot files and complete product copy come from package-owned Handlebars templates, and business decisions do not enter a template DSL
- `dsh-sdk start/dev/build/config` is the runtime product API, typecheck uses `tsc -b` directly, HMR is not injected by command mode, and only the scripts package transitively supplies `node-addon-require-builtin`
- `--link-workspace` exists only as a hidden repository-development option and preserves one module identity under npm, pnpm, and Yarn
## Risks
- Behavior objects and typed specs create two extension shapes. Dedicated classes must remain limited to features that truly depend on project context or custom behavior, or the design will grow a meaningless type hierarchy
- Optimistic concurrency checks and pre-write validation cannot recover from an I/O failure during writing; callers must still report a possible partial commit to the developer
- Hidden link mode depends on repository layout and package-manager link semantics and must change with either one
- The Cordis loader resolves `node-addon-require-builtin` from its own module path, so the scripts package must continue to satisfy that optional peer under npm, pnpm, and Yarn npm dependency layouts
- Handlebars `noEscape` makes typed model construction responsible for target-language encoding; new template fields must be escaped correctly at the owning boundary, and downstream Handlebars placeholders must be escaped explicitly in template source

View File

@@ -1,129 +0,0 @@
# Agent Note: SDK 工程编辑架构
Status: proposed
[English](2026-07-15-sdk-project-editing-architecture.md) | 中文
## 问题
[开发者拥有的 SDK 工程](../feature/2026-07-14-sdk-developer-projects.md) 由 create 创建,可以通过 config 调整,并由 start 等命令构建和运行。初始创建、配置调整和编译运行都需要理解功能、功能选项、NPM 依赖、Cordis 配置项、环境变量、包管理器、本地插件和多个项目文件。如果各个项目读写工作流分别使用不同的解析协议SDK 开发者工作流会变得难以维护。
## 提案
SDK 使用一个共享的面向对象工程模型。`SdkProject` 是只读快照,`ProjectEditSession` 是唯一修改与提交边界功能对象负责自身的功能选项、关系、资源贡献和现状识别create 与 config 只编排各自的用户工作流,并通过同一组领域操作修改工程。
结构化文件通过文档对象修改,一次性文本产物通过完整模板生成。问题由类型化对象表达,并使用 clack 交互。差异计算可以作为编辑会话的内部实现,但不成为要求调用方组装的公共执行协议。
## 术语
| 名词 | 本文用词 | 含义 |
|---|---|---|
| Feature | 功能 | 由 SDK 策划和管理的产品单元;一项功能可以包含多个功能选项,并贡献多个 Cordis 配置项、NPM 依赖、环境变量占位和独占文件 |
| Feature option | 功能选项 | 一项功能内有限、可选择的实现或配置形状;根据功能规则可以固定、互斥或多选 |
| Cordis plugin | Cordis 插件 | Cordis 加载的插件实现,通常由一个 NPM 包导出;它不是 `cordis.yml` 中的一项配置 |
| Cordis config entry | Cordis 配置项 | `cordis.yml` 插件列表中的一项,通过 `id` 标识实例并通过 `name` 指向 Cordis 插件 |
| Cordis plugin config | Cordis 插件配置 | Cordis 插件公开的配置对象或配置结构;其中由功能拥有并更新的单个字段称为「配置键」 |
| config key | 配置键 | Cordis 插件配置中的单个字段;功能只更新自己声明拥有的配置键,并保留未知配置键 |
| npm dependency | NPM 依赖 | `package.json` 中的包关系;`dependencies``devDependencies` 等字段保持原样 |
| Feature requirement | 功能依赖 | 功能或功能选项通过 `requires` 声明的关系 |
## 包边界
| 包 | 责任 | 不负责 |
|---|---|---|
| `@deepseek-ai/dsh-helper` | 编辑会话、功能配置、工程模板渲染、包管理器适配和 prompt 交互适配 | 启动 Cordis 应用或决定 create/config 的终端工作流 |
| `@deepseek-ai/dsh-scripts` | `dsh-sdk start/dev/build/config`、进程生命周期、项目入口加载、config 工作流和所属终端文案模板 | 直接解释功能定义或修改 YAML/JSON AST |
| `@deepseek-ai/create-sdk` | `npm create @deepseek-ai/sdk` 的参数、问题顺序、首次工程创建、安装收尾和所属终端文案模板 | 成为生成工程的运行时 NPM 依赖或提供库 API |
`@deepseek-ai/create-sdk` 是仓库 `@deepseek-ai/dsh-*` 命名规则的唯一例外npm scoped initializer 约定要求 `npm create @deepseek-ai/sdk` 对应这个包名。该例外是仓库架构事实,不增加第三个开发者产品入口。
三个包只导出相邻层实际使用的最小入口,不提供 `src/*` 深路径。scripts 的库入口与构建配置子路径服务生成代码和项目构建配置,但开发者产品约定仍由 `dsh-sdk` 命令承担。
## 工程聚合与编辑会话
`SdkProject.create(root, request)` 构造尚未写盘的新工程快照,`SdkProject.open(root)` 加载已有工程。open 只要求根 `package.json``cordis.yml` 可读,其余文件是按需存在的资源;两条路径返回同一种只读聚合,并通过显式 origin 区分来源。
`project.edit()` 克隆项目文档形成 working copy。install、configure、enable、disable 和 addPlugin 等领域命令只修改 working copy命令完成后立即重新检查所属功能最终 commit 再检查全部关系和文件。
```text
validate feature requirements and resource ownership
-> validate every affected document
-> compute changed and removed paths
-> compare existing files with the session's original text
-> write through one commit boundary
-> return a new SdkProject snapshot and ChangeSet
```
校验失败或检测到会话外修改时不写盘。“一次 commit”只表示写入前零副作用和单一写入口。`ChangeSet` 只描述功能、插件和文件的最终变化,用于 Review & Apply 与 create 收尾。
## 功能与资源所有权
功能是一等行为对象。浅层基类实现 install、configure、enable、disable、required/requires 校验和通用状态检查;固定功能选项、互斥功能选项与可多选功能选项共享这些生命周期。只有资源贡献依赖项目上下文或需要自定义 round-trip 的功能才使用专用行为类,其余功能通过标准化数据声明真正不同的部分。
每项功能贡献带稳定 key 的 Cordis 配置项、NPM 依赖、环境变量占位和独占文件。注册表初始化时拒绝不同功能声明同一个资源 key同一功能的不同功能选项可以共享资源并由该功能根据最终选项集合处理。
Cordis 配置项是功能安装锚点。NPM 包名用于确定配置项所属的功能,配置项 ID 区分同一插件包的多个实例;只有 NPM 依赖而没有功能拥有的 Cordis 配置项时该功能仍视为未安装。Cordis 配置项存在后,缺失 NPM 依赖、无法读取的 Cordis 插件配置或资源冲突会使功能进入不一致状态config 命令显示诊断并拒绝猜测式修改。
配置同一功能选项时,只更新其声明拥有的配置键,保留未知键。替换功能选项会删除旧功能选项独占且仍可确认的资源;无法确认旧资源或发现独占文件被用户修改时,整个操作失败。
## 问题与工作流
问题由 TypeScript `Question<T>` 对象表达,默认值、校验、适用条件和类型留在同一个对象中。`PromptPort` 是领域层与终端库之间的唯一接口helper 提供一个轻量的 `ClackPromptPort`create 和 config 注入各自的命令行输入输出流,并在各自工作流中决定取消、返回和收尾语义。
create 的有状态问题顺序留在一个向导中config 的最终状态选择留在一个工作流中。两者通过同一个功能配置器收集功能选项与专用输入,因此增加一项普通功能、功能选项或参数不要求同时修改两个入口。
## 项目文档与模板
只有需要读取或修改的结构化文件拥有具体文档对象,包括 `package.json``cordis.yml``.env``.env.example`、根 `tsconfig.json` 和 pnpm workspace 文件。文档对象拥有解析、克隆、校验和序列化行为;具体类与模块分别使用 `*File``*-file.ts` 命名,业务层不直接操作 YAML/JSON AST异常结构会在所属文档边界明确报错。
README、入口代码、构建配置、`.gitignore` 和其他一次性文本产物使用与真实文件一一对应的完整模板。CLI命令行界面用法、创建结果与恢复提示、安装与重试指导以及默认 persona 等完整产品文案也由所属包的本地模板提供。
helper 提供通用的数据类型化 `TextTemplate` 模板渲染器,调用方包通过本地 asset URL 加载自己的模板。
模板使用 Handlebars strict mode 与 `noEscape`,不进行自定义处理。文件对象负责把类型化数据值编码成目标语言文本;模板源码在必须原样输出下游字面量时,将插值转义为 `\{{model}}`
## 命令与运行边界
scripts 支持 `dsh-sdk start/dev/build/config`。start 动态加载模块 target 并调用其命名入口dev 在同一路径前增加 TypeScript 与本地 workspace 源码解析build 调用工程安装的 tsdownconfig 打开一个编辑会话并在 Review & Apply 后提交。类型检查由生成工程直接执行 `tsc -b`
HMR热模块替换作为显式 Cordis 配置项由 dev 和 start 加载;它所需的 `node-addon-require-builtin` 由 scripts 包传递提供,不写入开发者工程的 `package.json`
dev/start 会执行开发者入口在开发者代码中处理命令行参数、cwd由开发者自行传入 `--model=<name>``--resume=<session-id>` 启动标准流程。
## 仓库本地链接模式
create-sdk 保留隐藏的 `--link-workspace` 选项供 Harness 仓库开发和 e2e 使用。该选项可以被解析,但不出现在 help、公开 flag 清单或普通用户文档中,也不接收仓库路径参数;仓库根从正在执行的 create-sdk 模块位置向上确定。
链接模式保持普通工程的文件形状。`@deepseek-ai/*` 指向 `packages/`Cordis 相关 NPM 依赖指向 `vendor/`,共享底层包锚定到仓库实际使用的同一物理拷贝,避免 Cordis 类型合并产生多个模块类型定义。npm 使用 `file:`pnpm 使用 `link:` 并关闭自动 peer 安装Yarn 使用 `portal:` 与 resolutions仓库包需要先构建。
## 后续工作
- **可替换的 required 主干角色。** 当前 `spine` 以一个固定功能选项拥有整组实现,包含 SystemPrompt、LLMService 等。无法让开发者对其进行替换和切换,只能手工修改 Cordis 配置项。
- **服务约定与包声明。** 替换特定内建服务时Cordis 插件目前无法通过 `provides` 元数据声明其提供的服务,因此 SDK 无法在开发阶段辅助配置,也无法在运行时检查兼容性。后续需要设计相应协议。
- **功能参数描述。** 当前功能的专用输入必须手工声明SDK 无法从任意 Cordis 插件配置或 NPM package.json 信息中自动推导可交互参数。后续可以定义有限的声明式参数元数据,但不把任意 Cordis 插件配置转换成通用表单。
- **SDK 应用级配置。** 当前项目资源模型只描述 Cordis 配置项及单个 Cordis 插件拥有的配置键,因此所有受 SDK 管理的配置都必须归属某个插件。跨插件或面向整个 SDK 应用的设置没有独立持久化位置;后续需要定义应用级配置文档及其所有权、读取和修改边界。
## 曾考虑的替代方案
**保留静态 Catalog 与中心 engine。** 该方案改动最小但功能参数、round-trip、独占文件和 create/config 复用都会继续进入同一个协调中心;拆文件只能缩短单文件,不能收拢职责。
**使用 `wizard.json` 与通用 Questionnaire。** 静态表单无法直接表达功能依赖、选项切换、已有值回填和项目资源变化;类型、门禁和动态选项最终仍要通过字符串注册表与过程式 `run()` 连接,形成新的内部 DSL。
**公开本地链接 flag。** 该模式依赖 Harness monorepo 布局和未发布包,只服务仓库开发;公开后会形成无法对外兑现的项目创建约定,因此保持隐藏。
## 验收标准
- create 与 config 只通过 `SdkProject``ProjectEditSession` 修改工程,写入前的任何业务、文档或并发校验失败都不产生磁盘变化
- 新增普通功能、功能选项或参数只扩展类型化 spec 或所属行为对象create/config 工作流不增加中央 switch
- 功能模型、NPM 依赖与其他资源配置、不一致检测由 helper 统一实现
- 结构化文件通过 `*File` 文档对象修改;一次性文件和完整产品文案通过所属包的 Handlebars 模板生成,业务决策不进入模板 DSL
- `dsh-sdk start/dev/build/config` 是运行时产品接口,类型检查直接使用 `tsc -b`HMR 不通过命令隐式注入,`node-addon-require-builtin` 只由 scripts 包传递提供
- `--link-workspace` 只作为隐藏的仓库开发选项存在,并对 npm、pnpm 和 Yarn 保持单一模块身份
## 风险
- 行为对象与类型化 spec 并存会形成两种扩展形状;专用类必须只用于确实依赖项目上下文或需要自定义行为的功能,否则会重新产生无意义的类型层次
- 乐观并发检查与写前校验不能解决写入中途的 I/O 故障,调用方仍需向开发者报告可能的部分提交
- 隐藏链接模式依赖仓库目录与包管理器链接语义,仓库布局或工具行为变化时必须与实现一起更新
- Cordis loader 从自身模块路径加载 `node-addon-require-builtin`;在 npm、pnpm 和 Yarn 的 NPM 依赖布局下scripts 包必须持续满足该可选对等依赖peer dependency
- Handlebars 的 `noEscape` 把目标语言编码责任交给 typed model 构造方;新增模板字段时必须在 owner 处完成正确转义,下游 Handlebars 占位符必须在模板源码中显式转义

View File

@@ -1,6 +0,0 @@
# 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 .agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md
2026-07-14-sdk-developer-projects.md: 168e667e2bbdf1f2bc9af7593fd9a7f04fb84e37
2026-07-14-sdk-developer-projects.zh.md: 3148d1b17017848fff6038f2770c4901d2a28660

View File

@@ -1,167 +0,0 @@
# Agent Note: Developer-owned SDK projects
Status: proposed
English | [中文](2026-07-14-sdk-developer-projects.zh.md)
## Problem
DeepSeek Harness composes features through Cordis plugins, but building a runnable project from an empty directory still requires a developer to understand npm dependencies, the `cordis.yml` plugin set, environment variables, TypeScript builds, local-plugin workspaces, and runtime entrypoints together. These manual steps constrain one another: omitting any one can produce a project that installs but cannot be developed, develops but cannot be built, or builds but cannot start.
A one-shot generator reduces only the initial creation cost. If the generated result is hidden inside a preset or an uneditable CLI, advanced developers cannot reshape the plugin tree, change Cordis plugin config, or add project-specific behavior. If a generated project immediately leaves tool management altogether, developers must again maintain consistency across all npm dependencies and Cordis plugin config themselves.
Initial creation and later configuration address the same builtin feature set. When those workflows maintain separate feature lists, feature options, and npm dependencies, new Cordis plugins, npm packages, and Cordis plugin config changes make them diverge. Projects also need an ordinary local-plugin development path that participates in development, build, and start flows.
## Proposal
The SDK creates an ordinary, explicit TypeScript/Cordis project owned by its developer. `cordis.yml` is the only runtime plugin tree; development and production read the same file. The generated `package.json`, `cordis.yml`, TypeScript entrypoint, build configuration, and `plugins/*` remain directly editable instead of being hidden behind a preset.
The only developer product entrypoints are `npm create @deepseek-ai/sdk` and the `dsh-sdk` commands. The initializer performs initial creation, `dsh-sdk config` manages SDK-recognized builtin features afterward, and `dsh-sdk dev`, `dsh-sdk build`, and `dsh-sdk start` own development, build, and startup; this phase provides no `dsh-sdk create`. Create and config consume one manually authored feature definition, so each feature has one source for its feature options, npm dependencies, Cordis config entries, related files, and inspection rules. The [SDK project editing architecture](../architecture/2026-07-15-sdk-project-editing-architecture.md) defines terms such as feature and feature option.
The SDK offers interaction for feature selection and finite feature options only; it does not turn arbitrary Cordis plugin config into a generic form. A feature collects the small number of dedicated inputs required by its feature options. All other Cordis plugin config remains in `cordis.yml`, with comments documenting common edits, for direct developer control.
## Developer workflow
Initial creation collects information in an order where earlier answers determine later questions: target directory and package identity, model provider and credentials, run interface, builtin features and feature options, an optional local plugin, package manager, and whether to install npm dependencies and build. Command-line arguments suppress questions they already answer. Create and config require an interactive TTY in this phase, and cancelling creation writes nothing to the target directory.
```sh
npm create @deepseek-ai/sdk my-agent
cd my-agent
npm exec dsh-sdk dev index.ts
npm exec dsh-sdk config
npm exec dsh-sdk build
npm exec dsh-sdk start index.js
```
Create rejects every target path that already exists. After committing the project files, the CLI asks whether to install npm dependencies and build. An install or build failure preserves the generated project and prints commands that can retry the failed work.
Create also offers one `none / plugin / tool` choice. `plugin` creates a fixed `plugins/plugin` Cordis plugin, while `tool` creates a fixed `plugins/tool` model-facing tool; one project creation includes at most one local plugin. The operation updates the workspace, root npm dependency, TypeScript reference, build configuration, and `cordis.yml` together, and any pre-write validation failure leaves the project absent.
## Features supported during creation
The table is the developer-visible support set for this phase. A `required` feature is always present but may still offer finite feature options; a `default` feature is preselected in the feature tree; an `optional` feature is selected explicitly. The table describes the product support set, while the runtime registry remains the implementation source of truth.
| Feature | Create state | Feature options | Constraints and relationships |
|---|---|---|---|
| `provider` | required | `deepseek` (default) / `custom` | DeepSeek collects an API key; custom also collects a base URL, and a CLI option may override the model name |
| `app` | required | `tui` (default) / `acp` / `embed` | Selects the run interface |
| `spine` | required | `default` | Timer, the LLM seam, session storage, system prompt, the tool registry, the agent registry, and the agent loop |
| `bash` | required | `local` (default) / `sandbox` | The two feature options are exclusive and independent of the run interface, and both install the model-facing bash tool; sandbox installs the local sandbox provider and sandboxed bash backend |
| `persistence` | required | `jsonl` (default) / `sqlite` | Every project selects exactly one persistence backend |
| `hmr` | default | `default` | Loads `@cordisjs/plugin-hmr`; dev and start both enable it with the plugin defaults |
| `fs` | default | `local` | Installs the local filesystem, policy, and model-facing tools; the process sandbox does not confine in-process fs tools |
| `todo` | default | `default` | Provides the `todo_write` tool |
| `skill` | default | `default` | Installs the skill registry, the local skill provider, and the model-facing skill tool |
| `web` | optional | `deepseek` (default) / `exa` / `perplexity` / `fetch-only` | Search feature options are exclusive; Exa and Perplexity collect their API keys; timeout policy is recommended |
| `subagent` | optional | `spawn` (default) / `fork`, multiple | This phase provides only in-process backends |
| `workflow` | optional | `workerthread` | Requires the subagent `spawn` feature option |
| `compact` | optional | `basic` | Uses SDK-provided context-compaction parameters |
| `hooks` | optional | `claude` (default) / `codex`, multiple | Each feature option creates a separate editable configuration file |
| `guard` | optional | `repeat-tool` | Provides repeated-tool-call reminders |
| `timeout-policy` | optional | `default` | Applies a uniform policy to tools that declare timeout budgets |
| `ask-user` | optional | `default` | Provides the `ask_user_question` tool; only `tui` can select it because ACP is an automation transport and embed provides no human-interaction service |
Both `bash` feature options apply to ACP, TUI, and embed and are not selected by the run interface. The sandbox feature option writes no active config key and therefore keeps `dsh-bash-sandbox`'s `read-only` default. Generated `cordis.yml` includes a commented example that developers can change explicitly to `workspace-write`:
```yaml
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
# Uncomment to allow writes under the project workspace.
# config:
# mode: workspace-write
# workspaceRoot: !!js process.cwd()
```
Feature contributions reference only single-plugin npm packages and never bundle packages such as `agent-spine-demo`, `tui-demo`, or `acp-demo`. Plugins outside the table are not managed by create in this phase; advanced developers may still compose them by editing the ordinary project files directly.
## Generated project
With default answers, an npm project uses the DeepSeek provider, the TUI interface, local bash, JSONL persistence, and the preselected hmr, fs, todo, and skill features. Its initial tree is:
```text
my-agent/
├── .env
├── .env.example
├── .gitignore
├── README.md
├── cordis.yml
├── index.ts
├── package.json
├── tsconfig.base.json
├── tsconfig.json
└── tsdown.config.ts
```
`.env.example` always exists, and the SDK keeps its placeholders aligned with the current feature set. A gitignored `.env` is also created when a secret is captured or the developer confirms an empty credential to fill later. The SDK only appends differently named variables that are not already present in `.env` and never updates or removes existing contents. Feature-option changes may remove obsolete `.env.example` placeholders, while old credentials remain in `.env` for the developer to manage. pnpm and Yarn projects add their required workspace files, but do not fork the runtime plugin tree or TypeScript entrypoint.
Generated `package.json` provides the following scripts. `dev`, `build`, `start`, and `config` invoke `dsh-sdk`, while `typecheck` invokes TypeScript directly:
| Script | Behavior |
|---|---|
| `dev` | Run `dsh-sdk dev index.ts`, registering development-time resolution for TypeScript and local workspace plugins |
| `build` | Run `dsh-sdk build`, invoking the project's installed tsdown for the root entrypoint and `plugins/*` packages |
| `typecheck` | Run `tsc -b` directly |
| `start` | Run `dsh-sdk start index.js`, starting the built entrypoint without an implicit build |
| `config` | Run `dsh-sdk config` to edit the current project's feature tree |
`dsh-sdk start` and `dsh-sdk dev` accept a module target and forward arguments after `--` unchanged to the project entrypoint. Generic argument parsing uses Node `parseArgs()` with zero schema: valued flags use `--key=value`, bare flags become `true`, and `--no-*` becomes `false`.
- TUI projects pass the selected model through `--model=<name>` and create or resume an agent according to optional `--resume=<session-id>`;
- ACP clients create fresh sessions through protocol `session/new`;
- Embed uses the model written into the generated code.
Each feature-owned Cordis config entry keeps its developer-editable Cordis plugin config and explanatory comments in `cordis.yml`. When `dsh-sdk config` changes other features, it preserves unknown fields, formatting on untouched nodes, and comments. HMR is an ordinary leaf config entry: when the feature is selected, dev and start load the same watcher, and the command does not change the plugin tree implicitly.
## Post-creation configuration
`dsh-sdk config` requires only readable root `package.json` and `cordis.yml` files in the current directory. It inspects standard features and their current feature options, expresses the final desired state through one feature tree, and shows feature changes and affected files before Review & Apply.
`dsh-sdk config` can install missing features, enable or disable installed features, and switch finite feature options. Required features cannot be removed. An npm dependency change runs the project package manager's install once after the file commit; installation failure does not roll back committed project files.
The SDK modifies only Cordis config entries, config keys, npm dependencies, `.env.example` placeholders, and owned files explicitly owned by a feature. Updating the same feature option preserves unknown config keys in its Cordis config entries. Handwritten and third-party plugins support enable and disable by stable ID only. When a known feature has been edited into an incomplete, ambiguous, or otherwise unreadable shape, `dsh-sdk config` displays diagnostics and refuses automatic changes until the developer repairs it manually.
One config session accumulates every change in an in-memory working copy. Before Apply, it validates feature relationships, resource conflicts, and document shapes, then compares each affected existing file with the text read when the session opened. Validation failure or an external edit causes zero writes. Once physical writes begin, the SDK does not provide cross-file transactional rollback.
## Maintenance model
The SDK curates its builtin support set instead of exposing npm packages automatically by npm dependency name or directory convention. One feature may compose several Cordis config entries, feature options may share resources, and a feature option may declare a feature requirement on another feature or a specific feature option. Adding an ordinary feature or feature option does not require changes to both create and config command workflows.
## Future work
- `dsh-sdk add [package-spec]` unifies local-plugin creation with external Cordis plugin installation: without a package or repository source it creates a local plugin/tool, while a supplied source adds the npm dependency and `cordis.yml` config entry; the source model leaves room for GitHub repositories and other extensions
- Non-interactive create/config: both workflows require a TTY in this phase and provide no complete input contract for automation
- More feature-specific inputs: this product API exposes only finite feature options, secrets, and a few dedicated values in this phase rather than a generic parameter interface for Cordis plugin config
## Alternatives considered
**An opaque preset or generator-owned project.** This shortens initial creation but hides the real plugin tree and build boundaries, prevents advanced developers from composing Cordis plugins directly, and makes project behavior depend on the CLI version rather than committed project files.
**A one-shot generator only.** Leaving all later maintenance manual redistributes feature requirements, feature-option switches, and multi-file updates. A config workflow over the shared registry retains continuing management for generated projects.
**Separate `cordis.yml` files for development and production.** Two plugin trees mean a successful development run does not demonstrate that production loads the same features. Dev adds only TypeScript and local-workspace resolution; runtime configuration remains singular.
**A generic form for arbitrary Cordis plugin config.** Cordis plugin config contains nested structures, expressions, and plugin-specific semantics. A generic form would become a second incomplete schema. The SDK manages finite feature options and dedicated secrets, while developers continue to edit complex config directly.
**A private local-plugin discovery protocol.** Ordinary package-manager workspaces, root npm dependencies, TypeScript references, and Cordis config entries already express the complete relationship. Another discovery protocol would create hidden state understood only by the SDK.
**A `dsh-sdk create` command for existing projects.** Create already provides one editable local-plugin skeleton, and later plugins can use ordinary workspace and Cordis mechanisms manually. A parallel command would add a second scaffolding product API without adding composition functionality.
**Automatically expose every new Cordis plugin as a builtin.** An npm package cannot say how several plugins compose into one product feature, nor can it derive exclusivity, feature requirements, secrets, interface applicability, or security constraints. The support set requires human curation; automation is suitable only for checking whether candidates have been classified.
## Acceptance criteria
- `npm create @deepseek-ai/sdk` collects project identity, provider, interface, features, an optional local plugin, package manager, and installation choice in the documented order, and cancellation leaves the target path absent
- A default npm project has the documented tree and `dev`, `build`, `typecheck`, `start`, and `config` scripts, with dev and start sharing one `cordis.yml`
- Create offers the documented features and feature options; local and sandbox bash are exclusive with local as the default, the sandbox Cordis config entry retains the editable commented config example, and HMR is selected by default and loaded by both dev and start
- Create's `plugin` or `tool` choice creates at most one fixed-name local plugin and atomically updates its files and root-project relationships; this phase provides no `dsh-sdk create`
- `dsh-sdk config` reads the same support set from an existing project, installs, enables, disables, and switches supported feature options, preserves unknown config and comments, and refuses to modify inconsistent config
- `.env.example` reflects variables required by the current features; `.env` only appends missing differently named variables and never updates or removes existing contents
- npm, pnpm, and Yarn workspaces install, build, and start; local plugins resolve from source under dev and from built output under start
## Risks
- Developers can edit a builtin into a shape the registry cannot recognize; the SDK stops automating that feature instead of guessing and overwriting config
- Pre-write validation and external-edit detection do not provide transactional rollback once multi-file writes begin; an I/O failure can leave a partial commit requiring manual repair
- The sandbox feature option depends on an available local sandbox backend for the target platform; an unavailable backend must fail closed instead of falling back to unsandboxed execution
- HMR retains its filesystem watcher and hot-reload behavior under production start; this is the result of an explicit plugin choice, not an implicit development-only service
- The append-only `.env` policy retains credentials that are no longer used; the SDK does not decide when user-owned secret data is safe to delete

View File

@@ -1,167 +0,0 @@
# Agent Note: 开发者拥有的 SDK 工程
Status: proposed
[English](2026-07-14-sdk-developer-projects.md) | 中文
## 问题
DeepSeek Harness 通过 Cordis 插件对功能进行组合,但从空目录开始搭建一个可运行工程仍要求开发者同时理解 NPM 依赖、`cordis.yml` 插件组、环境变量、TypeScript 构建、本地插件 workspace 和运行入口。手工步骤之间存在约束,漏掉任意一处都会得到能够安装却无法开发、能够开发却无法构建,或能够构建却无法启动的工程。
一次性生成器只能降低首次创建成本。若生成结果隐藏在 preset 或不可编辑的 CLI命令行界面内部高级开发者无法调整插件树、修改 Cordis 插件配置或增加项目特有行为;若创建后的工程完全脱离工具管理,开发者又必须重新承担所有 NPM 依赖和 Cordis 插件配置的一致性工作。
初始创建和后续配置面对同一组内置功能。两条流程各自维护功能列表、功能选项和 NPM 依赖时,新增 Cordis 插件、NPM 包或调整 Cordis 插件配置会使二者逐渐分叉。工程还需要一条普通的本地插件开发路径,参与开发、构建和启动流程。
## 提案
SDK 创建一个普通、显式且归开发者所有的 TypeScript/Cordis 工程。`cordis.yml` 是唯一的运行时插件树;开发和生产读取同一份文件。工程中的 `package.json``cordis.yml`、TypeScript 入口、构建配置和 `plugins/*` 均可直接编辑SDK 不把它们封装成不可见的 preset。
开发者产品入口只有 `npm create @deepseek-ai/sdk``dsh-sdk` 命令。前者负责首次创建,`dsh-sdk config` 在创建后管理 SDK 能识别的内置功能,`dsh-sdk dev``dsh-sdk build``dsh-sdk start` 负责开发、构建和启动;本期不提供 `dsh-sdk create`。create 与 config 使用同一份人工编写的功能定义因此一项功能的功能选项、NPM 依赖、Cordis 配置项、相关文件和识别规则只有一个来源。[SDK 工程编辑架构](../architecture/2026-07-15-sdk-project-editing-architecture.md) 定义了功能、功能选项等术语。
SDK 只为功能选择和有限功能选项提供交互,不尝试把任意 Cordis 插件配置变成通用表单。功能选项所需的少量专用输入由所属功能收集;其余 Cordis 插件配置留在 `cordis.yml` 中,并通过注释指明常用改法,由开发者直接修改。
## 开发者流程
首次创建按会影响后续问题集合的顺序收集信息:目标目录与包身份、模型提供方与凭据、运行接口、内置功能与功能选项、可选本地插件、包管理器,以及是否安装 NPM 依赖并构建。命令参数已提供的答案不重复询问;本期 create 和 config 都要求交互式 TTY取消创建时不写入目标目录。
```sh
npm create @deepseek-ai/sdk my-agent
cd my-agent
npm exec dsh-sdk dev index.ts
npm exec dsh-sdk config
npm exec dsh-sdk build
npm exec dsh-sdk start index.js
```
create 拒绝任何已经存在的目标路径。工程文件提交成功后CLI 询问是否安装 NPM 依赖并构建;安装或构建失败时保留生成结果,并打印可以重新执行的命令。
create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `plugins/plugin` 的 Cordis 插件,`tool` 固定生成 `plugins/tool` 的模型工具;一次创建至多包含一个本地插件。生成操作同时更新 workspace、根 NPM 依赖、TypeScript reference、构建配置和 `cordis.yml`,任何写入前校验失败都不创建工程。
## 创建时支持的功能
下表是本期 create 面向开发者展示的支持集。`required` 始终存在但仍可切换有限功能选项;`default` 在选择树中预选;`optional` 由开发者主动选择。表格说明产品支持集,运行时注册表是实现的真源。
| 功能 | create 状态 | 功能选项 | 限制与关系 |
|---|---|---|---|
| `provider` | required | `deepseek`(默认)/ `custom` | DeepSeek 收集 API keycustom 另收集 base URL模型名可由 CLI 参数覆盖 |
| `app` | required | `tui`(默认)/ `acp` / `embed` | 选择运行接口 |
| `spine` | required | `default` | timer、LLM大语言模型seam、会话存储、系统提示词、工具注册表、agent 注册表,以及 agent loop智能体循环 |
| `bash` | required | `local`(默认)/ `sandbox` | 两个功能选项互斥、与运行接口正交,且都安装面向模型的 bash 工具sandbox 安装本地沙箱提供方和沙箱 bash 后端 |
| `persistence` | required | `jsonl`(默认)/ `sqlite` | 每个工程恰好选择一个持久化后端 |
| `hmr` | default | `default` | 加载 `@cordisjs/plugin-hmr`dev 和 start 都启用,使用插件默认配置 |
| `fs` | default | `local` | 安装本地文件系统、策略和模型工具;进程沙箱不约束进程内 fs 工具 |
| `todo` | default | `default` | 提供 `todo_write` 工具 |
| `skill` | default | `default` | 安装 skill技能注册表、本地 skill 提供方和面向模型的 skill 工具 |
| `web` | optional | `deepseek`(默认)/ `exa` / `perplexity` / `fetch-only` | 搜索功能选项互斥Exa/Perplexity 收集各自 API key建议同时启用 timeout policy |
| `subagent` | optional | `spawn`(默认)/ `fork`,可多选 | 本期只提供进程内后端 |
| `workflow` | optional | `workerthread` | 要求 subagent 的 `spawn` 功能选项 |
| `compact` | optional | `basic` | 使用 SDK 提供的上下文压缩context compaction参数 |
| `hooks` | optional | `claude`(默认)/ `codex`,可多选 | 各功能选项生成独立的可编辑配置文件 |
| `guard` | optional | `repeat-tool` | 提供重复工具调用提醒 |
| `timeout-policy` | optional | `default` | 对声明超时预算的工具执行统一策略 |
| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;只有 `tui` 可选,因为 ACPAgent Client Protocol是自动化传输而 embed 不提供人类交互服务 |
`bash` 的两个功能选项都适用于 ACP、TUI 和 embed不由运行接口决定。sandbox 功能选项不写任何生效的配置键,因而沿用 `dsh-bash-sandbox``read-only` 默认值;生成的 `cordis.yml` 保留注释示例,开发者可以显式改为 `workspace-write`
```yaml
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
# Uncomment to allow writes under the project workspace.
# config:
# mode: workspace-write
# workspaceRoot: !!js process.cwd()
```
功能贡献只引用单插件 NPM 包,绝不引用 `agent-spine-demo``tui-demo``acp-demo` 这类组合 NPM 包。表格之外的插件不由本期 create 管理;开发者仍可直接编辑普通工程文件进行高级组合。
## 生成工程
使用默认答案创建 npm 工程时,提供方为 DeepSeek运行接口为 TUIbash 为 local持久化为 JSONLhmr、fs、todo 与 skill 处于选中状态。初始目录树为:
```text
my-agent/
├── .env
├── .env.example
├── .gitignore
├── README.md
├── cordis.yml
├── index.ts
├── package.json
├── tsconfig.base.json
├── tsconfig.json
└── tsdown.config.ts
```
`.env.example` 始终存在,并由 SDK 根据当前功能维护占位。收集到 secret 或开发者确认稍后填写空凭据时,同时生成 gitignored `.env`。SDK 只向 `.env` 追加尚不存在的不同名变量,绝不覆盖或删除已有内容;切换功能选项可以清理 `.env.example` 中不再需要的占位,但旧凭据仍留在 `.env` 中供开发者自行处理。pnpm 和 Yarn 工程增加各自所需的 workspace 配置文件,但运行时插件树和 TypeScript 入口不分叉。
生成的 `package.json` 提供以下 scripts其中 `dev``build``start``config` 调用 `dsh-sdk``typecheck` 直接调用 TypeScript
| script | 行为 |
|---|---|
| `dev` | 运行 `dsh-sdk dev index.ts`,为 TypeScript 和本地 workspace 插件注册开发期解析 |
| `build` | 运行 `dsh-sdk build`,调用工程安装的 tsdown 构建根入口和 `plugins/*` 包 |
| `typecheck` | 直接运行 `tsc -b` |
| `start` | 运行 `dsh-sdk start index.js`,启动已构建入口且不隐式构建 |
| `config` | 运行 `dsh-sdk config`,修改当前工程功能树 |
`dsh-sdk start``dsh-sdk dev` 可以接收模块 target并把 `--` 后的参数原样转发给工程入口。通用参数解析使用 Node `parseArgs()` 的零 schema 模式:带值 flag 采用 `--key=value`bare flag 转换为 `true``--no-*` 转换为 `false`
- TUI 工程通过 `--model=<name>` 传入所选 model并根据可选的 `--resume=<session-id>` 创建或恢复 agent
- ACP 客户端通过协议 `session/new` 创建全新会话;
- embed 使用生成代码中的 model。
每个功能拥有的 Cordis 配置项在 `cordis.yml` 中保留自己的可编辑 Cordis 插件配置和说明注释;`dsh-sdk config` 修改其他功能时必须保留未知字段、未修改节点的格式和注释。HMR热模块替换是普通叶子配置项选择该功能后dev 和 start 加载同一个 watcher命令不隐式改变插件树。
## 创建后的配置
`dsh-sdk config` 只要求当前目录具有可读的根 `package.json``cordis.yml`。它检查标准功能及其当前功能选项,以一棵功能树表达最终目标状态,并在 Review & Apply 前展示功能变化和受影响文件。
`dsh-sdk config` 可以安装缺失功能、启停已安装功能和切换有限功能选项。required 功能不能取消。改变 NPM 依赖后只运行一次项目包管理器安装;安装失败不回滚已经提交的工程文件。
SDK 只修改功能明确拥有的 Cordis 配置项、配置键、NPM 依赖、`.env.example` 占位和自有文件。同一功能选项的更新保留 Cordis 配置项中的未知配置键;手写或第三方插件只支持按稳定 ID 启停。已知功能被手改成不完整、歧义或无法读取的形状时,`dsh-sdk config` 显示诊断并拒绝自动修改,直到开发者手工修复。
一次 config 会话在内存工作区上累计全部修改。Apply 前完成功能关系、资源冲突和文件形状校验,并比较受影响文件与会话打开时的原文;校验失败或检测到外部修改时不写盘。实际写盘开始后不提供跨文件事务回滚。
## 维护模型
Builtin 支持集由 SDK 人工策划,不根据 NPM 依赖名称或目录约定自动暴露。一个功能可以组合多个 Cordis 配置项,功能选项可以共享资源,并声明对其他功能或特定功能选项的功能依赖;新增普通功能或功能选项无需同时修改 create 和 config 两个命令流程。
## 后续工作
- `dsh-sdk add [package-spec]`:统一本地插件创建与外部 Cordis 插件接入;未指定包或仓库来源时创建本地插件/工具,指定来源时增加 NPM 依赖和 `cordis.yml` 配置项,来源模型为 GitHub 仓库等扩展保留空间
- 非交互 create/config本期两个流程都要求 TTY不提供供自动化调用的完整输入约定
- 更多功能专用参数输入:本期产品 API 只暴露有限功能选项、secret 和少量专用值,不为 Cordis 插件配置提供通用参数界面
## 曾考虑的替代方案
**不可编辑的 preset 或生成器托管工程。** 该方案可以缩短初次创建路径,但会隐藏真实插件树和构建边界,使高级开发者无法直接组合 Cordis 插件,也让项目行为依赖 CLI 版本而不是检入的工程文件。
**只提供一次性生成器。** 创建后完全依赖手工维护,会让功能依赖、功能选项切换和多文件更新再次分散;共享注册表的 config 流程为生成工程保留持续管理机制。
**为开发和生产维护两份 `cordis.yml`。** 两份插件树会使开发成功无法证明生产加载相同功能dev 只增加 TypeScript 与本地 workspace 解析,运行配置保持唯一。
**为任意 Cordis 插件配置生成通用表单。** Cordis 插件配置包含嵌套结构、表达式和插件特有语义,通用表单会形成第二套不完整 schema。SDK 只管理有限功能选项和专用 secret复杂配置继续由开发者直接编辑。
**使用私有协议发现本地插件。** 普通包管理器 workspace、根 NPM 依赖、TypeScript references 和 Cordis 配置项已能表达完整关系;额外发现协议会创造只能由 SDK 理解的隐藏状态。
**在现有工程中提供 `dsh-sdk create`。** create 已能生成一个可编辑的本地插件骨架,后续插件可以沿用普通 workspace 和 Cordis 机制手工添加;再提供并行命令会增加第二条脚手架产品面,却不增加新的组合功能。
**把每个新 Cordis 插件自动暴露为 builtin。** 包无法说明多个插件如何组合成一项产品功能也无法推导互斥关系、功能依赖、secret、接口适用性和安全限制支持集需要人工策划自动化只适合检查候选是否完成分类。
## 验收标准
- `npm create @deepseek-ai/sdk` 按本文顺序收集项目身份、提供方、接口、功能、可选本地插件、包管理器和安装选择,并在取消时保持目标路径不存在
- 默认 npm 工程具有本文目录树和 `dev``build``typecheck``start``config` scripts且 dev/start 使用同一份 `cordis.yml`
- create 展示本文功能及功能选项;`bash` 的 local/sandbox 二选一且默认 localsandbox Cordis 配置项保留可编辑的注释配置示例HMR 默认选中并同时由 dev/start 加载
- create 的 `plugin``tool` 选择至多生成一个固定名称的本地插件,并原子更新插件文件与根工程关系;本期不提供 `dsh-sdk create`
- `dsh-sdk config` 从现有工程读取同一支持集,能够安装、启停和切换支持的功能选项,保留未知配置与注释,并拒绝修改不一致配置
- `.env.example` 反映当前功能所需变量;`.env` 只追加缺失的不同名变量,从不覆盖或清理已有内容
- npm、pnpm 和 Yarn 生成的 workspace 能安装、构建和启动;本地插件在 dev 中使用源码,在 start 中使用构建产物
## 风险
- 开发者可以把 builtin 手改成注册表无法识别的形状SDK 选择停止自动管理该功能而不是猜测并覆盖配置
- 多文件写入前的校验和外部修改检测不能提供写入阶段的事务回滚I/O 中途失败可能留下需要人工修复的部分提交
- sandbox 功能选项依赖目标平台存在可用的本地沙箱后端;后端不可用时必须 fail closed不能退回无沙箱执行
- HMR 在生产启动中也保持文件 watcher 和热重载行为;这是显式插件选择的结果,不是仅限开发环境的隐式服务
- `.env` 的仅追加策略会保留已经不用的凭据SDK 不判断这些用户拥有的密钥数据何时可以安全删除

View File

@@ -1,6 +0,0 @@
# 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 .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md
2026-07-17-sdk-follow-up-capabilities.md: f14d46a61f5fd3e64067441c2f8340cf94746a79
2026-07-17-sdk-follow-up-capabilities.zh.md: 2e5efba340d503d2445e408bfc43ee0d6c6bec3c

View File

@@ -1,120 +0,0 @@
# Agent Note: SDK follow-up capabilities
Status: proposed
English | [中文](2026-07-17-sdk-follow-up-capabilities.zh.md)
## Problem
The first SDK release creates and edits developer-owned Cordis projects through the shared model defined by the [developer-project Agent Note](2026-07-14-sdk-developer-projects.md) and the [project-editing architecture](../architecture/2026-07-15-sdk-project-editing-architecture.md). Its create and config workflows are interactive, external Cordis plugins require manual dependency and configuration edits, command-line telemetry has no owning boundary, and interactive branches lack a stable test strategy.
These gaps are coupled. Create and config already share questions, feature configuration, and `ProjectEditSession`; adding separate automation paths would duplicate that domain logic. External-plugin installation must update both the package manager's files and `cordis.yml`. Telemetry must observe commands such as create and build that do not boot Cordis. Interactive testing must exercise Harness behavior without making terminal rendering a brittle product contract.
## Proposal
The SDK extends the existing prompt and project-editing boundaries instead of creating parallel workflows. A non-interactive prompt port and structured feature plan drive create and config, `dsh-sdk create <source>` delegates dependency resolution to the project package manager before mounting the resolved package through `ProjectEditSession`, launcher-side telemetry wraps `create-sdk` and every `dsh-sdk` command, and injected prompt streams provide the primary interactive-test hook.
| Capability | Product entrypoint | Owning mechanism | Required outcome |
|---|---|---|---|
| Headless project creation | `create-sdk --config <file>` or `--config-json <json>` with optional `--json` | `HeadlessPromptPort`, structured project answers, and a complete feature plan | No terminal blocking; missing required input is explicit |
| External Cordis plugin installation | `dsh-sdk create <source>` | Native package-manager `add` plus `ProjectEditSession` | The dependency and `cordis.yml` entry identify the package manager's resolved package |
| Developer-cycle telemetry | `create-sdk` and every `dsh-sdk` command | Launcher-side consent, payload, redaction, anonymous identity, and delivery services | Reporting is best-effort and cannot change the command result |
| Interactive regression coverage | Create and config tests | Injected `PromptPort` input/output and filesystem assertions | Tests cover Harness decisions and generated files without snapshotting terminal repainting |
## Shared headless workflow
### Structured input and lifecycle events
Headless create accepts a JSON object either inline through `--config-json` or from a file through `--config`. Scalar fields supply the ordinary create answers, while `features` supplies the complete selected feature set, feature options, secrets, and dedicated values. Defaults remain valid only where the owning question declares one; the headless path never invents an answer for a required prompt.
With `--json`, stdout is an NDJSON event stream. `done` means creation and any requested setup completed, `action-required` names an unanswered required prompt, and `error` reports another failure. Human-readable progress and package-manager output go to stderr so every stdout line remains parseable as one event. A caller responds to `action-required` by adding the missing value and running the command again.
Create and config consume the same feature-plan shape. Create exposes it through the command-line inputs above; config uses it at the shared workflow boundary so a later automation entrypoint does not need a second feature-selection model.
### Prompt and project-editing boundaries
`PromptPort` remains the only boundary between SDK questions and an interaction implementation. `ClackPromptPort` handles terminals. `HeadlessPromptPort` consumes defaults exposed by the question contract and otherwise fails with the unanswered prompt; prefilled values normally prevent the port from being called.
Both paths use the same `Question` objects, `FeatureConfigurator`, `SdkProject`, and `ProjectEditSession`. The headless path therefore changes how answers arrive, not how features are interpreted or files are committed.
### Agent skill
The repository ships a thin `SKILL.md` that teaches an agent to construct the structured input, request NDJSON, fill an `action-required` value, and retry. The skill invokes the public CLI and does not import an internal SDK API or introduce another project specification.
## External Cordis plugin installation
`dsh-sdk create <source>` accepts a package-manager-native npm specifier such as `pkg@version` or a GitHub specifier such as `github:owner/repo#ref`. After confirmation, it asks the project's package manager to add the source, compares the direct dependency names before and after the operation, reopens the project, and mounts each newly resolved package in `cordis.yml` through `ProjectEditSession`.
The package manager owns source parsing, version or commit resolution, integrity data, lockfile updates, and any build policy. The SDK does not download or unpack a second copy through giget or pacote. An external plugin remains a dependency under `node_modules`; local plugin scaffolding remains a separate project-creation concern.
This proposal concerns dependencies of developer-owned SDK projects. Standalone apps install external packages as [profile bundles](../../implemented/simplification/2026-08-09-remove-repository-plugin.md), with their profile package manager and lockfile owning acquisition and lifecycle policy.
## Launcher telemetry
### Consent and collection
Telemetry wraps the `create-sdk` initializer and the `dsh-sdk` launcher command lifecycle because project initialization, plugin creation, and build do not reliably boot Cordis. One event records the command name, duration, success, a random per-user anonymous identifier, and redacted `cordis.yml` and `package.json` text when those project files are eligible.
Reporting is enabled unless a present telemetry config entry is explicitly disabled. `DO_NOT_TRACK` and CI deny reporting regardless of project configuration. A missing `cordis.yml` does not itself deny the event, but `package.json` content is included only when `cordis.yml` establishes that the directory is an SDK project.
### Safety and delivery
The payload builder never reads `.env`. It redacts secret-shaped keys and values, known token forms, PEM blocks, URL credentials, and high-entropy opaque strings in the two eligible text files. Redaction is a safety backstop rather than a guarantee; SDK projects must keep credentials in `.env`.
The reporter uses a fixed endpoint and resolves every send path without throwing. Command dispatch records success or failure in a `finally` path, starts reporting after the command outcome is known, and drains within a bounded interval. Consent parsing, payload construction, storage, or network failures are swallowed only at this telemetry boundary and never alter the command's exit code.
## Interactive workflow testing
Create and config tests inject a `PromptPort` and scripted input/output streams into the existing workflows. Parameterized scenarios cover feature selection, feature options, secrets, cancellation, review, and apply behavior, then assert the resulting `cordis.yml` and other project files. The stable product assertion is the generated project state, not clack's ANSI redraw sequence.
One or two optional real-PTY smoke tests may cover the shipped binary and TTY guard that injection cannot reproduce. Native PTY tooling does not belong on the required path unless it is reliable across the repository's supported Node and host versions.
## Deferred work
- Extend the headless create specification to express local `plugin` or `tool` scaffolding instead of defaulting that interactive choice to none.
- Expose the telemetry opt-out in create and config while preserving the consent representation in which only a disabled telemetry entry is written.
- Define whether GitHub source dependencies must be prebuilt or may run package-manager-controlled preparation scripts, and surface the policy before installation.
- Replace the telemetry package's `.invalid` endpoint placeholder with the production endpoint before release.
## Alternatives considered
**Build a separate headless creation engine.** This would duplicate questions, feature requirements, configuration behavior, and project-editing rules. Reusing the prompt and edit-session boundaries keeps one implementation of project semantics.
**Make a specification file the primary automation interface.** Agents can pass the same typed JSON object inline, while people and CI may still use a file. A file-only protocol adds persistence and cleanup without adding semantics.
**Use `npx skills add` as the project creator.** The skills CLI installs Markdown skills; it does not create SDK projects or install npm packages. The agent skill therefore drives the SDK initializer instead of replacing it.
**Fetch GitHub and npm sources through giget or pacote.** A second fetch layer would duplicate package-manager resolution, integrity, lockfile, and lifecycle policy. Native dependency specifiers keep those decisions in the selected package manager.
**Implement telemetry as a Cordis runtime plugin.** Create and build do not necessarily boot Cordis, so a runtime plugin cannot observe the complete developer command cycle. The launcher is the boundary shared by those commands.
**Derive the anonymous identifier from git metadata.** Repository remotes can identify a project or organization. A random per-user identifier supports aggregation without encoding repository identity.
**Collect only aggregate counters.** Aggregate-only events reduce exposure but cannot answer which plugins, dependencies, and configuration shapes developers actually use. This proposal accepts collection of redacted project text and makes that exposure explicit.
**Use real PTYs and transcript snapshots as the primary test strategy.** Native PTY dependencies and terminal repaint sequences add platform and rendering instability while mostly testing clack. Injected interaction plus generated-file assertions tests the SDK-owned behavior directly.
## Acceptance criteria
- Create runs without a TTY from a complete structured input, emits only NDJSON on stdout under `--json`, and reports missing required input as `action-required` without writing a partial project.
- Create and config resolve the same feature-plan contract through the shared question, feature-configuration, and project-editing code paths.
- `dsh-sdk create <source>` uses the selected project package manager, mounts the dependency name that operation actually added, and fails loudly when no new dependency can be identified.
- The initializer and every `dsh-sdk` command reach one best-effort telemetry completion path; an explicit disabled entry, `DO_NOT_TRACK`, or CI prevents delivery, and telemetry failures never change the command result.
- Telemetry never reads `.env`, withholds unrelated `package.json` content when no `cordis.yml` exists, redacts both eligible text payloads, and uses an identifier unrelated to git metadata.
- Interactive tests cover create and config decisions through injected interaction and assert committed project files; any real-PTY coverage remains a narrow smoke layer.
- The agent skill documents the public structured-input and event contracts without depending on private package exports.
## Risks
- Full redacted `cordis.yml` and `package.json` text still reveals plugin and dependency names, URLs, paths, and configuration values to the endpoint operator, and heuristic redaction can miss a secret.
- Default-on reporting may surprise developers when no telemetry entry exists; the CLI must make the opt-out discoverable before release.
- A package-manager add can change `package.json`, the lockfile, and installed files before `ProjectEditSession` mounts the plugin, so a later mount failure can leave dependency changes that require manual recovery.
- GitHub dependencies may execute preparation or lifecycle code according to package-manager policy; an unresolved build policy is a supply-chain and reproducibility risk.
- Injected prompt tests do not prove raw-mode, signal, or repaint behavior in a real terminal; the optional smoke layer must cover only those residual contracts.
## References
- [Vercel Eve](https://github.com/vercel/eve) and [Vercel Labs Skills](https://github.com/vercel-labs/skills) for the distinction between a headless initializer and skill distribution.
- [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec), [pnpm add](https://pnpm.io/cli/add), and [Yarn add](https://yarnpkg.com/cli/add) for package-manager-native sources.
- [`DO_NOT_TRACK`](https://donottrack.sh/) for the environment-level opt-out convention.
- [Clack](https://github.com/bombshell-dev/clack) and [Vitest snapshots](https://vitest.dev/guide/snapshot) for injected prompts and generated-file assertions.

View File

@@ -1,120 +0,0 @@
# Agent Note: SDK 后续功能
Status: proposed
[English](2026-07-17-sdk-follow-up-capabilities.md) | 中文
## 问题
首个 SDK 版本通过[开发者工程 Agent Note](2026-07-14-sdk-developer-projects.md) 和 [SDK 工程编辑架构](../architecture/2026-07-15-sdk-project-editing-architecture.md)定义的共享模型创建和编辑开发者拥有的 Cordis 工程。create 和 config 工作流仅支持交互调用,接入外部 Cordis 插件需要手工修改依赖和配置,命令行遥测没有明确的所属边界,交互分支也缺少稳定的测试策略。
这些缺口彼此关联。create 和 config 已经共享问题、功能配置和 `ProjectEditSession`;若另建自动化路径,就会复制领域逻辑。安装外部插件必须同时修改包管理器文件和 `cordis.yml`。遥测需要观察 create、build 等不会启动 Cordis 的命令。交互测试需要覆盖 Harness 自身行为,同时避免把终端渲染固化成脆弱的产品约定。
## 提案
SDK 扩展现有提示词与工程编辑边界,不另建平行工作流。非交互式 `PromptPort` 实现和结构化功能计划驱动 create 与 config`dsh-sdk create <source>` 先把依赖解析交给工程的包管理器,再通过 `ProjectEditSession` 挂载解析所得的包;启动器侧遥测包住 `create-sdk` 和每个 `dsh-sdk` 命令;注入的提示词输入输出流提供主要的交互测试钩子。
| 功能 | 产品入口 | 所属机制 | 必须达到的结果 |
|---|---|---|---|
| Headless 工程创建 | `create-sdk --config <file>``--config-json <json>`,可搭配 `--json` | `HeadlessPromptPort`、结构化工程答案和完整功能计划 | 不阻塞等待终端;明确报告缺失的必答输入 |
| 外部 Cordis 插件安装 | `dsh-sdk create <source>` | 包管理器原生 `add``ProjectEditSession` | 依赖和 `cordis.yml` 配置项指向包管理器解析出的包 |
| 开发周期遥测 | `create-sdk` 和每个 `dsh-sdk` 命令 | 启动器侧的上报条件判断、遥测内容构建、脱敏、匿名身份和传输服务 | 上报采用尽力而为语义,不能改变命令结果 |
| 交互回归覆盖 | create 和 config 测试 | 注入的 `PromptPort` 输入输出和文件系统断言 | 测试覆盖 Harness 决策与生成文件,不快照终端重绘 |
## 共享 headless 工作流
### 结构化输入和生命周期事件
Headless create 通过 `--config-json` 接收内联 JSON 对象,或通过 `--config` 从文件读取。标量字段提供普通 create 答案,`features` 提供完整的已选功能、功能选项、secret密钥和专用值。只有所属问题明确声明的默认值才有效headless 路径绝不为必答问题臆造答案。
使用 `--json`stdout 是 NDJSON 事件流。`done` 表示创建及要求执行的安装和构建均已完成,`action-required` 指明一个尚未回答的必答问题,`error` 报告其他失败。面向人的进度信息和包管理器输出写入 stderr确保 stdout 每一行都能解析成一个事件。调用方收到 `action-required` 后补充缺失值,再次运行命令。
Create 和 config 使用相同的功能计划形状。create 通过上述命令行输入公开该形状config 在共享工作流边界使用同一形状,使后续自动化入口无需另建功能选择模型。
### Prompt 与工程编辑边界
`PromptPort` 仍是 SDK 问题与交互实现之间的唯一边界。`ClackPromptPort` 负责终端交互。`HeadlessPromptPort` 使用问题约定公开的默认值,否则通过未回答问题快速失败;预填值通常会让流程根本不调用该 port。
两条路径使用相同的 `Question` 对象、`FeatureConfigurator``SdkProject``ProjectEditSession`。因此headless 路径只改变答案的到达方式,不改变功能解释或文件提交方式。
### Agent skill
仓库提供一份轻量 `SKILL.md`,指导 agent skill智能体技能构造结构化输入、请求 NDJSON、补充 `action-required` 指明的值并重试。该 skill 调用公开 CLI不导入 SDK 内部 API也不引入另一套工程规格。
## 外部 Cordis 插件安装
`dsh-sdk create <source>` 接受包管理器原生的 npm package specifier例如 `pkg@version`,也接受 `github:owner/repo#ref` 等 GitHub package specifier。用户确认后命令要求工程包管理器添加来源对比操作前后的直接依赖名重新打开工程再通过 `ProjectEditSession` 把每个新增且已解析的包挂载进 `cordis.yml`
包管理器负责来源解析、版本或 commit 解析、`integrity` 数据、lockfile 更新和构建策略。SDK 不再通过 giget 或 pacote 下载、解压第二份副本。外部插件是 `node_modules` 下的依赖;本地插件脚手架仍属于独立的工程创建问题。
本提案只涉及开发者自有 SDK 工程的依赖。独立应用将外部包安装为 [profile 组合包](../../implemented/simplification/2026-08-09-remove-repository-plugin.md),由 profile 的包管理器与 lockfile 负责获取和生命周期策略。
## Launcher 遥测
### Consent 与采集
遥测包住 `create-sdk` 初始化命令与 `dsh-sdk` launcher 的命令生命周期,因为工程初始化、插件创建和 build 都不会稳定地启动 Cordis。每个事件记录命令名、时长、成败、随机生成的用户级匿名标识符以及符合条件时经过脱敏的 `cordis.yml``package.json` 文本。
除非当前存在的遥测配置项被明确禁用,否则允许上报。`DO_NOT_TRACK` 和 CI 无论工程配置如何都禁止上报。缺少 `cordis.yml` 本身不会禁止事件,但只有 `cordis.yml` 能证明目录是 SDK 工程时,遥测内容才包含 `package.json` 文本。
### 安全与传输
Payload 构建器绝不读取 `.env`。它会脱敏两个符合条件的文本文件中的疑似密钥键和值、已知 token 形式、PEM 块、URL 凭据和高熵不透明字符串。脱敏只是安全兜底不能提供绝对保证SDK 工程必须把凭据放进 `.env`
`TelemetryReporter` 使用固定 endpoint每条发送路径都会正常结束且不抛错。命令分发通过 `finally` 路径记录成败,在命令结果已确定后启动上报,并在有界时间内等待传输结束。只有遥测边界会吞掉上报条件解析、遥测内容构建、存储或网络错误,这些错误绝不改变命令退出码。
## 交互工作流测试
Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入输出流。参数化场景覆盖功能选择、功能选项、secret、取消、评审和应用行为再断言最终的 `cordis.yml` 及其他工程文件。稳定的产品断言是生成后的工程状态,不是 clack 的 ANSI 重绘序列。
可以用一到两个可选的真实 PTY 冒烟测试覆盖注入无法复现的发布二进制和 TTY 检查。除非原生 PTY 工具在仓库支持的 Node 与宿主版本上足够可靠,否则它不进入必跑路径。
## 延后工作
- 扩展 headless create 规格,使其能表达本地 `plugin``tool` 脚手架,而不是把该交互选择默认为 none。
- 在 create 和 config 中公开遥测关闭选项,同时保留只有禁用时才写入遥测配置项的上报许可表示。
- 明确 GitHub 来源依赖必须预先构建,还是允许运行由包管理器控制的 preparation script准备脚本并在安装前向用户展示该策略。
- 发布前把遥测包中的 `.invalid` endpoint 占位符替换为生产端点。
## 曾考虑的替代方案
**另建 headless 创建引擎。** 该方案会复制问题、功能依赖、配置行为和工程编辑规则。复用提示词与编辑会话边界,可以保证工程语义只有一份实现。
**把规格文件作为主要自动化接口。** Agent 可以内联传入相同的类型化 JSON 对象,人和 CI 仍可选用文件。文件专用协议会增加持久化与清理工作,却不增加语义。
**使用 `npx skills add` 创建工程。** Skills CLI 只安装 Markdown skill不创建 SDK 工程,也不安装 npm 包。因此agent skill 驱动 SDK 初始化命令,而不是取代它。
**通过 giget 或 pacote 获取 GitHub 与 npm 来源。** 第二套获取层会复制包管理器的解析、完整性、lockfile 和生命周期策略。原生 package specifier 让这些决策留在所选包管理器中。
**把遥测实现成 Cordis 运行时插件。** Create 和 build 不一定启动 Cordis因此运行时插件无法观察完整的开发命令周期。Launcher 是这些命令共用的边界。
**从 git 元数据派生匿名标识符。** 仓库的 git remote 可能识别工程或组织。随机的用户级标识符能够支持聚合,同时不编码仓库身份。
**只采集聚合计数。** 仅聚合事件可以降低暴露,但无法回答开发者实际使用哪些插件、依赖和配置形状。本提案接受采集脱敏后的工程文本,并明确记录这项暴露。
**把真实 PTY 和 transcript文本记录快照作为主要测试策略。** 原生 PTY 依赖与终端重绘序列会带来平台和渲染不稳定性,而且主要是在测试 clack。注入交互并断言生成文件可以直接测试 SDK 拥有的行为。
## 验收标准
- Create 能依据完整结构化输入在没有 TTY 时运行;使用 `--json` 时 stdout 只输出 NDJSON缺少必答输入时通过 `action-required` 报告,且不写入部分工程。
- Create 和 config 通过共享的问题、功能配置和工程编辑代码路径解析相同的功能计划约定。
- `dsh-sdk create <source>` 使用工程选定的包管理器,挂载该操作实际新增的依赖名;无法识别新增依赖时快速失败。
- 初始化命令与每个 `dsh-sdk` 命令都进入同一条尽力而为的遥测收尾路径;明确禁用的配置项、`DO_NOT_TRACK` 或 CI 会阻止传输,遥测失败绝不改变命令结果。
- 遥测绝不读取 `.env`;没有 `cordis.yml` 时不发送无关的 `package.json` 内容;两个符合条件的文本都经过脱敏;匿名标识符与 git 元数据无关。
- 交互测试通过注入交互覆盖 create 和 config 决策,并断言已提交的工程文件;真实 PTY 覆盖只作为窄范围冒烟层。
- Agent skill 说明公开的结构化输入与事件约定,不依赖包的私有导出。
## 风险
- 即使经过脱敏,完整的 `cordis.yml``package.json` 文本仍会向 endpoint 运营方暴露插件名、依赖名、URL、路径和配置值启发式脱敏也可能漏掉 secret。
- 没有遥测配置项时默认上报可能让开发者意外;发布前 CLI 必须让关闭方法易于发现。
-`ProjectEditSession` 挂载插件前,包管理器的 add 操作已经可能修改 `package.json`、lockfile 和安装文件;后续挂载失败会留下需要手工恢复的依赖改动。
- GitHub 依赖可能按包管理器策略执行 preparation 或 lifecycle script尚未解决的构建策略会带来供应链与可复现性风险。
- 注入提示词交互的测试无法证明真实终端中的 raw mode、signal 或重绘行为;可选冒烟层只应覆盖这些残余约定。
## 参考资料
- [Vercel Eve](https://github.com/vercel/eve) 与 [Vercel Labs Skills](https://github.com/vercel-labs/skills) 用于区分 headless 初始化命令与 skill 分发。
- [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec)、[pnpm add](https://pnpm.io/cli/add)和 [Yarn add](https://yarnpkg.com/cli/add)说明包管理器原生来源。
- [`DO_NOT_TRACK`](https://donottrack.sh/)定义环境级关闭约定。
- [Clack](https://github.com/bombshell-dev/clack) 和 [Vitest snapshots](https://vitest.dev/guide/snapshot) 说明注入提示词交互与生成文件断言。

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md
2026-07-19-make-jsonrpc-directional.md: 945d58a0c989dac2beeb129bfd545fe5674c63cd
2026-07-19-make-jsonrpc-directional.zh.md: 181aab8cd2f2473f0b2694df1dff072fd805e69d
2026-07-19-make-jsonrpc-directional.md: 87c134ec1f412b1e4df439a5b6434f83e52b531e
2026-07-19-make-jsonrpc-directional.zh.md: b728f2335f1605f80b0b224fa2c889591c635645

View File

@@ -20,11 +20,11 @@ Return the settled outcome directly from `session/prompt` as `{ status, reason }
## Implementation plan
1. In `packages/scaffold/server/src/server.ts`, replace `SessionPromptResult.accepted` with `status: 'ok' | 'error' | 'aborted'` and the captured `TurnEndReason`. `HarnessSdkServer.prompt()` will return `completed` as `ok`, `aborted` as `aborted`, and every other current or merge-extensible reason as `error`; reaching idle without a `turn/end` remains an invariant error. Remove only `session.finished`, leaving `session.event`, `subagent.started`, and `subagent.finished` unchanged.
2. In `packages/scaffold/protocol/src/transport.ts`, narrow the shared class to the directions with consumers — inbound requests/outbound responses (the server) and outbound requests/inbound responses plus inbound notifications (the TypeScript SDK client) — removing only server-originated `request()` use and client-originated notification dispatch, or split the class into a server-side and client-side transport. Request result, method-not-found, and handler-error responses retain their current behavior and remain ordered after notifications emitted by the awaited handler.
1. In `packages/sdk/server/src/server.ts`, replace `SessionPromptResult.accepted` with `status: 'ok' | 'error' | 'aborted'` and the captured `TurnEndReason`. `HarnessSdkServer.prompt()` will return `completed` as `ok`, `aborted` as `aborted`, and every other current or merge-extensible reason as `error`; reaching idle without a `turn/end` remains an invariant error. Remove only `session.finished`, leaving `session.event`, `subagent.started`, and `subagent.finished` unchanged.
2. In `packages/sdk/protocol/src/transport.ts`, narrow the shared class to the directions with consumers — inbound requests/outbound responses (the server) and outbound requests/inbound responses plus inbound notifications (the TypeScript SDK client) — removing only server-originated `request()` use and client-originated notification dispatch, or split the class into a server-side and client-side transport. Request result, method-not-found, and handler-error responses retain their current behavior and remain ordered after notifications emitted by the awaited handler.
3. In `python/sdk/src/deepseek_harness/client.py`, `models.py`, and `__init__.py`, remove `IncomingRequest`, `_requests`, `notify()`, `next_request()`, `respond()`, and `respond_error()`. Add a public validated `SessionPromptResponse` carrying status and reason, return it from `session_prompt()`, and keep an explicit reader guard that ignores unexpected server-request frames instead of allowing them to match a response waiter.
4. In `python/sdk/src/deepseek_harness/api.py`, build `TurnResult.status` and a new `TurnResult.reason` from `SessionPromptResponse`, then delete the `session.finished` branch and second completion loop. Keep the subscription open during the request and preserve `_request_raw()`'s final notification drain so the last `turn/end` event and any subagent notification written before the response are collected before `Session.run()` reconstructs the final assistant message.
5. Replace the symmetric transport-pair cases in `packages/scaffold/protocol/tests/transport.spec.ts` with per-direction coverage, and update `server.spec.ts`, `plugin-apply.spec.ts`, and `built-scope-carrier.e2e.ts` for direct outcomes, ordering, overlap, shutdown, and the narrowed fake; update the TypeScript SDK client (`packages/scaffold/client`) and its suites for response-based settlement. Update `python/sdk/tests/test_client.py` for response-based settlement, unexpected-request-frame handling, callback and concurrency behavior, and the removed public helpers. Update the JSON-RPC and bilingual Python SDK READMEs, export JSDoc and declarations, `scripts/smoke-python-runtime.py`, and the Python single-executable snapshot.
5. Replace the symmetric transport-pair cases in `packages/sdk/protocol/tests/transport.spec.ts` with per-direction coverage, and update `server.spec.ts`, `plugin-apply.spec.ts`, and `built-scope-carrier.e2e.ts` for direct outcomes, ordering, overlap, shutdown, and the narrowed fake; update the TypeScript SDK client (`packages/sdk/client`) and its suites for response-based settlement. Update `python/sdk/tests/test_client.py` for response-based settlement, unexpected-request-frame handling, callback and concurrency behavior, and the removed public helpers. Update the JSON-RPC and bilingual Python SDK READMEs, export JSDoc and declarations, `scripts/smoke-python-runtime.py`, and the Python single-executable snapshot.
## Alternatives considered

View File

@@ -20,11 +20,11 @@ JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协
## 实施计划
1.`packages/scaffold/server/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted``HarnessSdkServer.prompt()``completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前已有或可通过声明合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event``subagent.started``subagent.finished` 不变。
2.`packages/scaffold/protocol/src/transport.ts` 中,把共享类收窄到有消费者的方向——入站请求/出站响应(服务端)与出站请求/入站响应及入站通知TypeScript SDK 客户端)——只删除服务端发起的 `request()` 用法与客户端发起的通知分发,或把该类拆分为服务端与客户端两个传输。请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。
1.`packages/sdk/server/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted``HarnessSdkServer.prompt()``completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前已有或可通过声明合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event``subagent.started``subagent.finished` 不变。
2.`packages/sdk/protocol/src/transport.ts` 中,把共享类收窄到有消费者的方向——入站请求/出站响应(服务端)与出站请求/入站响应及入站通知TypeScript SDK 客户端)——只删除服务端发起的 `request()` 用法与客户端发起的通知分发,或把该类拆分为服务端与客户端两个传输。请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。
3.`python/sdk/src/deepseek_harness/client.py``models.py``__init__.py` 中,删除 `IncomingRequest``_requests``notify()``next_request()``respond()``respond_error()`。新增公开且经过校验的 `SessionPromptResponse` 来携带状态与原因,由 `session_prompt()` 返回该对象,并保留明确的读取保护:忽略意外的服务端请求帧,避免它们命中响应等待器。
4.`python/sdk/src/deepseek_harness/api.py` 中,根据 `SessionPromptResponse` 构造 `TurnResult.status` 和新增的 `TurnResult.reason`,再删除 `session.finished` 分支与第二个完成循环。请求期间保持订阅打开,并保留 `_request_raw()` 最后的通知排空步骤,确保写在响应前的最后一条 `turn/end` 事件与任何 subagent 通知,都会在 `Session.run()` 重建最终助手消息之前被收集。
5. 用按方向的覆盖替换 `packages/scaffold/protocol/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts``plugin-apply.spec.ts``built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现;同步更新 TypeScript SDK 客户端(`packages/scaffold/client`)及其套件以采用基于响应的结束流程。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。
5. 用按方向的覆盖替换 `packages/sdk/protocol/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts``plugin-apply.spec.ts``built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现;同步更新 TypeScript SDK 客户端(`packages/sdk/client`)及其套件以采用基于响应的结束流程。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。
## 备选方案

View File

@@ -40,7 +40,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
acp/ automation-only Agent Client Protocol server
interaction/ approval/interaction capabilities, permission, commands, ask-user
boot/ shared app-bin glue
scaffold/ project tooling: helper, launcher, initializer, SDK protocol
sdk/ JSON-RPC protocol, server, and TypeScript client
examples/ demo bundles (agent-spine + CLI/ACP/JSON-RPC bins)
support/ dev/test infrastructure
util/ zero-dependency utilities

View File

@@ -35,8 +35,6 @@ External packages that a workspace package resolves at runtime. The tier covers
| [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript) | SEE LICENSE IN README.md |
| [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT |
| [`@babel/code-frame`](https://github.com/babel/babel) | MIT |
| [`@clack/core`](https://github.com/bombshell-dev/clack) | MIT |
| [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT |
| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT |
| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT |
@@ -60,10 +58,8 @@ External packages that a workspace package resolves at runtime. The tier covers
| [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
| [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT |
| [`immer`](https://github.com/immerjs/immer) | MIT |
| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
| [`jsonc-parser`](https://github.com/microsoft/node-jsonc-parser) | MIT |
| [`katex`](https://github.com/KaTeX/KaTeX) | MIT |
| [`koffi`](https://github.com/Koromix/koffi) | MIT |
| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |

View File

@@ -2,5 +2,5 @@
# 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 apps/cli/reference/README.md
README.md: cef687dc392f97886ea18b162e8f668a44ce2284
README.zh.md: f6721ec256d404e2b602fb2ed921b41c03633a82
README.md: 46ea3c241d6775ce90a89c7be58901375a0634a3
README.zh.md: f020f46260d6b04b87a4918a671ca6bcbed251d9

View File

@@ -24,7 +24,7 @@ The shipped apps own these command lines:
| Profile | Arguments |
|---|---|
| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` |
| `web` | `--host`, `--port`, repeatable `--trusted-host` |
| `headless` | the task text, as the positional argument |
A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
@@ -52,7 +52,7 @@ Git-hosted plugins that ship sources build during install through their `prepare
## Web alias
`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, and repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities). The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
```sh
dsh web
@@ -63,9 +63,9 @@ dsh web --help
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain`SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup.
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Every profile boot watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a one-shot surface exits through its bounded shutdown, which disposes the watchers.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.

View File

@@ -24,7 +24,7 @@
| Profile | 参数 |
|---|---|
| `web` | `--host``--port``--dev`可重复的 `--trusted-host` |
| `web` | `--host``--port`、可重复的 `--trusted-host` |
| `headless` | 任务文本,作为位置参数 |
一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent智能体提交任务、等待完全停稳并对 Session 执行 flush再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
@@ -52,7 +52,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构
## Web 别名
`dsh web``--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host``--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority部署表达式会拼接自己的 authority`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR热模块替换接收器若要无刷新更新客户端 bundle还需单独运行 `pnpm run dev:web` watcher。
`dsh web``--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host``--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority部署表达式会拼接自己的 authority客户端插件 HMR热模块替换接收器始终挂载单独运行 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲
```sh
dsh web
@@ -63,9 +63,9 @@ dsh web --help
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空——`SIGTERM` 是监督进程的普通停止请求,在所有 surface 上以 0 退出,`SIGINT` 报告 130;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md``CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml`profile 与 home的有效编辑并以事务方式重新应用一次性运行只在启动时读取这些文件一次
所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md``CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。每次 profile 启动都监视两个 `cordis.patch.yml`profile 与 home的有效编辑并以事务方式重新应用一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。

View File

@@ -38,7 +38,6 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im
const USER_PRESET_DIR = '.agent-presets'
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
import { resolveWindowsShellLayer } from './windows-shell.ts'
@@ -60,9 +59,6 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
const TELEMETRY_ROW_ID = 'telemetry-otel'
/** The one-shot runner row: its presence means this composition exits by itself. */
const HEADLESS_ROW_ID = 'headless-runner'
/** The empty root entry list every profile tree patches over. */
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
@@ -193,9 +189,20 @@ export interface RunProfileOptions {
args: readonly string[]
}
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
if (!signal.aborted) throw error
/**
* Re-throw a watcher-setup failure unless a shutdown already owns the tree:
* a signal aborted this invocation, or an app requested exit (`ctx.appExit`
* from a fast one-shot) and the root's disposal rejected the in-flight setup
* await. Either way the failure describes a tree that is exiting as asked,
* not a broken watch.
* @param ctx - the booted root context.
* @param signal - this invocation's signal-shutdown fact.
* @param error - the setup failure.
*/
function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown): void {
if (signal.aborted) return
if (ctx.fiber.state !== FiberState.ACTIVE || ctx.get('loader') === undefined) return
throw error
}
/**
@@ -206,11 +213,6 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void
*/
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
const composed = composeProfile(options.profile, options.patchFiles)
// A one-shot composition ends by itself, which changes what a signal means
// and makes watching the user's patch layer pointless.
const headlessRow = composed.rows.get(HEADLESS_ROW_ID)
const oneShot = headlessRow !== undefined && headlessRow.disabled !== true
const app: { current?: Context } = {}
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
const signalShutdown = new AbortController()
@@ -220,7 +222,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
}
// Signals own teardown throughout the startup window, not only after boot()
// settles: an inserted provider can publish before sibling rows finish mounting.
process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) })
// SIGTERM is a supervisor's ordinary stop request and exits 0 on every
// surface — the launcher does not know whether the app considered its work
// complete; SIGINT is a user interrupt and reports 130.
process.on('SIGTERM', () => { interrupt(0) })
process.on('SIGINT', () => { interrupt(130) })
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
@@ -246,9 +251,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
...composed.overlays,
])
// One-shot runs exit through the runner; watching would only hold the
// process open after its exit request.
const watchProfilePatch = !oneShot
// Cloned for the same insert-aliasing reason as composeLive: the boot
// application must not mutate the objects later reloads recompose from.
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
@@ -262,22 +264,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
args: options.args,
exit: code => void shutdown.shutdown(code),
})
if (oneShot) {
const io: HeadlessIo = {
stdout: process.stdout,
stderr: process.stderr,
exit: (code) => { void shutdown.shutdown(code) },
}
hostCtx.provide('headlessIo', io)
}
})
app.current = ctx
// A surface can dispose the whole tree while boot or this post-boot watcher
// setup is still in flight. Loader presence and fiber state own
// liveness; the local signal fact distinguishes that expected exit race
// from a real HMR error.
if (watchProfilePatch
&& !signalShutdown.signal.aborted
// setup is still in flight — a signal, or a fast one-shot's appExit. Loader
// presence and fiber state own liveness; the initial check skips a tree
// that already exited, and the catch below re-checks for an exit that
// landed mid-setup. Watching is unconditional: a one-shot surface exits
// through its bounded shutdown, which disposes the watchers before the
// loop drains.
if (!signalShutdown.signal.aborted
&& ctx.fiber.state === FiberState.ACTIVE
&& ctx.get('loader') !== undefined) {
try {
@@ -305,7 +301,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
compose: composeLive,
})
} catch (error) {
suppressSignalShutdownError(signalShutdown.signal, error)
suppressShutdownError(ctx, signalShutdown.signal, error)
}
}
return { ctx, shutdown }

View File

@@ -79,6 +79,9 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
{ id: 'skill-badge', disabled: false },
{ id: 'modules', disabled: true },
{ id: 'connection', disabled: true },
// The always-on reload chain waits for the browser roster and bound port
// disabled above.
{ id: 'client-hmr', disabled: true },
// The shipped `-auto` chooser resolves its interaction from a running
// host and so waits for the webserver disabled above; the browse variant
// supplies `directoryPicker` without one.

View File

@@ -0,0 +1,123 @@
// Web e2e: /goal opts its command input into the human transcript while the
// command remains log-only. The shipped composition runs with no model adapter,
// so an accidental turn fails loud in addition to the event-level assertions.
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-commands/types'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria,
compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-command-presentation', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL(
'./snapshots/goal-command-presentation/ui.expected.md', import.meta.url,
))
const MODE = webSnapshotMode()
describe('web e2e: /goal human transcript presentation', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const events: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold()
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('shows the bare input and result from a fresh session without a model turn', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation'))
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), {
timeout: 15_000,
}).toBe(1)
const input = page.locator('textarea').first()
await input.fill('/goal')
await input.press('Enter')
await expect.poll(() => input.inputValue()).toBe('/goal ')
await input.press('Enter')
const commandInput = page.locator('[data-command-input]')
await commandInput.waitFor({ timeout: 10_000 })
await expect.poll(() => commandInput.textContent()).toBe('/goal')
expect(await commandInput.getAttribute('role')).toBe('group')
expect(await commandInput.getAttribute('aria-label')).toBe('Command input')
expect(await commandInput.getByRole('button').count()).toBe(0)
const typography = await commandInput.evaluate((element) => {
const bubble = element.firstElementChild?.firstElementChild
if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing')
const rootStyle = getComputedStyle(element)
const bubbleStyle = getComputedStyle(bubble)
return {
fontFamily: bubbleStyle.fontFamily,
parentFontFamily: rootStyle.fontFamily,
fontSize: bubbleStyle.fontSize,
lineHeight: bubbleStyle.lineHeight,
}
})
expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' })
expect(typography.fontFamily).not.toBe(typography.parentFontFamily)
const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1)
await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
expect(await page.getByText('Into the Unknown', { exact: false }).count()).toBe(0)
const run = events.find(event => event.type === 'command/run')
expect(run).toMatchObject({
type: 'command/run',
data: { name: 'goal', args: ' ', source: { kind: 'user' } },
})
expect(events.some(event => event.type === 'command/done')).toBe(true)
expect(events.some(event => event.type === 'user/message')).toBe(false)
expect(events.some(event => event.type === 'turn/start')).toBe(false)
expect(events.some(event => event.type === 'step/start')).toBe(false)
expect(events.some(event => event.type === 'request/header')).toBe(false)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
}, 60_000)
it('reloads the same bubble and result from the persisted command lifecycle', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation-reload'))
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.locator('[data-command-input]').textContent(), { timeout: 15_000 }).toBe('/goal')
const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
const sessions = scaffold.ctx.sessions.list()
expect(sessions).toHaveLength(1)
const persisted = sessions[0]?.events ?? []
expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done')
.map(event => event.type)).toEqual(['command/run', 'command/done'])
expect(persisted.some(event => event.type === 'user/message')).toBe(false)
expect(persisted.some(event => event.type === 'turn/start')).toBe(false)
expect(persisted.some(event => event.type === 'step/start')).toBe(false)
expect(persisted.some(event => event.type === 'request/header')).toBe(false)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 90_000)
})

View File

@@ -1,4 +1,4 @@
/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -92,14 +92,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
host = subprocessCtx.subprocess.spawn(spawnSpec(
[process.execPath, binPath, 'web', '--dev', '--port', '0'],
[process.execPath, binPath, 'web', '--port', '0'],
world,
{
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
DSH_HOME: join(world, '.dsh'),
},
))
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web')
browser = await chromium.launch()
const page = await browser.newPage()
const pageErrors: string[] = []

View File

@@ -101,14 +101,14 @@ describe('web e2e: fresh round trip through the real assembly', () => {
callId: CallId('web-url-probe'),
name: 'bash',
arguments: {
command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
command: 'printf \'%s\\n\' "$DSH_WEB_URL"',
description: 'Print current Web runtime',
},
agent,
})
expect(result.isError).toBe(false)
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe(`${scaffold.baseUrl}\nproduction\n`)
.toBe(`${scaffold.baseUrl}\n`)
})
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {

View File

@@ -416,7 +416,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// (apps/web IS @deepseek-ai/dsh-frontend); only the URL line is silenced.
// Preserve the composed surface-context choice because a patch replaces
// the row's complete config.
{ id: 'web-runtime', config: { mode: 'production', printUrl: false, surfaceContext } },
{ id: 'web-runtime', config: { printUrl: false, surfaceContext } },
...options.remoteAuthority === undefined
? []
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],

View File

@@ -152,6 +152,67 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('uses the persisted dark preference while plugins are still loading', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-boot-theme'))
await page.emulateMedia({ colorScheme: 'light' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const initialDialog = page.getByRole('dialog', { name: '设置' })
const darkCube = initialDialog.getByRole('button', { name: '深色' })
await darkCube.click()
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-theme:\n\s+preference: dark/)
await page.keyboard.press('Escape')
// Hold real plugin bundles so the shell-owned loading page remains observable.
const pluginPattern = '**/plugins/**'
let releaseBundles = (): void => {}
const bundlesReleased = new Promise<void>((resolve) => { releaseBundles = resolve })
await page.route(pluginPattern, async (route) => {
await bundlesReleased
await route.continue()
})
const warningStart = tripwire.warnings.length
let reload: ReturnType<Page['reload']> | undefined
try {
reload = page.reload({ waitUntil: 'domcontentloaded' })
const loading = page.getByText('Loading plugins…', { exact: true })
await loading.waitFor({ timeout: 10_000 })
const state = await loading.evaluate((element) => {
const boot = element.parentElement?.parentElement
if (boot === undefined || boot === null) throw new Error('loading hint is detached from the boot page')
return {
attr: document.body.hasAttribute('data-ds-dark-theme'),
background: getComputedStyle(boot).backgroundColor,
colorScheme: document.documentElement.style.colorScheme,
}
})
expect(state).toEqual({
attr: true,
background: 'rgb(21, 21, 23)',
colorScheme: 'dark',
})
} finally {
releaseBundles()
await reload
await page.unroute(pluginPattern)
}
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '设置', exact: true }).click()
const restoredDialog = page.getByRole('dialog', { name: '设置' })
const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' })
await systemCube.click()
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), {
timeout: 5_000,
}).toBe(false)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
interface ThemeState {

View File

@@ -27,7 +27,7 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => {
@@ -187,7 +187,7 @@ describe('dsh web keyless CLI smoke', () => {
}
})
it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
it('routes web runtime context and workspace instructions through the real CLI request', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
mkdirSync(join(workspace, '.git'))
@@ -226,7 +226,7 @@ describe('dsh web keyless CLI smoke', () => {
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
const child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
{
cwd: workspace,
env: {
@@ -261,7 +261,7 @@ describe('dsh web keyless CLI smoke', () => {
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system')
const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd()
.replace('{{webUrl}}', baseUrl)
expect(systemMessage?.content).toContain(expectedWebSection)
expect(workspaceMessage).toMatchInlineSnapshot(`

Some files were not shown because too many files have changed in this diff Show More