diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index a626e1bcfa..f3d763058b 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -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-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 4909f6433f0a528c9e16f842afec11be382b1d44 -2026-08-03-per-session-agent-presets.zh.md: f6a751bf75806f59f168fd9beec0d4b4dc187f3a +2026-08-03-per-session-agent-presets.md: 6f1643c25008c3363cb10adb7fbff7afeea31cbe +2026-08-03-per-session-agent-presets.zh.md: 7afe9ade5c98fadb96384a7e0acd47531c370e0c diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 4909f6433f..6f1643c250 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -53,7 +53,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Switching is allowed only while a session is blank.** Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails. -**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. All four are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. `list` deliberately stays ordinary — ids and trust only, and a LAN client's picker needs it. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. +**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. **A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index f6a751bf75..7afe9ade5c 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -54,7 +54,7 @@ Status: implemented **只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。 -**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这四者都被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力,而选择它可以把会话切到一个能编辑活动运行时的 preset 上。`list` 刻意保持为普通方法——只有 id 与信任级别,而局域网客户端的选择器需要它。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 +**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 **在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与后端属于宿主平面;preset 贡献的是委派**工具**,它们解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..d2ac19b830 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,19 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dsh-web", + "runtimeExecutable": "env", + "runtimeArgs": [ + "DSH_HOME=/Users/jyc/projects/agentProjects/deepseek-harness/.claude/worktrees/tool-call-side-effects-fix-680343/tmp/dsh_home", + "DSH_TELEMETRY_DISABLED=1", + "node", + "apps/cli/lib/bin.js", + "web", + "--port", + "3080" + ], + "port": 3080 + } + ] +} diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index b2101317a7..ab4feb7d21 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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 packages/client/connection/README.md -README.md: 0392d51a929e2b92d9cd4b4728549250437631f1 -README.zh.md: e82b25bf30717abbcd9ec460b5790277d3f9ce13 +README.md: 974c04f2076f784e4c39c35fc779b7e36414f90c +README.zh.md: d20d196e11adad727fce6209d3b94594233ec3d7 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 0392d51a92..974c04f207 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.select`/`read`/`write`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime; `agentPreset.list` stays out, carrying only ids and trust) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`write`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability; `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index e82b25bf30..d20d196e11 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.select`/`read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力,而选择它可以把会话切到一个能编辑活动运行时的 preset 上;`agentPreset.list` 不在其中,它只携带 id 与信任级别)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力;`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index af44fc2cf1..95f683df7d 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -57,12 +57,16 @@ export const Config: z = z.object({ const PRIVILEGED_METHODS = new Set([ // A preset composition names the plugins a session runs, so reading one is // reconnaissance and writing one is arbitrary capability — strictly more than - // the settings document beside it. `agentPreset.select` joins them because - // it can move a session from a two-tool preset onto one that edits the live - // runtime, which is a real escalation even though every candidate is already - // installed. `agentPreset.list` deliberately stays out: it carries ids and - // trust only, like the model catalog, and a LAN client's picker needs it. - 'agentPreset.select', + // the settings document beside it. + // + // CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a + // preset looks like escalation — one of them mounts the toolset that edits the + // live runtime — but `session.create` already takes an `agentPreset`, so + // pinning only the switch would leave the same capability one method over. + // The deeper reason is that the capability is not the preset's to grant: the + // deployment's own default already carries `bash` and the filesystem tools, so + // any caller that may start a session at all can already run commands as this + // process. Pinning the switch would be a fence beside an open gate. 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 4335e6e0d4..0bf37c9b92 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -139,9 +139,8 @@ describe('connection node half', () => { 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.discoverModels', // A composition names the plugins a session runs: reading one is - // reconnaissance, writing one is arbitrary capability, and selecting one - // can move a session onto a preset that edits the live runtime. - 'agentPreset.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', + // reconnaissance and writing one is arbitrary capability. + 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', ]) { const denied = fakeResponse() await routes[0]!.handler( @@ -230,7 +229,7 @@ describe('connection node half over a real HTTP server', () => { // Carries a draft credential and turns the host into a fetcher for a // URL the caller picked: an anonymous LAN caller must not reach it. 'llm.discoverModels', - 'agentPreset.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', + 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', ]) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403]) } @@ -238,8 +237,11 @@ describe('connection node half over a real HTTP server', () => { // client's model picker needs it, and it carries no key or endpoint // state (404 is the empty proxy's carrier answer — the fence passed). // `agentPreset.list` joins the model catalog for the same reason: ids and - // trust only, and a LAN client's preset picker needs it. - for (const method of ['llm.providers', 'llm.models', 'agentPreset.list']) { + // trust only, and a LAN client's preset picker needs it. `select` is + // reachable too: `session.create` already takes an `agentPreset`, and the + // deployment's own default already carries bash, so pinning the switch + // would be a fence beside an open gate. + for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404]) } // Loopback reaches everything, configuration included. diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index d0e3b74e62..4844e36061 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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 packages/host/apiproxy/README.md -README.md: 65e60e78bf82eeafeae2f3d49be290e93507d0ef -README.zh.md: 91b707fe86d31b12a045d07c891cd33d16d7439d +README.md: a18a4e4c123d70b44e2104d746dd416bacac8210 +README.zh.md: 177af927aa1b0dc9c8ce49f4a0e8357cbb277760 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 65e60e78bf..a18a4e4c12 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,7 +36,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names) and whether it is the current default. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one. -`agentPreset.read`, `write`, and `remove` author the compositions themselves. `read` reports the text with its `trust` and whether it is `writable`; `write` and `remove` refuse a preset that ships with the deployment, and `write` refuses an id that is not a containable directory name or text that is not a Cordis entry list (`agent-preset-invalid`), a refusal that reaches the caller as `agent-preset-read-only` for the shipped case. Together with `select`, these four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` stays ordinary — it carries ids and trust, and every preset picker needs it — and reports `authorable`, whether the deployment configures a root a new preset could be written to at all. +`agentPreset.read`, `write`, and `remove` author the compositions themselves. `read` reports the text with its `trust` and whether it is `writable`; `write` and `remove` refuse a preset that ships with the deployment, and `write` refuses an id that is not a containable directory name or text that is not a Cordis entry list (`agent-preset-invalid`), a refusal that reaches the caller as `agent-preset-read-only` for the shipped case. These three are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports `authorable`, whether the deployment configures a root a new preset could be written to at all. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 91b707fe86..177af927aa 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,7 +36,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 -`agentPreset.read`、`write` 与 `remove` 负责创作组装本身。`read` 返回文本连同它的 `trust` 以及是否 `writable`;`write` 与 `remove` 拒绝随部署提供的 preset,`write` 还拒绝不构成可约束目录名的 id 或不是 Cordis entry 列表的文本(`agent-preset-invalid`),而随部署提供这一情形以 `agent-preset-read-only` 抵达调用方。连同 `select`,这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 保持为普通方法——它只携带 id 与信任级别,而每个 preset 选择器都需要它——并报告 `authorable`,即部署是否配置了可供写入新 preset 的根目录。 +`agentPreset.read`、`write` 与 `remove` 负责创作组装本身。`read` 返回文本连同它的 `trust` 以及是否 `writable`;`write` 与 `remove` 拒绝随部署提供的 preset,`write` 还拒绝不构成可约束目录名的 id 或不是 Cordis entry 列表的文本(`agent-preset-invalid`),而随部署提供这一情形以 `agent-preset-read-only` 抵达调用方。这三个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 还报告 `authorable`,即部署是否配置了可供写入新 preset 的根目录。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 2df8b66017..11ad62d49d 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -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 packages/preset/agent-presets/README.md -README.md: c61a84730a273052eeab31dc24696a7340621d4f -README.zh.md: 1caacec4f93990865e82c4498e64578eb149a3d6 +README.md: cc019e79673c8441feaeb0509e0f333fcce17252 +README.zh.md: 48950cc001811abd7cfcb2eac34e923f64886514 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index c61a84730a..cc019e7967 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -18,7 +18,7 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be written at all. - `ctx.agentPresets.read(id): Promise` One preset's composition text, exactly as stored. - `ctx.agentPresets.write(id, content): Promise` Create or replace a locally authored preset. -- `ctx.agentPresets.remove(id): Promise` Delete a locally authored preset. +- `ctx.agentPresets.remove(id): Promise` Delete a locally authored preset. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick. `AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file). diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 1caacec4f9..48950cc001 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -18,7 +18,7 @@ - `ctx.agentPresets.authorable: boolean` 是否存在 `user` 信任级别的根目录,也即是否可能写入 preset。 - `ctx.agentPresets.read(id): Promise` 某个 preset 的组装文本,与存储内容完全一致。 - `ctx.agentPresets.write(id, content): Promise` 创建或替换一个本地创作的 preset。 -- `ctx.agentPresets.remove(id): Promise` 删除一个本地创作的 preset。 +- `ctx.agentPresets.remove(id): Promise` 删除一个本地创作的 preset。若用户默认值正指向刚被删除的这一个,则清除它:存下一个尚不存在的默认值是有意为之,但本次调用删掉的那个再也不会有人提供,留着它会让每个未显式指名的会话都无法开启。 `AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。 diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index f48c25b7d3..9f1fe66938 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -13,7 +13,7 @@ import { Context, Service } from 'cordis' import { scopeOf } from '@deepseek-ai/dsh-scope' import z from 'schemastery' -import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings' +import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { deleteComposition, readComposition, writeComposition } from './authoring.ts' import type { PresetMetadata } from './metadata.ts' @@ -83,6 +83,12 @@ export class AgentPresets extends Service { */ private settings: SettingsScope | undefined + /** + * The settings service behind {@link settings}, held for the one write this + * service makes: clearing a user default it has just deleted. + */ + private settingsService: SettingsService | undefined + constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') // Deliberately not `installSettingsSection`: that helper exists to re-judge @@ -96,7 +102,11 @@ export class AgentPresets extends Service { AgentPresetSettingsSchema, { base: { default: config.default } }, ) - settingsCtx.effect(() => () => { this.settings = undefined }, 'agentPresets.settings()') + this.settingsService = settingsCtx.settings + settingsCtx.effect(() => () => { + this.settings = undefined + this.settingsService = undefined + }, 'agentPresets.settings()') }) } @@ -196,6 +206,17 @@ export class AgentPresets extends Service { */ async remove(id: string): Promise { await deleteComposition(this.config.roots, await this.resolve(id)) + // Storing a default that does not exist YET is deliberate — the roster is a + // live directory, so a name absent now may exist by the time a session asks + // for it, and `resolve` reports it then. A default this call just deleted is + // not that case: nothing will ever supply it again, and left in place every + // session created without an explicit pick would fail to start. Clearing it + // exposes the deployment's own default underneath, which is the layering. + if (this.settings?.get().default !== id) return + await this.settingsService?.mutate( + settingsNamespace(SETTINGS_NAMESPACE), + [{ op: 'unset', path: ['default'] }], + ) } /** diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index a586717c1d..081ffceba4 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -4,7 +4,7 @@ * so a person can change which preset new sessions get without a restart. */ -import { mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -20,7 +20,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SettingsLocal from '@deepseek-ai/dsh-settings-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { describe, expect, it } from 'vitest' -import AgentPresets, { SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }] @@ -30,7 +30,9 @@ const NS = settingsNamespace(SETTINGS_NAMESPACE) * A composition with a real file-backed settings provider. `settingsFiber` is * the provider's own handle, so a test can take it away the way a reload does. */ -async function harness(): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> { +async function harness( + extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [], +): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> { const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-')) const settingsFile = join(home, 'settings.yaml') await writeFile(settingsFile, '{}\n') @@ -47,7 +49,7 @@ async function harness(): Promise<{ ctx: Context; settingsFile: string; settings await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false }) await settingsFiber - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] }) return { ctx, settingsFile, settingsFiber } } @@ -114,6 +116,26 @@ describe('the default preset as a user setting', () => { expect(ctx.agentPresets.defaultId).toBe('standard') }) + it('clears a user default it has just deleted', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-')) + await mkdir(join(root, 'mine')) + await writeFile( + join(root, 'mine', COMPOSITION_FILE), + `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`, + ) + const { ctx } = await harness([{ path: root, trust: 'user' as const }]) + await ctx.settings.update(NS, { default: 'mine' }) + expect(ctx.agentPresets.defaultId).toBe('mine') + + await ctx.agentPresets.remove('mine') + + // Nothing will ever supply that id again, so leaving the setting pointed at + // it would fail every session created without an explicit pick. Clearing it + // exposes the deployment's own default underneath. + expect(ctx.agentPresets.defaultId).toBe('standard') + expect((await ctx.agentPresets.resolve()).id).toBe('standard') + }) + it('reports an unknown user default only when a session tries to use it', async () => { const { ctx } = await harness()