diff --git a/docs/subsystems/claude-code-mods.i18n.yaml b/docs/subsystems/claude-code-mods.i18n.yaml index d21af289b9..67dce12eae 100644 --- a/docs/subsystems/claude-code-mods.i18n.yaml +++ b/docs/subsystems/claude-code-mods.i18n.yaml @@ -15,8 +15,8 @@ en: 111c186536345342 zh: 8ddc81c78c05a840 /claude-code-mods-compatibility/members: - en: 1f1d39f01937b39c - zh: 6365f4d47ea0ba8f + en: 11c3d07e9deea30a + zh: 3a47a1114869d845 /claude-code-mods-compatibility/drawing: en: 1bd1a60970fcf426 zh: 8f6a8e069f742fb7 diff --git a/docs/subsystems/claude-code-mods.md b/docs/subsystems/claude-code-mods.md index ab4ae9c514..1d125affae 100644 --- a/docs/subsystems/claude-code-mods.md +++ b/docs/subsystems/claude-code-mods.md @@ -53,7 +53,7 @@ Every way a mod behaves differently through this bridge than under Claude Code, | `$.command` | `register`, `run`, `list` | Same; `run` of a command nobody answers rejects with the registering mod named | | `$.tool` | `register`, `call`, `list` | Same; a registered tool is `mcp____`; `call` runs the harness pipeline and injects a tool's deferred contexts into the session | | `$.prompt` | `submit`, `compose` | `submit`; the message is `user`-sourced, framed `Message from the "" mod:` unless `asUser`, and the `prompt.submit` it raises names the mod as origin | -| `$.session` | `id`, `cwd`, `root`, `repo`, `model`, `turns`, `messages`, `usage`, `version`, `send`, `append`, `authorize`, `compact`, `surfaces` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version`; `cwd` and `root` both report the session workspace; `usage.context.window` is `0` and `percent` absent until the token meter knows the route's window; `rateLimits` is empty; `version` names this bridge | +| `$.session` | `id`, `cwd`, `root`, `repo`, `model`, `turns`, `messages`, `usage`, `version`, `send`, `append`, `authorize`, `compact`, `surfaces` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version`; `cwd` reports the committed current directory; `root` retains the original project; `usage.context.window` is `0` and `percent` absent until the token meter knows the route's window; `rateLimits` is empty; `version` names this bridge | | `$.state` | Per session, survives hot reload | Per session, in memory; a read during `ui.render` subscribes the band to the slot | | `$.store` | Per plugin, durable | Same, in the `claude_code_mods` storage domain, 4 MiB per plugin | | `$.clock` | `now`, `sleep`, `after`, `every`; timers die with the plugin | Same; a timer also dies with the session whose event scheduled it, and `sleep` rejects when the event is cancelled | diff --git a/docs/subsystems/claude-code-mods.zh.md b/docs/subsystems/claude-code-mods.zh.md index 0c744b5085..696b6af1e7 100644 --- a/docs/subsystems/claude-code-mods.zh.md +++ b/docs/subsystems/claude-code-mods.zh.md @@ -53,7 +53,7 @@ | `$.command` | `register`、`run`、`list` | 相同;`run` 一个无人回答的命令时拒绝,并点名注册它的模组 | | `$.tool` | `register`、`call`、`list` | 相同;注册的工具为 `mcp____`;`call` 运行 harness 流水线,并把工具推迟的上下文注入会话 | | `$.prompt` | `submit`、`compose` | `submit`;消息为 `user` 来源,除非 `asUser` 否则套上 `Message from the "" mod:` 框架,它触发的 `prompt.submit` 以该模组为来源 | -| `$.session` | `id`、`cwd`、`root`、`repo`、`model`、`turns`、`messages`、`usage`、`version`、`send`、`append`、`authorize`、`compact`、`surfaces` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version`;`cwd` 与 `root` 都报告会话工作区;在 token 计量器得知路由窗口前 `usage.context.window` 为 `0` 且无 `percent`;`rateLimits` 为空;`version` 指明本桥接 | +| `$.session` | `id`、`cwd`、`root`、`repo`、`model`、`turns`、`messages`、`usage`、`version`、`send`、`append`、`authorize`、`compact`、`surfaces` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version`;`cwd` 报告已提交的当前目录;`root` 保留原始项目;在 token 计量器得知路由窗口前 `usage.context.window` 为 `0` 且无 `percent`;`rateLimits` 为空;`version` 指明本桥接 | | `$.state` | 按会话,热重载后保留 | 按会话,内存中;`ui.render` 期间的读取让横幅订阅该槽位 | | `$.store` | 按插件,持久 | 相同,在 `claude_code_mods` 存储域中,每插件 4 MiB | | `$.clock` | `now`、`sleep`、`after`、`every`;定时器随插件消亡 | 相同;定时器还随安排它的事件所属会话消亡,事件取消时 `sleep` 拒绝 | diff --git a/packages/deliverables/workspace-changes/README.i18n.yaml b/packages/deliverables/workspace-changes/README.i18n.yaml index e5e89ac185..fa5bf72888 100644 --- a/packages/deliverables/workspace-changes/README.i18n.yaml +++ b/packages/deliverables/workspace-changes/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-workspace-changes/use-this-package: - en: d6364de942d83439 - zh: b958129205655ba9 + en: 412b611ae784ca37 + zh: 29860e7a4dfd9b53 /deepseek-ai-dsh-workspace-changes/understand-the-implementation: - en: efb5cc9657ce290d - zh: b659836fb1fba36a + en: 80189ba9ccef8a92 + zh: 3d16718bb0b135ac /deepseek-ai-dsh-workspace-changes/further-exploration: en: 240df7d8b2e80059 zh: 214cebb4d18ca4d8 diff --git a/packages/deliverables/workspace-changes/README.md b/packages/deliverables/workspace-changes/README.md index 33a4ad05a3..097fe4bf1c 100644 --- a/packages/deliverables/workspace-changes/README.md +++ b/packages/deliverables/workspace-changes/README.md @@ -41,9 +41,9 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the | `maxFileBytes` | `2097152` | Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison; a larger file gets no comparison, and one captured around a file-tool edit is also listed without counts | | `diffTimeoutMs` | `100` | Milliseconds a line comparison may run before it degrades to whole-file replacement | -Every Session with a working directory and no subagent origin is recorded; subagent Sessions are not. Snapshots are written through a private index into a temporary object directory owned by the Session, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. Session disposal removes the directory. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. A working directory outside any git repository takes no snapshots. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — no repository is located either, and the plugin logs that once. Either way the summary lists the file-tool edits alone, as described next, with the working directory as the workspace; shell edits are absent. +This plugin requires the `subprocess` and `fs` services. Every Session with a working directory and no subagent origin is recorded; subagent Sessions are not. Snapshots are written through a private index into a temporary object directory owned by the Session, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. Session disposal removes the directory. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. A working directory outside any git repository takes no snapshots. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — no repository is located either, and the plugin logs that once. Either way the summary lists the file-tool edits alone, as described next, with the working directory as the workspace; shell edits are absent. -Before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the file at its path into the Session's temporary directory, once per path per turn, and copies it again at turn end; the copies are named by the SHA-1 of their bytes, so identical content is stored once. This needs no git. Paths the snapshots cover keep their git counts; the copies serve the other paths — files matching an ignore pattern, files outside the repository, and every file-tool edit when there is no snapshot — with counts from a line comparison of the two copies, so repeated edits to one file count once and a shell edit after a file-tool edit is included. A path whose content is unchanged at turn end is not listed. A copy larger than `maxFileBytes` is not stored: the file is listed with `oversized` and no counts, and a path whose both sides are that large is listed too, since unread content is never known to be unchanged. A path whose only difference is a missing final newline compares as unchanged, while git still counts that line. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. Changes made only through shell commands outside the snapshot coverage are not recorded. +After a `write`, `edit`, or mutating `str_replace_editor` call resolves its target and obtains its mutation guard, the recorder copies that exact file before the mutation into the Session's temporary directory, once per path per turn, and copies it again at turn end; the copies are named by the SHA-1 of their bytes, so identical content is stored once. This needs no git. Repository discovery and display paths retain the original project; file captures follow the operation's resolved target even after the Session changes directories. Paths the snapshots cover keep their git counts; the copies serve the other paths — files matching an ignore pattern, files outside the repository, and every file-tool edit when there is no snapshot — with counts from a line comparison of the two copies, so repeated edits to one file count once and a shell edit after a file-tool edit is included. A path whose content is unchanged at turn end is not listed. A copy larger than `maxFileBytes` is not stored: the file is listed with `oversized` and no counts, and a path whose both sides are that large is listed too, since unread content is never known to be unchanged. A path whose only difference is a missing final newline compares as unchanged, while git still counts that line. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the repository. Changes made only through shell commands outside the snapshot coverage are not recorded. Each file carries a durable `path` — relative to the working directory inside it, absolute elsewhere — and a `display` path used for ordering and labels: the relative path, a `../` path for repository files above the working directory, a `~` path under the home directory, otherwise the absolute path. Files sort by `display` in code-unit order, which lists parent and absolute paths before the working directory's own files. The `workspace/changes` event carries only the turn number; `ctx.workspaceChanges.summary(sessionId, seq)` returns the summary the event with that sequence announced, or undefined once the Session is disposed or when this Host process never recorded it. `ctx.workspaceChanges.diff(sessionId, seq, index, signal)` compares the listed file at that index: hunks with three context lines from the two snapshot trees or the two copies, `binary` for a side git reported as binary or that holds a NUL byte, `oversized` for a side larger than `maxFileBytes`. A line comparison that runs longer than `diffTimeoutMs` degrades to one hunk replacing every line, marked `coarse`. A conversation reopened after a Host restart therefore has no card, and no comparison, for its earlier turns. @@ -55,7 +55,7 @@ Each file carries a durable `path` — relative to the working directory inside
Implementation internals — click to expand -One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository once per Session and the Session's temporary object directory is created, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the temporary directory and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline; the same step queues the whole-file capture of the path a `write`, `edit`, or mutating `str_replace_editor` call names, so the copy precedes the edit, and `tool/result` events only mark that the turn has results to record. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for captured paths inside the work tree, a second copy and line comparison of each uncovered path, the appended event, and the summary kept under the event's sequence beside each listed file's two content sources, a path in a snapshot tree or a copy. A comparison is computed when asked for: `ls-tree -l` locates and sizes a snapshot side and `cat-file blob` reads it under `maxFileBytes`, copies are read from disk, and both sides go through the same line comparison with its timeout. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read. +One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository once per Session and the Session's temporary object directory is created, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the temporary directory and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline; the filesystem's `fs/write-intent` and `fs/edit-intent` waterfalls delegate the guard decision, then queue and await the capture of the resolved target before mutation, and `tool/result` events only mark that the turn has results to record. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for captured paths inside the work tree, a second copy and line comparison of each uncovered path, the appended event, and the summary kept under the event's sequence beside each listed file's two content sources, a path in a snapshot tree or a copy. A comparison is computed when asked for: `ls-tree -l` locates and sizes a snapshot side and `cat-file blob` reads it under `maxFileBytes`, copies are read from disk, and both sides go through the same line comparison with its timeout. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read. Git runs through the `subprocess` capability with a scrubbed environment, `GIT_CONFIG_COUNT=0` (ambient indexed configuration is excluded because the credential scrub removes its key entries), `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work, forget the summaries, and remove the temporary directory. diff --git a/packages/deliverables/workspace-changes/README.zh.md b/packages/deliverables/workspace-changes/README.zh.md index 75269a7eda..2bd807c6c8 100644 --- a/packages/deliverables/workspace-changes/README.zh.md +++ b/packages/deliverables/workspace-changes/README.zh.md @@ -41,9 +41,9 @@ kind: "package-reference" | `maxFileBytes` | `2097152` | 文件在文件工具编辑前后被捕获、或从快照读出做对比所允许的最大字节数;更大的文件不提供对比,其中在文件工具编辑前后被捕获的也不带行数 | | `diffTimeoutMs` | `100` | 逐行对比允许运行的毫秒数,超时后退化为整文件替换 | -有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。不在任何 git 仓库内的工作目录不做快照。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——同样定位不到仓库,插件记录一次日志。两种情况下摘要都只列下文所述的文件工具编辑,并以工作目录作为工作区;shell 的改动不会出现。 +本插件需要 `subprocess` 与 `fs` 服务。有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。不在任何 git 仓库内的工作目录不做快照。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——同样定位不到仓库,插件记录一次日志。两种情况下摘要都只列下文所述的文件工具编辑,并以工作目录作为工作区;shell 的改动不会出现。 -在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把该路径上的文件复制到 Session 的临时目录,每轮每个路径只复制第一次,轮次结束时再复制一次;副本按其字节的 SHA-1 命名,相同内容只存一份。这一步不需要 git。快照覆盖到的路径保留 git 的行数;其余路径由副本提供,也就是匹配忽略模式的文件、仓库之外的文件,以及没有快照时的每一次文件工具编辑,行数来自两份副本的逐行对比,因此同一文件的重复编辑只计一次,文件工具编辑之后的 shell 改动也包含在内。轮次结束时内容没有变化的路径不会列出。超过 `maxFileBytes` 的副本不会保存:该文件列出时带 `oversized`,没有行数;两侧都这么大的路径同样列出,因为没读过的内容永远不能认定为没有改动。只差一个末尾换行的路径对比为两侧相同,而 git 仍会把那一行计入行数。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。快照覆盖范围之外只通过 shell 命令做出的改动不会被记录。 +在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用解析目标并取得修改防护之后,记录器在实际修改前把该文件复制到 Session 的临时目录,每轮每个路径只复制第一次,轮次结束时再复制一次;副本按其字节的 SHA-1 命名,相同内容只存一份。这一步不需要 git。仓库定位与显示路径保留原始项目;即使 Session 切换目录,文件捕获仍跟随该操作已解析的目标。快照覆盖到的路径保留 git 的行数;其余路径由副本提供,也就是匹配忽略模式的文件、仓库之外的文件,以及没有快照时的每一次文件工具编辑,行数来自两份副本的逐行对比,因此同一文件的重复编辑只计一次,文件工具编辑之后的 shell 改动也包含在内。轮次结束时内容没有变化的路径不会列出。超过 `maxFileBytes` 的副本不会保存:该文件列出时带 `oversized`,没有行数;两侧都这么大的路径同样列出,因为没读过的内容永远不能认定为没有改动。只差一个末尾换行的路径对比为两侧相同,而 git 仍会把那一行计入行数。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。快照覆盖范围之外只通过 shell 命令做出的改动不会被记录。 每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。`workspace/changes` 事件只携带轮号;`ctx.workspaceChanges.summary(sessionId, seq)` 返回该序号的事件宣告的摘要,Session 已释放或本 Host 进程从未记录时返回 undefined。`ctx.workspaceChanges.diff(sessionId, seq, index, signal)` 对比该下标所列的文件:从两棵快照树或两份副本得出带三行上下文的 hunk;git 报告为二进制或某一侧含 NUL 字节时返回 `binary`;某一侧超过 `maxFileBytes` 时返回 `oversized`。逐行对比运行超过 `diffTimeoutMs` 时退化为一个替换全部行的 hunk,并标记 `coarse`。因此 Host 重启后重新打开的对话,先前轮次既没有卡片也没有对比。 @@ -55,7 +55,7 @@ kind: "package-reference"
实现细节——点击展开 -每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生;同一步骤还排入对 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用所指路径的整文件捕获,因此副本先于编辑,`tool/result` 事件只标记本轮有结果需要记录。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内已捕获路径的 `check-ignore`、对每个未覆盖路径的第二次复制与逐行对比、追加事件,以及按事件序号保存摘要和每个所列文件两侧的内容来源,即快照树中的路径或一份副本。对比在被请求时计算:`ls-tree -l` 定位快照一侧并取其大小,`cat-file blob` 在 `maxFileBytes` 之内读出,副本从磁盘读取,两侧随后走同一个带超时的逐行对比。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。 +每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生;文件系统的 `fs/write-intent` 与 `fs/edit-intent` waterfall 委托防护决策,然后排入并等待对已解析目标的捕获,确保副本先于修改,`tool/result` 事件只标记本轮有结果需要记录。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内已捕获路径的 `check-ignore`、对每个未覆盖路径的第二次复制与逐行对比、追加事件,以及按事件序号保存摘要和每个所列文件两侧的内容来源,即快照树中的路径或一份副本。对比在被请求时计算:`ls-tree -l` 定位快照一侧并取其大小,`cat-file blob` 在 `maxFileBytes` 之内读出,副本从磁盘读取,两侧随后走同一个带超时的逐行对比。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。 git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_CONFIG_COUNT=0`(凭据清理会移除索引配置的键,因此不继承这些环境配置)、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。 diff --git a/packages/deliverables/workspace-changes/package.json b/packages/deliverables/workspace-changes/package.json index d0765de211..5fc5f7ae86 100644 --- a/packages/deliverables/workspace-changes/package.json +++ b/packages/deliverables/workspace-changes/package.json @@ -40,7 +40,8 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -51,6 +52,13 @@ "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*" + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*" } } diff --git a/packages/deliverables/workspace-changes/src/index.ts b/packages/deliverables/workspace-changes/src/index.ts index c05787d931..7cff045be2 100644 --- a/packages/deliverables/workspace-changes/src/index.ts +++ b/packages/deliverables/workspace-changes/src/index.ts @@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-agent' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/dsh-tools' +import type { FsTarget } from '@deepseek-ai/dsh-fs' import { GitRunner } from './git.ts' import { TurnRecorder } from './recorder.ts' import type { WorkspaceChanges } from './types.ts' @@ -26,8 +27,8 @@ export type { /** Stable Loader identity. */ export const name = 'workspace-changes' -/** Services used to run git and observe turns. */ -export const inject = ['subprocess'] +/** Services used to run git, resolve mutation targets, and observe turns. */ +export const inject = ['subprocess', 'fs'] /** Snapshot, capture, and comparison bounds. Invalid values fail plugin load. */ export interface Config { @@ -88,7 +89,7 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise() + const mutations = new WeakMap() const byId = new Map() const forget = (session: Session): Promise => { const recorder = recorders.get(session) @@ -153,11 +155,27 @@ export function apply(ctx: Context, config: Config): void { ctx.on('agent/turn-stopping', async ({ agent, turn }) => { await recorders.get(agent.session)?.stopping(turn) }) + const capture = async (target: FsTarget, actor: object | undefined): Promise => { + const recorder = actor === undefined ? undefined : mutations.get(actor) + if (recorder === undefined) return + recorder.capture(ctx.fs.processPath(target)) + await recorder.settled() + } + ctx.on('fs/write-intent', async (target, actor, next) => { + const intent = await next() + await capture(target, actor) + return intent + }, { prepend: true }) + ctx.on('fs/edit-intent', async (target, actor, next) => { + const intent = await next() + await capture(target, actor) + return intent + }, { prepend: true }) ctx.on('tools/pre-execute', async (exec, next) => { const session = exec.agent?.session const recorder = session === undefined ? undefined : recorders.get(session) if (recorder !== undefined) { - recorder.capture(exec.name, exec.arguments) + mutations.set(exec, recorder) await recorder.settled() } return next() diff --git a/packages/deliverables/workspace-changes/src/recorder.ts b/packages/deliverables/workspace-changes/src/recorder.ts index 380341a85a..d0abb9e7ad 100644 --- a/packages/deliverables/workspace-changes/src/recorder.ts +++ b/packages/deliverables/workspace-changes/src/recorder.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' import { homedir } from 'node:os' import { join, relative, resolve } from 'node:path' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { captureFile, mutationPath, sameCapture, type Capture } from './capture.ts' +import { captureFile, sameCapture, type Capture } from './capture.ts' import { compareText } from './compare.ts' import { blobText, diffTrees, gitlinkPaths, ignoredPaths, locateGitWorkspace, snapshotTree, treeBlob, type GitRunner, type GitWorkspace, @@ -29,7 +29,7 @@ export interface RecorderEnvironment { /** Canonical paths every comparison and display uses, resolved once per Session. */ interface Paths { - /** Canonical working directory; git reports symlink-resolved paths, so every comparison uses that form. */ + /** Canonical original project directory for repository discovery and display paths. */ cwd: string /** Canonical home directory abbreviated as `~` in display paths. */ home: string @@ -150,17 +150,14 @@ export class TurnRecorder { * Queue the capture of the path a file tool is about to mutate, before the * tool runs; only the turn's first mutation of a path captures it. Await * {@link settled} afterwards so the tool cannot overtake the capture. - * @param name - wire tool name. - * @param args - parsed call arguments. + * @param path - absolute process path of the filesystem provider's resolved mutation target. */ - capture(name: string, args: unknown): void { - const path = mutationPath(name, args) - if (path === undefined) return + capture(path: string): void { const state = this.state void this.enqueue(async () => { const paths = this.paths if (paths === undefined) return - const absolute = await canonicalPath(resolve(paths.cwd, path)) + const absolute = await canonicalPath(path) if (state.captures.has(absolute)) return const capture = await captureFile(absolute, join(await this.scratchDir(), 'captures'), this.env.maxFileBytes) if (capture !== undefined) state.captures.set(absolute, capture) diff --git a/packages/deliverables/workspace-changes/tests/current-directory.spec.ts b/packages/deliverables/workspace-changes/tests/current-directory.spec.ts new file mode 100644 index 0000000000..57e2ee6ced --- /dev/null +++ b/packages/deliverables/workspace-changes/tests/current-directory.spec.ts @@ -0,0 +1,84 @@ +/** Real file-tool mutations and their before-copies after a Session directory change. */ +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import * as StrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as WorkspaceChanges from '../src/index.ts' + +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + cleanups.length = 0 +}) + +it.each(['write', 'edit', 'str_replace_editor'] as const)('captures the resolved %s target before mutation, without a repository', async (name) => { + const origin = await realpath(await mkdtemp(join(tmpdir(), 'dsh-workspace-directory-'))) + cleanups.push(() => rm(origin, { recursive: true, force: true })) + const current = join(origin, 'current') + await mkdir(current) + await writeFile(join(origin, 'witness.txt'), 'origin\n') + await writeFile(join(current, 'witness.txt'), 'before\n') + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + const harness = await mountAgentLoopTestHarness(ctx) + await ctx.plugin(LocalSubprocessRuntime) + await ctx.plugin(ToolFs) + await ctx.plugin(StrReplaceEditor) + await ctx.plugin(WorkspaceChanges) + const args = name === 'write' + ? { file_path: 'witness.txt', content: 'after\n' } + : name === 'edit' + ? { file_path: 'witness.txt', old_string: 'before', new_string: 'after' } + : { command: 'str_replace', path: join(current, 'witness.txt'), old_str: 'before', new_str: 'after' } + ctx.llm.registerAdapter(['mock'], new MockAdapter([toolCallResponse('mutation', name, args), textResponse('done')])) + const agent = await harness.create(SessionId('capture-current'), { provider: 'mock', model: 'mock' }, { cwd: origin }) + await ctx.workingDirectory.set(agent, current) + // A later directory change cannot alter the filesystem's already resolved mutation target. + const changeDirectory = async (next: () => T): Promise> => { + await ctx.workingDirectory.set(agent, origin) + return await next() + } + ctx.on('fs/write-intent', (_target, _actor, next) => changeDirectory(next)) + ctx.on('fs/edit-intent', (_target, _actor, next) => changeDirectory(next)) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'update witness' }], source: { kind: 'user' } })) + await agent.whenIdle() + + expect(await readFile(join(origin, 'witness.txt'), 'utf8')).toBe('origin\n') + expect(await readFile(join(current, 'witness.txt'), 'utf8')).toBe('after\n') + expect(ctx.workingDirectory.get(agent.session)).toBe(origin) + const events = agent.session.snapshotEvents() + const result = events.find(event => event.type === 'tool/result') + expect(result).toMatchObject({ data: { message: { isError: false } } }) + if (name !== 'str_replace_editor') expect(result).toMatchObject({ data: { meta: { path: join(current, 'witness.txt') } } }) + const announcement = events.findLast(event => event.type === 'workspace/changes') + expect(announcement).toBeDefined() + const summary = ctx.workspaceChanges.summary(agent.id, announcement!.seq) + expect(summary).toMatchObject({ cwd: origin, total: 1, files: [{ path: 'current/witness.txt', added: 1, deleted: 1 }] }) + expect(summary!.snapshot).toBeUndefined() + expect(await ctx.workspaceChanges.diff(agent.id, announcement!.seq, 0, new AbortController().signal)).toMatchObject({ kind: 'text', hunks: [{ lines: ['-before', '+after'] }] }) +}) + +it('delegates filesystem intents without a recorded tool actor and preserves a rejecting policy', async () => { + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + await ctx.plugin(LocalSubprocessRuntime) + await ctx.plugin(WorkspaceChanges) + const target = await ctx.fs.resolve('witness.txt') + const bare = vi.fn(() => undefined) + await ctx.waterfall('fs/write-intent', target, undefined, bare) + await ctx.waterfall('fs/edit-intent', target, {}, bare) + expect(bare).toHaveBeenCalledTimes(2) + ctx.on('fs/write-intent', () => { throw new Error('policy denied') }) + await expect(ctx.waterfall('fs/write-intent', target, undefined, bare)).rejects.toThrow('policy denied') + expect(bare).toHaveBeenCalledTimes(2) +}) diff --git a/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts b/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts index e75427e5a3..26c795c087 100644 --- a/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts +++ b/packages/deliverables/workspace-changes/tests/loader-composition.spec.ts @@ -13,6 +13,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as WorkspaceChangesPlugin from '@deepseek-ai/dsh-workspace-changes' import { changes, endTurn, git, startTurn, toolCall } from './support.ts' @@ -33,6 +34,7 @@ describe('real Loader composition', () => { await writeFile(join(root, 'cordis.yml'), [ "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-subprocess-local'", + "- name: '@deepseek-ai/dsh-fs-local'", "- name: '@deepseek-ai/dsh-workspace-changes'", '', ].join('\n')) @@ -43,6 +45,7 @@ describe('real Loader composition', () => { const modules = new Map([ ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], + ['@deepseek-ai/dsh-fs-local', LocalFileSystem], ['@deepseek-ai/dsh-workspace-changes', WorkspaceChangesPlugin], ]) context.loader.internal = { diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index b304c5a12a..d5f2da8638 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/packages/deliverables/workspace-changes/tests/plugin.spec.ts @@ -7,6 +7,7 @@ import { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as WorkspaceChanges from '../src/index.ts' import { changes, endTurn, git, mutate, scratchDir, settle, startTurn, toolCall } from './support.ts' @@ -24,6 +25,7 @@ async function boot(config: Partial = {}) { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(LocalSubprocessRuntime) + await ctx.plugin(LocalFileSystem) const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config) return { ctx, fiber } } @@ -322,6 +324,7 @@ describe('workspace-changes in a repository', () => { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(LocalSubprocessRuntime) + await ctx.plugin(LocalFileSystem) await expect(ctx.plugin(WorkspaceChanges, { maxFiles: 0 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer maxFiles') await expect(ctx.plugin(WorkspaceChanges, { diffTimeoutMs: 1.5 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer diffTimeoutMs') }) diff --git a/packages/deliverables/workspace-changes/tests/support.ts b/packages/deliverables/workspace-changes/tests/support.ts index c7087aa26d..8d23f6ba59 100644 --- a/packages/deliverables/workspace-changes/tests/support.ts +++ b/packages/deliverables/workspace-changes/tests/support.ts @@ -7,6 +7,8 @@ import type { Context } from '@deepseek-ai/cordis' import { ToolCallId, createAssistantMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-fs' +import { mutationPath } from '../src/capture.ts' import type { WorkspaceChangesSummary } from '../src/types.ts' let callNumber = 0 @@ -59,7 +61,14 @@ export async function mutate( ctx: Context, session: Session, turn: number, name: string, args: unknown, apply: () => Promise, result: { meta?: unknown; isError?: boolean } = {}, ) { - await ctx.waterfall('tools/pre-execute', { agent: { session }, name, arguments: args } as never, () => Promise.resolve(undefined as never)) + const actor = { agent: { session }, name, arguments: args } + await ctx.waterfall('tools/pre-execute', actor as never, () => Promise.resolve(undefined as never)) + const path = mutationPath(name, args) + if (path !== undefined) { + const target = await ctx.fs.resolve(path, session.header.cwd === undefined ? {} : { cwd: session.header.cwd }) + const event = name === 'edit' ? 'fs/edit-intent' : 'fs/write-intent' + await ctx.waterfall(event, target, actor, () => undefined) + } await apply() return toolCall(session, turn, name, args, result) } diff --git a/packages/deliverables/workspace-changes/tsconfig.json b/packages/deliverables/workspace-changes/tsconfig.json index b0d5795f1d..3daa220c27 100644 --- a/packages/deliverables/workspace-changes/tsconfig.json +++ b/packages/deliverables/workspace-changes/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../util/home-paths" + }, + { + "path": "../../fs/fs" } ] } diff --git a/packages/experimental/claude-code-mods/README.i18n.yaml b/packages/experimental/claude-code-mods/README.i18n.yaml index 3f8d8b0b0b..b74490c148 100644 --- a/packages/experimental/claude-code-mods/README.i18n.yaml +++ b/packages/experimental/claude-code-mods/README.i18n.yaml @@ -9,8 +9,8 @@ en: bf54b493565f1624 zh: 6f28a518f7b0dea8 /deepseek-ai-dsh-experimental-claude-code-mods/summary: - en: e4fbe86dd316b2af - zh: 6d056c3d9a6d50c3 + en: 4ddb71e280b6b328 + zh: e4775593fd7f232c /deepseek-ai-dsh-experimental-claude-code-mods/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,14 +18,14 @@ en: 95dcbcd789f3a4dd zh: 34aa7c9d225f6fe4 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-events-your-mod-receives: - en: 909e5c93c27ed388 - zh: 2377524dbb845ff1 + en: d2d735b07524c08a + zh: 6b90dc578d61ab22 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/the-band-above-the-prompt: en: 2b04be6b58e61a74 zh: f4854198d8999d11 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-members-your-mod-can-call: - en: 9ca1aff59b65305f - zh: c6043a8b7aadbdec + en: bf4f9f971aeb1d17 + zh: 83804163ca9039d5 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/test-a-mod: en: 5848fccf79f41e0a zh: 62bb20c4d7e4d8c2 @@ -39,8 +39,8 @@ en: 5330d8f5a6c9b992 zh: 8f6d167642a1c263 /deepseek-ai-dsh-experimental-claude-code-mods/understand-the-implementation/mapping-onto-the-harness: - en: fbbc266cc3657e17 - zh: be505d92315c0882 + en: 955ed36f58b0a5c4 + zh: 65b2f0ddc1154be4 /deepseek-ai-dsh-experimental-claude-code-mods/understand-the-implementation/source-map: en: a141066b146cef7c zh: fe91851f05c008c4 diff --git a/packages/experimental/claude-code-mods/README.md b/packages/experimental/claude-code-mods/README.md index bfc440eb73..d259150e9a 100644 --- a/packages/experimental/claude-code-mods/README.md +++ b/packages/experimental/claude-code-mods/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. Mounting costs nothing until a mod acts; each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. +Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. ## Table of Contents @@ -59,7 +59,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a | Event | Raised from | A hook can | |---|---|---| -| `session.start` | `agent/created` of a root agent, awaited before its first turn; cancelling the creation abandons a waiting hook | observe; register commands and tools | +| `session.start` | `agent/created` of a root agent, awaited before its first turn; `cwd` uses the validated directory, including persisted resume; cancelling creation abandons validation and a waiting hook | observe; register commands and tools | | `prompt.submit` | `agent/pre-step` with claimed messages; `e.text` joins the text blocks of the human's own (`user`-sourced) messages, and a rewrite touches only those | rewrite `text`, add `context` blocks after the prompt as typed, or `{ drop }` the prompt | | `turn.start` | the first `agent/pre-step` of a turn | observe | | `tool.call` | the `tools/execute` waterfall, after the harness permission decision; a call a mod raised with `$.tool.call` reaches only the mods loaded before it, attributed to the caller | observe before and after, `{ deny }`, answer with `{ result }`, or rewrite the result or its `isError` after `next` | @@ -85,12 +85,12 @@ The band is one instance per session, drawn by the first mod in load order whose | `$.command` | `register`, `run`, `list` | `ctx.commands`, scoped to the agent whose event is running | | `$.tool` | `register`, `call`, `list` | `ctx.tools`; a registered tool is named `mcp____`; contexts a tool defers to the next request are injected into the session | | `$.prompt` | `submit` | `agent.followup()` as a `user`-sourced message, framed as a message from the mod unless `asUser`; the `prompt.submit` it raises carries `origin: { kind: 'plugin', name }` | -| `$.session` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version` | the agent's Session and the `turnBoundary` and `contextPressure` projections; `cwd` and `root` both report the session workspace, the harness's one directory per session | +| `$.session` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version` | the agent's Session and the `turnBoundary` and `contextPressure` projections; `cwd` reports the committed current directory through `ctx.workingDirectory`; `root` keeps the original project | | `$.state` | `get`, `set` | memory held for the session, addressed by the `{ plugin, key }` a mod names; a read during `ui.render` subscribes the band | | `$.store` | `get`, `set`, `delete`, `keys` | the `claude_code_mods` storage domain, one JSON object per plugin, 4 MiB | | `$.clock` | `now`, `sleep`, `after`, `every` | timers owned by the session whose event scheduled them; `sleep` rejects when the event is cancelled | -| `$.fs` | `read`, `write`, `list`, `exists`, `stat` | `ctx.fs`, relative to the session workspace, 4 MiB per file | -| `$.process` | `run` | `ctx.subprocess`, argv without a shell | +| `$.fs` | `read`, `write`, `list`, `exists`, `stat` | `ctx.fs`, relative to the validated current Session directory, 4 MiB per file | +| `$.process` | `run` | `ctx.subprocess`, argv without a shell; new processes default to the validated current Session directory unless `init.cwd` is supplied | | `$.http` | `fetch` | the process's `fetch`, bodies up to 4 MiB | | `$.env` | `get`, `set` | this process's environment, shared by every session and plugin | @@ -119,7 +119,7 @@ A call whose service is not composed rejects with the missing service's package ### Mapping onto the harness -[`index.ts`](src/index.ts) registers the listeners. `tool.call` runs around `tools/execute`, so the harness permission decision precedes the chain; a `{ deny }` becomes an error result with the reason, a `{ result }` becomes a successful result when the tool is mod-registered or the value satisfies the tool's output schema, and an error-shaped result otherwise. A result a hook rewrote after `next` is installed as replacement content through `tools/post-execute`. A hook that passes rewritten arguments to `next` is skipped with a report, because the call's arguments are already logged. [`host-ops.ts`](src/host-ops.ts) holds the engine behavior for each `$` call over `ctx.get(...)` services, so a deployment composes only what its mods use. [`surfaces.ts`](src/surfaces.ts) keeps one band per session: it raises `ui.render`, validates and serializes the tree with [`elements.ts`](src/elements.ts), holds each `Button`'s `onPress` behind a per-drawing action id, subscribes the band to the `$.state` slots the drawing read, and streams generations to the Client through the `claudeCodeMods` Remote (`watchBand`, `pressBand`). +[`index.ts`](src/index.ts) registers the listeners. `tool.call` runs around `tools/execute`, so the harness permission decision precedes the chain; a `{ deny }` becomes an error result with the reason, a `{ result }` becomes a successful result when the tool is mod-registered or the value satisfies the tool's output schema, and an error-shaped result otherwise. A result a hook rewrote after `next` is installed as replacement content through `tools/post-execute`. A hook that passes rewritten arguments to `next` is skipped with a report, because the call's arguments are already logged. [`host-ops.ts`](src/host-ops.ts) holds the engine behavior for each `$` call over `ctx.get(...)` services; the directory owner is required and other services are read on demand. [`surfaces.ts`](src/surfaces.ts) keeps one band per session: it raises `ui.render`, validates and serializes the tree with [`elements.ts`](src/elements.ts), holds each `Button`'s `onPress` behind a per-drawing action id, subscribes the band to the `$.state` slots the drawing read, and streams generations to the Client through the `claudeCodeMods` Remote (`watchBand`, `pressBand`). ### Source map diff --git a/packages/experimental/claude-code-mods/README.zh.md b/packages/experimental/claude-code-mods/README.zh.md index ece8fe5fc7..d8f789461d 100644 --- a/packages/experimental/claude-code-mods/README.zh.md +++ b/packages/experimental/claude-code-mods/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。挂载本身没有开销,直到模组行动;每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 +在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 ## 目录 @@ -59,7 +59,7 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import | 事件 | 触发自 | 钩子可以 | |---|---|---| -| `session.start` | 根智能体的 `agent/created`,在其首轮之前等待完成;取消创建会放弃等待中的钩子 | 观察;注册命令与工具 | +| `session.start` | 根智能体的 `agent/created`,在其首轮之前等待完成;`cwd` 使用已验证的目录,包括从持久化恢复的目录;取消创建会放弃验证和等待中的钩子 | 观察;注册命令与工具 | | `prompt.submit` | 带有已认领消息的 `agent/pre-step`;`e.text` 拼接人类自己(`user` 来源)消息的文本块,改写也只触及这些块 | 改写 `text`、在输入的提示词之后追加 `context` 块,或 `{ drop }` 掉提示词 | | `turn.start` | 一轮中的首个 `agent/pre-step` | 观察 | | `tool.call` | `tools/execute` 瀑布流,在 harness 权限决定之后;模组用 `$.tool.call` 发起的调用只到达在它之前加载的模组,并归属于调用方 | 前后观察、`{ deny }`、以 `{ result }` 作答,或在 `next` 之后改写结果或其 `isError` | @@ -85,12 +85,12 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import | `$.command` | `register`、`run`、`list` | `ctx.commands`,限定在事件所属的智能体 | | `$.tool` | `register`、`call`、`list` | `ctx.tools`;注册的工具名为 `mcp____`;工具推迟到下一请求的上下文会注入会话 | | `$.prompt` | `submit` | `agent.followup()`,作为 `user` 来源的消息,除非 `asUser` 否则套上"来自模组"的框架;它触发的 `prompt.submit` 带 `origin: { kind: 'plugin', name }` | -| `$.session` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version` | 智能体的 Session 及 `turnBoundary`、`contextPressure` 投影;`cwd` 与 `root` 都报告会话工作区,即 harness 每会话一个目录 | +| `$.session` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version` | 智能体的 Session 及 `turnBoundary`、`contextPressure` 投影;`cwd` 通过 `ctx.workingDirectory` 报告已提交的当前目录;`root` 保留原始项目 | | `$.state` | `get`、`set` | 为会话持有的内存,以模组命名的 `{ plugin, key }` 寻址;`ui.render` 期间的读取会让横幅订阅 | | `$.store` | `get`、`set`、`delete`、`keys` | `claude_code_mods` 存储域,每插件一个 JSON 对象,4 MiB | | `$.clock` | `now`、`sleep`、`after`、`every` | 由安排它们的事件所属会话拥有的定时器;事件取消时 `sleep` 拒绝 | -| `$.fs` | `read`、`write`、`list`、`exists`、`stat` | `ctx.fs`,相对于会话工作区,每文件 4 MiB | -| `$.process` | `run` | `ctx.subprocess`,不经 shell 的 argv | +| `$.fs` | `read`、`write`、`list`、`exists`、`stat` | `ctx.fs`,相对于已验证的 Session 当前目录,每文件 4 MiB | +| `$.process` | `run` | `ctx.subprocess`,不经 shell 的 argv;未提供 `init.cwd` 时,新进程默认使用已验证的 Session 当前目录 | | `$.http` | `fetch` | 进程的 `fetch`,正文最多 4 MiB | | `$.env` | `get`、`set` | 本进程的环境变量,所有会话与插件共享 | @@ -119,7 +119,7 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import ### 映射到 harness -[`index.ts`](src/index.ts) 注册监听器。`tool.call` 包裹 `tools/execute`,因此 harness 的权限决定先于链;`{ deny }` 成为带原因的错误结果,`{ result }` 在工具为模组注册或值满足工具输出 schema 时成为成功结果,否则为错误形态的结果。钩子在 `next` 之后改写的结果经 `tools/post-execute` 作为替换内容安装。向 `next` 传入改写参数的钩子会被跳过并报告,因为调用参数已经写入日志。[`host-ops.ts`](src/host-ops.ts) 持有每个 `$` 调用基于 `ctx.get(...)` 服务的引擎行为,部署只需组合其模组用到的服务。[`surfaces.ts`](src/surfaces.ts) 为每个会话保留一条横幅:触发 `ui.render`,用 [`elements.ts`](src/elements.ts) 校验并序列化树,把每个 `Button` 的 `onPress` 保存在按绘制分配的动作 id 之后,让横幅订阅该次绘制读取的 `$.state` 槽位,并通过 `claudeCodeMods` Remote(`watchBand`、`pressBand`)把各代推流给 Client。 +[`index.ts`](src/index.ts) 注册监听器。`tool.call` 包裹 `tools/execute`,因此 harness 的权限决定先于链;`{ deny }` 成为带原因的错误结果,`{ result }` 在工具为模组注册或值满足工具输出 schema 时成为成功结果,否则为错误形态的结果。钩子在 `next` 之后改写的结果经 `tools/post-execute` 作为替换内容安装。向 `next` 传入改写参数的钩子会被跳过并报告,因为调用参数已经写入日志。[`host-ops.ts`](src/host-ops.ts) 持有每个 `$` 调用基于 `ctx.get(...)` 服务的引擎行为;目录所有者为必需服务,其他服务按需读取。[`surfaces.ts`](src/surfaces.ts) 为每个会话保留一条横幅:触发 `ui.render`,用 [`elements.ts`](src/elements.ts) 校验并序列化树,把每个 `Button` 的 `onPress` 保存在按绘制分配的动作 id 之后,让横幅订阅该次绘制读取的 `$.state` 槽位,并通过 `claudeCodeMods` Remote(`watchBand`、`pressBand`)把各代推流给 Client。 ### 源码地图 diff --git a/packages/experimental/claude-code-mods/package.json b/packages/experimental/claude-code-mods/package.json index 4be2f11901..21b9d18317 100644 --- a/packages/experimental/claude-code-mods/package.json +++ b/packages/experimental/claude-code-mods/package.json @@ -68,7 +68,8 @@ "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@deepseek-ai/dsh-user-questions": "workspace:*" + "@deepseek-ai/dsh-user-questions": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -94,6 +95,8 @@ "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@deepseek-ai/dsh-user-questions": "workspace:*" + "@deepseek-ai/dsh-user-questions": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*" } } diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts index 27245fcff4..3d6363b463 100644 --- a/packages/experimental/claude-code-mods/src/host-ops.ts +++ b/packages/experimental/claude-code-mods/src/host-ops.ts @@ -20,6 +20,7 @@ import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { scopeOf } from '@deepseek-ai/dsh-scope' import type { Session } from '@deepseek-ai/dsh-session' +import type { WorkingDirectoryService } from '@deepseek-ai/dsh-working-directory' // Type-only: merges the token-meter projection states into SessionProjectionStateMap. import type {} from '@deepseek-ai/dsh-token-meter' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' @@ -179,10 +180,20 @@ export function createHostOps(options: HostOpsOptions): OpTable { } async function target(path: string, agent: Agent | undefined, signal: AbortSignal): Promise { - const cwd = agent?.session.header.cwd + const cwd = await currentDirectory(agent, signal) return fs().resolve(path, { ...cwd === undefined ? {} : { cwd }, signal }) } + function directories(): WorkingDirectoryService { + const service = ctx.get('workingDirectory') + if (service === undefined) throw new Error('$.session.cwd and session-owned operations need dsh-working-directory, which this deployment did not compose') + return service + } + + function currentDirectory(agent: Agent | undefined, signal: AbortSignal): Promise { + return agent === undefined ? Promise.resolve(undefined) : directories().ensure(agent, signal) + } + function fs(): FileSystem { const service = ctx.get('fs') if (service === undefined) throw new Error('$.fs needs a filesystem service (dsh-fs), which this deployment did not compose') @@ -389,8 +400,10 @@ export function createHostOps(options: HostOpsOptions): OpTable { // ---- session facts ---- 'session.id': (_input, context) => requireAgent(context, 'session.id').session.id, - // The harness keeps one directory per session, so the running directory and the project root coincide. - 'session.cwd': (_input, context) => requireAgent(context, 'session.cwd').session.header.cwd ?? process.cwd(), + 'session.cwd': (_input, context) => { + const agent = requireAgent(context, 'session.cwd') + return directories().get(agent.session) + }, 'session.root': (_input, context) => requireAgent(context, 'session.root').session.header.cwd ?? process.cwd(), 'session.model': (_input, context) => { const agent = requireAgent(context, 'session.model') @@ -473,12 +486,13 @@ export function createHostOps(options: HostOpsOptions): OpTable { }, 'fs.stat': async (input, context) => { const path = requireString(record(input).path, '$.fs.stat path') - const cwd = context.binding.agent?.session.header.cwd + const cwd = await currentDirectory(context.binding.agent, context.signal) const info = await fs().lstat(path, cwd === undefined ? {} : { cwd }, context.signal) if (info === undefined) throw new Error(`$.fs.stat: ${path} does not exist`) if (info.type !== 'symlink') return { kind: kindOf(info.type), size: sizeOf(info), mtimeMs: 0, isLink: false } satisfies FsStat // A link reports what it points at, as Claude Code's `resolve: true` does; a dangling one is `other`. - const followed = await fs().stat(await target(path, context.binding.agent, context.signal), context.signal) + const resolved = await fs().resolve(path, { ...cwd === undefined ? {} : { cwd }, signal: context.signal }) + const followed = await fs().stat(resolved, context.signal) return { kind: kindOf(followed?.type ?? 'other'), size: sizeOf(followed), mtimeMs: 0, isLink: true } satisfies FsStat }, @@ -492,7 +506,7 @@ export function createHostOps(options: HostOpsOptions): OpTable { } const settings = record(init) const timeoutMs = typeof settings.timeoutMs === 'number' ? settings.timeoutMs : options.processTimeoutMs - const cwd = typeof settings.cwd === 'string' ? settings.cwd : context.binding.agent?.session.header.cwd ?? process.cwd() + const cwd = typeof settings.cwd === 'string' ? settings.cwd : await currentDirectory(context.binding.agent, context.signal) ?? process.cwd() const env = typeof settings.env === 'object' && settings.env !== null ? settings.env as Record : undefined const deadline = AbortSignal.any([context.signal, AbortSignal.timeout(timeoutMs)]) const handle = subprocess.spawn({ diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts index 12d7854482..1c97870b73 100644 --- a/packages/experimental/claude-code-mods/src/index.ts +++ b/packages/experimental/claude-code-mods/src/index.ts @@ -11,6 +11,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-working-directory' import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' @@ -207,9 +208,8 @@ export class ClaudeCodeMods extends TypertRemoteService { bandRows: z.number().default(10), }) - // Every harness service is read through `ctx.get` when a mod's call needs it, - // so a deployment composes only what its mods use. - static inject: string[] = [] + // Session initialization requires its directory owner; other services are read on demand. + static inject = ['workingDirectory'] private readonly engine: ModsEngine private readonly registrations = new Map void>>() @@ -304,14 +304,14 @@ export class ClaudeCodeMods extends TypertRemoteService { ctx.on('agent/created', async ({ agent, signal }) => { if (!isRoot(agent)) return startedRoots.add(agent.session.id) + // Cancelling creation also cancels directory validation and a waiting hook. + /* v8 ignore next -- the loop always supplies an initialization signal; the payload type keeps it optional */ + const abandon = signal === undefined ? detached.controller.signal : AbortSignal.any([signal, detached.controller.signal]) const input: SessionStartInput = { - cwd: agent.session.header.cwd ?? process.cwd(), + cwd: await ctx.workingDirectory.ensure(agent, abandon), surface: null, isInteractive: ctx.get('userQuestions') !== undefined, } - // Cancelling the agent's creation abandons a hook still waiting, such as one inside `$.ui.ask`. - /* v8 ignore next -- the loop always supplies an initialization signal; the payload type keeps it optional */ - const abandon = signal === undefined ? detached.controller.signal : AbortSignal.any([signal, detached.controller.signal]) await engine.raise( 'session.start', input, e => ({ cwd: e.cwd }), { binding: { agent }, signal: abandon }, ) diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts index 286f4a9a71..5111e575c3 100644 --- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts +++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts @@ -12,6 +12,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import CommandRuntime from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import WorkingDirectory from '@deepseek-ai/dsh-working-directory' import { createAssistantMessage, createUserMessage, ToolCallId as ToolCallIdOf } from '@deepseek-ai/dsh-llm' import type { LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm' import TokenMeter from '@deepseek-ai/dsh-token-meter' @@ -108,6 +109,8 @@ async function harness( await ctx.plugin(CommandRuntime) const workspace = scratch('dsh-cc-mods-ws-') await options.services?.(ctx, workspace) + if (ctx.get('fs') === undefined) await ctx.plugin(LocalFileSystem, { cwd: workspace }) + await ctx.plugin(WorkingDirectory, { defaultDirectory: workspace }) const mods = await ctx.plugin(ClaudeCodeMods, { ...options.config }) await mods.await() for (const entry of modsToLoad) { @@ -246,7 +249,7 @@ describe('ticket-mod: a mod-registered tool, prompt context, and a turn.complete const agent = await h.agent() await h.turn(agent, 'open a PR for this change') expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('Current branch: feature/mods') - const prompts = events(agent).filter(e => e.type === 'user/message') + const prompts = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'user') expect(prompts.map(e => e.type === 'user/message' && e.data.source)).toEqual([{ kind: 'user' }]) expect(prompts[0]?.type === 'user/message' && prompts[0].data.content).toEqual([ { type: 'text', text: 'open a PR for this change' }, @@ -533,7 +536,11 @@ describe('the mods API over harness services', () => { id: 'a1', cwd: h.workspace, root: h.workspace, model: 'mock', turns: 1, version: MODS_API_VERSION, usage: { startedAt: agent.session.header.createdAt, context: { window: 0 }, rateLimits: [] }, - messages: [{ role: 'user', text: 'hello', toolUses: [] }, { role: 'assistant', text: 'ok', toolUses: [] }], + messages: [ + { role: 'user', text: 'hello', toolUses: [] }, + { role: 'user', text: `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: ${JSON.stringify(h.workspace)}.`, toolUses: [] }, + { role: 'assistant', text: 'ok', toolUses: [] }, + ], }) expect(out.store).toEqual({ count: 7, keys: ['count'] }) expect(out.env).toBe('yes') @@ -944,7 +951,7 @@ describe('loading diagnostics and configuration', () => { }) it('is a Service plugin the Loader keeps by its default export', () => { - expect(ClaudeCodeMods.inject).toEqual([]) + expect(ClaudeCodeMods.inject).toEqual(['workingDirectory']) const loader = Object.create(Loader.prototype) as Loader const unwrapped: unknown = loader.unwrapExports({ default: ClaudeCodeMods }) expect(unwrapped).toBe(ClaudeCodeMods) diff --git a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts index 1c7a64c4f9..d4bc93702d 100644 --- a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts +++ b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' +import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import ClaudeCodeMods, { defineMod } from '../src/index.ts' const fibers: Fiber[] = [] @@ -30,6 +31,7 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) + provideWorkingDirectoryFixture(ctx) await ctx.plugin(ClaudeCodeMods, {}) const fiber = await ctx.plugin(plugin, { history: 3 }) await fiber.await() @@ -49,6 +51,7 @@ describe('defineMod', () => { }) const ctx = new Context() fibers.push(ctx.fiber) + provideWorkingDirectoryFixture(ctx) await ctx.plugin(ClaudeCodeMods, {}) const fiber = ctx.plugin(slow, {}) await vi.waitFor(() => { expect(release).toBeDefined() }) @@ -65,6 +68,7 @@ describe('defineMod', () => { const broken = defineMod({ name: 'broken', register: () => { throw new Error('no thanks') } }) const ctx = new Context() fibers.push(ctx.fiber) + provideWorkingDirectoryFixture(ctx) await ctx.plugin(ClaudeCodeMods, {}) const fiber = await ctx.plugin(bare, {}) await fiber.await() diff --git a/packages/experimental/claude-code-mods/tests/directory-resume.spec.ts b/packages/experimental/claude-code-mods/tests/directory-resume.spec.ts new file mode 100644 index 0000000000..2fe573c178 --- /dev/null +++ b/packages/experimental/claude-code-mods/tests/directory-resume.spec.ts @@ -0,0 +1,58 @@ +/** Session-start payloads use the directory restored by the real persistence owner. */ +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { SessionId } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import WorkingDirectory from '@deepseek-ai/dsh-working-directory' +import ClaudeCodeMods, { defineMod } from '../src/index.ts' + +it('reports the final committed cwd when a persisted Session resumes, retaining its original project', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-mods-resume-directory-'))) + const origin = join(root, 'project') + const current = join(origin, 'current') + await mkdir(current, { recursive: true }) + const observed: Array<{ cwd: string; root: string; payload: string }> = [] + const contexts: Context[] = [] + const mount = async (): Promise => { + const ctx = new Context() + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, { cwd: origin }) + await ctx.plugin(WorkingDirectory, { defaultDirectory: origin }) + await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'sessions'), compression: 'none' }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(ClaudeCodeMods) + await ctx.plugin(defineMod({ + name: 'directory-observer', + register(on) { + on('session.start', async ($, event, next) => { + observed.push({ cwd: await $.session.cwd(), root: await $.session.root(), payload: event.cwd }) + return next(event) + }) + }, + })) + return ctx + } + try { + const first = await mount() + const handle = await first.agents.create({ sessionId: SessionId('directory-resume'), meta: { cwd: origin } }) + await first.workingDirectory.set(handle.agent, current) + await handle.dispose() + await first.fiber.dispose() + const second = await mount() + const resumed = await second.agents.resume({ resumeSessionId: SessionId('directory-resume') }) + expect(observed).toEqual([{ cwd: origin, root: origin, payload: origin }, { cwd: current, root: origin, payload: current }]) + expect(resumed.agent.session.header.cwd).toBe(origin) + expect(second.workingDirectory.get(resumed.agent.session)).toBe(current) + await resumed.dispose() + } finally { + for (const ctx of contexts.reverse()) await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts index 28c8add8d1..7fa4f780d9 100644 --- a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts +++ b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts @@ -1,11 +1,12 @@ -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import CommandRuntime from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -139,6 +140,7 @@ describe('session facts from the agent', () => { expect(await call('session.model', {}, fakeAgent(ctx, { requestModel: 'logged', model: 'opt' }))).toBe('logged') expect(await call('session.model', {}, fakeAgent(ctx, { model: 'opt' }))).toBe('opt') expect(await call('session.model', {}, fakeAgent(ctx))).toBe('') + provideWorkingDirectoryFixture(ctx) expect(await call('session.cwd', {}, fakeAgent(ctx))).toBe(process.cwd()) expect(await call('session.cwd', {}, fakeAgent(ctx, { cwd: '/work' }))).toBe('/work') expect(await call('session.root', {}, fakeAgent(ctx))).toBe(process.cwd()) @@ -155,6 +157,7 @@ describe('files through the filesystem seam', () => { const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: root }) const { call } = setup(ctx) + provideWorkingDirectoryFixture(ctx) writeFileSync(join(root, 'small.txt'), 'small') writeFileSync(join(root, 'big.txt'), Buffer.alloc(FS_MAX_BYTES + 1, 97)) symlinkSync(join(root, 'small.txt'), join(root, 'link.txt')) @@ -187,6 +190,7 @@ describe('processes through the subprocess seam', () => { }) as { exitCode: number; stdout: string } expect(run.exitCode).toBe(0) expect(run.stdout.endsWith('|set')).toBe(true) + provideWorkingDirectoryFixture(ctx) const viaAgent = await call('process.run', { argv: ['node', '-e', 'process.stdout.write(process.cwd())'] }, fakeAgent(ctx, { cwd })) as { stdout: string } expect(viaAgent.stdout.length).toBeGreaterThan(0) const bare = await call('process.run', { argv: ['node', '-e', 'process.stdout.write(process.cwd())'] }) as { exitCode: number; stdout: string } @@ -270,3 +274,56 @@ describe('ui.log sinks', () => { expect(debug).toHaveBeenCalledWith('unit-mod: hidden') }) }) + + +describe('the committed directory owner', () => { + it('moves session cwd, relative files and new processes while retaining the project root', async () => { + const origin = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-mods-directory-'))) + dirs.push(origin) + const current = join(origin, 'current') + mkdirSync(current) + writeFileSync(join(origin, 'witness.txt'), 'origin') + writeFileSync(join(current, 'witness.txt'), 'current') + writeFileSync(join(current, 'only-current.txt'), 'selected') + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + const harness = await mountAgentLoopTestHarness(ctx) + await ctx.plugin(LocalSubprocessRuntime) + const { call } = setup(ctx) + const agent = await harness.create(SessionId('directory-owner'), {}, { cwd: origin }) + await ctx.workingDirectory.set(agent, current) + + expect(await call('session.cwd', {}, agent)).toBe(current) + expect(await call('session.root', {}, agent)).toBe(origin) + expect(await call('fs.read', { path: 'witness.txt' }, agent)).toBe('current') + expect(await call('fs.exists', { path: 'only-current.txt' }, agent)).toBe(true) + expect(await call('fs.stat', { path: 'witness.txt' }, agent)).toMatchObject({ kind: 'file', size: 7 }) + expect(await call('fs.list', { path: '.' }, agent)).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'only-current.txt' })])) + await call('fs.write', { path: 'created.txt', text: 'selected output' }, agent) + expect(await call('fs.exists', { path: join(origin, 'created.txt') }, agent)).toBe(false) + expect(await call('fs.read', { path: join(current, 'created.txt') }, agent)).toBe('selected output') + symlinkSync(join(current, 'witness.txt'), join(current, 'link.txt')) + const lstat = ctx.fs.lstat.bind(ctx.fs) + vi.spyOn(ctx.fs, 'lstat').mockImplementationOnce(async (path, options, signal) => { + const info = await lstat(path, options, signal) + await ctx.workingDirectory.set(agent, origin) + return info + }) + expect(await call('fs.stat', { path: 'link.txt' }, agent)).toMatchObject({ kind: 'file', size: 7, isLink: true }) + await ctx.workingDirectory.set(agent, current) + expect(await call('process.run', { argv: [process.execPath, '-e', 'process.stdout.write(process.cwd())'] }, agent)).toEqual({ exitCode: 0, stdout: current, stderr: '' }) + expect(await call('process.run', { argv: [process.execPath, '-e', 'process.stdout.write(process.cwd())'], init: { cwd: origin } }, agent)).toEqual({ exitCode: 0, stdout: origin, stderr: '' }) + expect(ctx.workingDirectory.get(agent.session)).toBe(current) + expect(agent.session.header.cwd).toBe(origin) + }) + + it('fails explicitly when a session-bound operation has no directory owner', async () => { + const { ctx, call } = setup() + await ctx.plugin(LocalFileSystem) + await ctx.plugin(LocalSubprocessRuntime) + const agent = fakeAgent(ctx) + for (const [op, input] of [['session.cwd', {}], ['fs.read', { path: 'x' }], ['fs.stat', { path: 'x' }], ['process.run', { argv: [process.execPath] }]] as const) { + await expect(call(op, input, agent)).rejects.toThrow(/dsh-working-directory/) + } + }) +}) diff --git a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts index fe90a866dd..5eae8a59d6 100644 --- a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts +++ b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts @@ -11,6 +11,8 @@ import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import CommandRuntime from '@deepseek-ai/dsh-commands' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import WorkingDirectory from '@deepseek-ai/dsh-working-directory' import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -39,6 +41,8 @@ it('loads from cordis.yml, counts the model\'s tool calls, and answers /tally th ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-fs-local', LocalFileSystem], + ['@deepseek-ai/dsh-working-directory', WorkingDirectory], ['@deepseek-ai/dsh-tools', ToolRuntime], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], diff --git a/packages/experimental/claude-code-mods/tsconfig.json b/packages/experimental/claude-code-mods/tsconfig.json index 8f0566d9f5..a4aad54eac 100644 --- a/packages/experimental/claude-code-mods/tsconfig.json +++ b/packages/experimental/claude-code-mods/tsconfig.json @@ -61,6 +61,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../session/working-directory" } ] } diff --git a/packages/hooks/hooks-claude-code/README.i18n.yaml b/packages/hooks/hooks-claude-code/README.i18n.yaml index 9f0924b661..136e11d2ab 100644 --- a/packages/hooks/hooks-claude-code/README.i18n.yaml +++ b/packages/hooks/hooks-claude-code/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-hooks-claude-code/use-this-package: - en: 526a6612c2cabbd7 - zh: f595b34dccd3359b + en: 7534e384c3ba85f2 + zh: dd28c3a11d096796 /deepseek-ai-dsh-hooks-claude-code/use-this-package/when-to-choose-it: en: 5fdf4ff17e6d9a2e zh: 82979c137758005d diff --git a/packages/hooks/hooks-claude-code/README.md b/packages/hooks/hooks-claude-code/README.md index 36a011cf55..97dd24d8d7 100644 --- a/packages/hooks/hooks-claude-code/README.md +++ b/packages/hooks/hooks-claude-code/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Hook processes and their payload `cwd` use the Session's current working directory when each hook point starts. `CLAUDE_PROJECT_DIR` follows it unless `projectDir` is configured. +Hook processes and their payload `cwd` use the Session's current working directory when each hook point starts. `CLAUDE_PROJECT_DIR` follows it unless `projectDir` is configured. `SubagentStop` reads the child's final committed directory after disposal; it cannot recover a missing directory or mutate that Session, and a failed stop hook is warned. Mount this package, point `configPath` at your hook config, and the hooks you already have start firing at the corresponding moments in agent runs. There is nothing else to set up before the first hook works. diff --git a/packages/hooks/hooks-claude-code/README.zh.md b/packages/hooks/hooks-claude-code/README.zh.md index b99ab588fe..829ddd92bf 100644 --- a/packages/hooks/hooks-claude-code/README.zh.md +++ b/packages/hooks/hooks-claude-code/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -每个钩子事件开始时,钩子进程及其载荷中的 `cwd` 均使用 Session 的当前工作目录。未配置 `projectDir` 时,`CLAUDE_PROJECT_DIR` 同样使用该目录。 +每个钩子事件开始时,钩子进程及其载荷中的 `cwd` 均使用 Session 的当前工作目录。未配置 `projectDir` 时,`CLAUDE_PROJECT_DIR` 同样使用该目录。`SubagentStop` 在子智能体释放后读取其最终已提交的目录;它不能恢复缺失目录或修改该 Session,停止钩子失败时会发出警告。 挂载本包并把 `configPath` 指向你的钩子配置,你已有的钩子就会在 agent 运行中的对应时刻开始触发。在第一个钩子生效之前无需其他设置。 diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 1b76bcbc88..f186c440cd 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*" } } diff --git a/packages/hooks/hooks-claude-code/src/index.ts b/packages/hooks/hooks-claude-code/src/index.ts index 161e32b8d4..afbc60de08 100644 --- a/packages/hooks/hooks-claude-code/src/index.ts +++ b/packages/hooks/hooks-claude-code/src/index.ts @@ -144,12 +144,14 @@ export function apply(ctx: Context, config: Config): void { point: string, matchQuery: string, payload: Record, - opts: { agent?: Agent; turn?: number; readonly signal: AbortSignal }, + opts: { agent?: Agent; turn?: number; directory?: 'committed'; readonly signal: AbortSignal }, ): Promise { const groups = selectHookGroups(parsed[point], matchQuery, 'claude-code') if (groups.length === 0) return mergeHookOutputs([]) const outputs: HookOutput[] = [] - const workdir = opts.agent === undefined ? undefined : await ctx.workingDirectory.ensure(opts.agent, opts.signal) + const workdir = opts.agent === undefined ? undefined + : opts.directory === 'committed' ? ctx.workingDirectory.get(opts.agent.session) + : await ctx.workingDirectory.ensure(opts.agent, opts.signal) // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session // workspace (the same dir the hook runs in). const projectDir = config.projectDir ?? workdir @@ -281,7 +283,7 @@ export function apply(ctx: Context, config: Config): void { }) // SubagentStart may inject child context; SubagentStop only observes. Both - // use the live child's workspace and the generic agent-type matcher subject. + // use the child's directory and the generic agent-type matcher subject. ctx.on('subagent/start', (info) => { const child = ctx.get('agents')?.get(info.id) if (child !== undefined) subagentChildren.set(info.runId, child) @@ -295,7 +297,9 @@ export function apply(ctx: Context, config: Config): void { ctx.on('subagent/end', (info) => { const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id) subagentChildren.delete(info.runId) - detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) + // The end edge follows child disposal; committed Session state remains readable. + detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child, directory: 'committed' } : {}, signal: detached.signal }) + .catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStop hook failed: ${String(error)}`) })) }) } diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index 65c432d802..b77aa85c96 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -2,7 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { provideWorkingDirectoryFixture, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -704,12 +704,14 @@ export function defineCoverageCases(group: CoverageGroup): void { it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { const serverDir = dir() const childDir = dir() - const marker = join(childDir, 'stopwhere') - const payload = join(childDir, 'stoppayload') + const currentDir = join(childDir, 'current') + mkdirSync(currentDir) + const marker = join(currentDir, 'stopwhere') + const payload = join(currentDir, 'stoppayload') hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] }) const ctx = new Context() - provideWorkingDirectoryFixture(ctx) - await mountAgentLoopTestDependencies(ctx) + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the child session cwd). await ctx.plugin(LocalSubprocessRuntime) @@ -724,6 +726,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // Start is the registry-backed capture edge; end deliberately follows // handle disposal, matching continuable Activation settlement. ctx.emit(subagentCarrier(ctx), 'subagent/start', identity) + await ctx.workingDirectory.set(childHandle.agent, currentDir) await childHandle.dispose() expect(ctx.agents.get(childHandle.agent.id)).toBeUndefined() ctx.emit(subagentCarrier(ctx), 'subagent/end', { ...identity, stopReason: 'completed' }) @@ -733,8 +736,19 @@ export function defineCoverageCases(group: CoverageGroup): void { const where = readFileSync(marker, 'utf8').trim() const input = JSON.parse(readFileSync(payload, 'utf8')) as { cwd: string; session_id: string } // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. - expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) - expect(input).toMatchObject({ cwd: childDir, session_id: childHandle.agent.id }) + expect(where.endsWith('/current')).toBe(true) + expect(input).toMatchObject({ cwd: ctx.workingDirectory.get(childHandle.agent.session), session_id: childHandle.agent.id }) + + const warn = vi.fn() + ctx.logger.warn = warn as never + const failed = await ctx.agents.create({ sessionId: SessionId('failed-stop-observation'), meta: { cwd: childDir } }) + const failedIdentity = { ...identity, id: failed.agent.id, runId: SubagentRunId('failed-stop') } + ctx.emit(subagentCarrier(ctx), 'subagent/start', failedIdentity) + await failed.dispose() + vi.spyOn(ctx.workingDirectory, 'get').mockImplementationOnce(() => { throw new Error('directory observation failed') }) + ctx.emit(subagentCarrier(ctx), 'subagent/end', { ...failedIdentity, stopReason: 'completed' }) + await waitFor(() => warn.mock.calls.some(call => String(call[0]).includes('SubagentStop hook failed'))) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('directory observation failed')) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41b714174f..07f9997649 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7256,6 +7256,18 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:* + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:* + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-fs': + specifier: workspace:* + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:* + version: link:../../fs/fs-local '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm @@ -7268,9 +7280,18 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:* + version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:* + version: link:../../fs/tool-str-replace-editor '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools + '@deepseek-ai/dsh-working-directory': + specifier: workspace:* + version: link:../../session/working-directory packages/document/office-to-pdf: dependencies: @@ -7785,6 +7806,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:* + version: link:../../session/session-persistence-jsonl '@deepseek-ai/dsh-session-projection': specifier: workspace:* version: link:../../session/session-projection @@ -7818,6 +7842,9 @@ importers: '@deepseek-ai/dsh-user-questions': specifier: workspace:* version: link:../../interaction/user-questions + '@deepseek-ai/dsh-working-directory': + specifier: workspace:* + version: link:../../session/working-directory packages/experimental/client-ui-agent-team: devDependencies: @@ -9314,6 +9341,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../../shell/bash-local + '@deepseek-ai/dsh-fs-local': + specifier: workspace:* + version: link:../../fs/fs-local '@deepseek-ai/dsh-hook-protocol': specifier: workspace:* version: link:../hook-protocol