diff --git a/.oxlintrc.json b/.oxlintrc.json index 65043f4b7b..f52fdb1e84 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -23,7 +23,7 @@ "packages/typert/generator/tests/fixtures/type-model/**", // tsgolint rejects this fixture's preserved project shapes before rules run. "website/.generated/**", "vendor/**", // Vendored source keeps upstream style and idioms. - "packages/experimental/claude-code-mods/examples/**", // Claude Code's published example mods and their tests keep the upstream style; see the headers. + "packages/experimental/claude-code-mods/examples/*/{hooks,tests,types}/**", // Claude Code's published example mods and their tests keep the upstream style; see the headers. "native/**", // The imported landlock-run subtree has its own gates; see native/README.md. "**/*.js", "**/*.mjs", diff --git a/apps/cli/config/examples/claude-code-mods.overlay.yml b/apps/cli/config/examples/claude-code-mods.overlay.yml deleted file mode 100644 index 5b72d6cd63..0000000000 --- a/apps/cli/config/examples/claude-code-mods.overlay.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Opt-in: the Claude Code mods bridge, its band above the prompt, and the three -# example mods from Anthropic's "Getting started with Claude Code mods" post. -# Source launch only: the example wrappers are TypeScript under the package's -# examples/ directory. Mods load in list order and the band above the prompt is -# one instance consulted in that order, so the guards that yield when idle come -# before Token Weather, which draws whenever it has a reading. -# -# pnpm dsh web --patch apps/cli/config/examples/claude-code-mods.overlay.yml -# pnpm dsh --profile headless --patch apps/cli/config/examples/claude-code-mods.overlay.yml "task" -- insert: - - id: claude-code-mods - name: '@deepseek-ai/dsh-experimental-claude-code-mods' - - id: claude-code-mod-blast-radius - name: '@deepseek-ai/dsh-experimental-claude-code-mods/examples/blast-radius/index.ts' - - id: claude-code-mod-replay-theater - name: '@deepseek-ai/dsh-experimental-claude-code-mods/examples/replay-theater/index.ts' - - id: claude-code-mod-token-weather - name: '@deepseek-ai/dsh-experimental-claude-code-mods/examples/token-weather/index.ts' - - id: client-ui-claude-code-mods - name: '@deepseek-ai/dsh-experimental-client-ui-claude-code-mods' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4b2368d6cf..e98a7c41ce 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -102,9 +102,7 @@ "@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/dsh-agent-preset": "workspace:*", "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*", - "@deepseek-ai/dsh-experimental-auto-review": "workspace:*", - "@deepseek-ai/dsh-experimental-claude-code-mods": "workspace:*", - "@deepseek-ai/dsh-experimental-client-ui-claude-code-mods": "workspace:*" + "@deepseek-ai/dsh-experimental-auto-review": "workspace:*" }, "devDependencies": { "@agentclientprotocol/sdk": "1.4.0", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 06925bbd68..cfd181b2e2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -946,7 +946,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) +- `source`: [`packages/experimental/claude-code-mods/src/index.ts:52`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog /** Plugin config: the limits mod hooks run under. */ @@ -959,6 +959,10 @@ export interface Config { processTimeoutMs?: number /** Claude Code tool name → harness tool name entries added to the built-in alias table. */ toolAliases?: Record + /** Columns the band above the prompt reports to `ui.render` as `bodyColumns` and `viewport.columns`. */ + bandColumns?: number + /** Rows the band reports as `maxRows`. */ + bandRows?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8eae6815d0..6bce1c020c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -948,7 +948,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) +- `source`: [`packages/experimental/claude-code-mods/src/index.ts:52`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog /** Plugin config: the limits mod hooks run under. */ @@ -961,6 +961,10 @@ export interface Config { processTimeoutMs?: number /** Claude Code tool name → harness tool name entries added to the built-in alias table. */ toolAliases?: Record + /** Columns the band above the prompt reports to `ui.render` as `bodyColumns` and `viewport.columns`. */ + bandColumns?: number + /** Rows the band reports as `maxRows`. */ + bandRows?: number } ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 3d207971c7..45e6681108 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1287,7 +1287,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_claude_code_mods --> pkg_api_gateway pkg_experimental_client_ui_claude_code_mods --> pkg_api_remotes pkg_experimental_client_ui_claude_code_mods --> pkg_api_session_controller pkg_experimental_client_ui_claude_code_mods --> pkg_client_locale @@ -1686,7 +1685,7 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | -| [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 4b60176304..cb80078509 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1289,7 +1289,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_claude_code_mods --> pkg_api_gateway pkg_experimental_client_ui_claude_code_mods --> pkg_api_remotes pkg_experimental_client_ui_claude_code_mods --> pkg_api_session_controller pkg_experimental_client_ui_claude_code_mods --> pkg_client_locale @@ -1688,7 +1687,7 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | -| [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/subsystems/claude-code-mods.i18n.yaml b/docs/subsystems/claude-code-mods.i18n.yaml index 287c780748..d21af289b9 100644 --- a/docs/subsystems/claude-code-mods.i18n.yaml +++ b/docs/subsystems/claude-code-mods.i18n.yaml @@ -6,20 +6,20 @@ en: e520df3bf44e1160 zh: 557956eaf6faf4a7 /claude-code-mods-compatibility/packaging-and-loading: - en: 2c36fb0f324f8a6d - zh: 69723bf6e3a953e5 + en: 75cf021ce8babaab + zh: caa20540f8bce11a /claude-code-mods-compatibility/events: - en: 4932edf66891e29b - zh: 0a08ca2fcc233cd2 + en: cea776ec0063c311 + zh: 6b8c8b32f2f7ede9 /claude-code-mods-compatibility/the-chain: en: 111c186536345342 zh: 8ddc81c78c05a840 /claude-code-mods-compatibility/members: - en: 7b4984a5bdf5029b - zh: 114b91a050cc8b85 + en: 1f1d39f01937b39c + zh: 6365f4d47ea0ba8f /claude-code-mods-compatibility/drawing: - en: ad63be0ce30bb705 - zh: 15811dc20f5f9ce8 + en: 1bd1a60970fcf426 + zh: 8f6a8e069f742fb7 /claude-code-mods-compatibility/testing: en: 8b3530b79e09a067 zh: c041e63f7fd8de5a diff --git a/docs/subsystems/claude-code-mods.md b/docs/subsystems/claude-code-mods.md index ce98d183c4..ab4ae9c514 100644 --- a/docs/subsystems/claude-code-mods.md +++ b/docs/subsystems/claude-code-mods.md @@ -13,7 +13,7 @@ Every way a mod behaves differently through this bridge than under Claude Code, | Tiers (`user`, `project`, `managed`) and `next.to(e, tier)` | Every mod loads as `user`; `tier()` in a test is accepted and ignored; `next.to` rejects | Managed settings are not a DSH concept | | Hot reload of the hooks module on edit; `claude plugin validate`, `claude plugin test`, types generation from `types/index.d.ts` | None of these commands; a remount re-runs `register` on the same evaluated module, so module-level variables keep their values | Follow-up only on demand; the test kit's `createModTestKit` replaces `claude plugin test` inside Vitest | | A hooks module may be TypeScript | A `.ts` hooks module loads only where the launcher transpiles (the source launch does; a built install does not) | DSH ships plain Node | -| Mods run in-process with `$` as their only access to the host | The hooks module runs in-process with Node's globals and no access rule | No sandbox is applied to mods | +| Mods run in-process with `$` as their only access to the host | The hooks module runs in-process with Node's globals, no access rule, and the process's full authority: `$.env` reads and writes the harness environment, `$.http.fetch` reaches any URL, `$.fs` and `$.tool.call` act as the session | No sandbox is applied to mods; mount only mods you would run as a plugin | | `import type { … } from 'claude-code'`, `declare module 'claude-code' { interface PluginState }` | The `claude-code` module name resolves only inside this repository's test setup, to the bridge's types; a mod outside it imports `@deepseek-ai/dsh-experimental-claude-code-mods` | The type names are the bridge's own | | `settings hooks` in `hooks.json` run beside mod hooks | Not run; mount `@deepseek-ai/dsh-hooks-claude-code` for them | Different bridge | @@ -23,9 +23,9 @@ Every way a mod behaves differently through this bridge than under Claude Code, |---|---|---| | `session.start` | Raised once per session, before the first prompt | Raised for a root agent from `agent/created`, awaited before its first turn; not raised for subagents | | `session.end` | Raised once per session | Raised for a root agent from `agent/disposed`; `$.state` is readable until the hooks settle | -| `prompt.submit` | `e.text` is the typed prompt; `context` entries become one block each after the prompt as typed | Same; `e.text` joins the text blocks of the human's own (`user`-sourced) messages in the claimed batch, so a prompt another plugin injected is not offered for rewrite | +| `prompt.submit` | `e.text` is the typed prompt; `context` entries become one block each after the prompt as typed; a prompt a plugin submitted carries `origin: { kind: 'plugin', name }` | Same; `e.text` joins the text blocks of the human's own (`user`-sourced) messages in the claimed batch, a batch of injected context alone raises nothing, and a prompt `$.prompt.submit` queued carries the submitting mod as its origin | | `turn.start`, `turn.complete` | Per turn; subagent turns carry `agentId` | Same; `turn.complete`'s `{ text }` reaches the host log, not a line under the answer; `durationMs` counts from `turn/start` | -| `tool.call` | Runs before Claude Code's permission check; `next({ ...e, command })` rewrites the arguments the tool runs with | Runs around `tools/execute`, after the harness permission decision; a hook that rewrites arguments is skipped with a report naming the [pre-tool input rewrite proposal](../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md), because the call's arguments are already logged. `{ deny }`, `{ result }`, observing, and rewriting the result or `isError` after `next` work as described | +| `tool.call` | Runs before Claude Code's permission check; `next({ ...e, command })` rewrites the arguments the tool runs with, and `next({ ...e, tool })` reroutes the call | Runs around `tools/execute`, after the harness permission decision; a hook (or `.catch` handler) that rewrites arguments or names another tool is skipped with a report — the argument case names the [pre-tool input rewrite proposal](../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md) — because the logged call runs as logged. `{ deny }`, `{ result }`, observing, and rewriting the result or `isError` after `next` work as described | | `command.run` | Runs for a command the mod registered | Same | | `ui.render` | Raised for every render site (`AbovePrompt`, `Pane`, `ToolUse`, `AssistantMessage`, `Spinner`, …) | Raised for `AbovePrompt` only, once per redraw of a session's band; `Pane` hooks run only through the test kit's `$.ui.mount({ component: 'Pane' })` | | `tool.check`, `tool.describe`, `tool.list`, `tool.register` | Shape the tool list and permissions | Not raised; `$.tool.register` and `$.tool.list` are served as `$` calls | @@ -52,7 +52,7 @@ Every way a mod behaves differently through this bridge than under Claude Code, | `$.ui` | `resolve`, `invalidate`, `open`, `close`, `panes`, `focus`, `log`, `toast`, `status`, `notice`, `ask`, `blit`, `copy`, `press`, `input`, `select` | `resolve`, `invalidate`, `open`, `close`, `panes`, `log`, `toast`, `status`, `ask`; `open` answers `{ isPlaced: false }` with a reason and redraws the band; `log`, `toast`, `status` reach the host log; the rest reject | | `$.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` | +| `$.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 | | `$.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 | @@ -71,8 +71,8 @@ Every way a mod behaves differently through this bridge than under Claude Code, | `Box` props: `flexDirection`, `padding*`, `gap`, `border*`, `width`, `height`, `overflow`, `scroll` | `flexDirection`, `padding`, `paddingX`, `paddingY`, `gap`, `border`, `borderColor`; the others serialize and the Web band ignores them | | `Text` props: `color`, `bold`, `dimColor`, `italic`, `underline`, `wrap`, `truncate` | `color` (the terminal palette names), `bold`, `dimColor`, `italic`, `underline`; the Web band wraps | | `Button` props: `label`, `hotkey`, `disabled`, `onPress`; a bare digit arms the hotkey | `label`, `hotkey` (shown as a hint), `disabled`, `onPress` (a click) | -| The band is one instance; `hasSurvey`, `isWorking`, `maxRows`, `bodyColumns` reflect the terminal | One instance per session, consulted in mod load order; `bodyColumns` and `viewport.columns` are `120`, `hasSurvey` is `false`, `isWorking` is `false`, `maxRows` is `10`; the Web band wraps instead of scrolling | -| The band redraws on the host's frame and when a `$.state` value a render read changes | Redraws when a `$.state` value the last drawing read is written, after `$.ui.open`, `$.ui.close`, `$.ui.invalidate`, after each `tool.call` chain and `turn.complete` settle, and after a press; a mod that changes module-level state at other times draws it at the next trigger | +| The band is one instance; `hasSurvey`, `isWorking`, `maxRows`, `bodyColumns` reflect the terminal | One instance per session, consulted in mod load order; `bodyColumns` and `viewport.columns` are the bridge's `bandColumns` (default `120`), `maxRows` its `bandRows` (default `10`), `hasSurvey` and `isWorking` are `false`; the Web band wraps instead of scrolling | +| The band redraws on the host's frame and when a `$.state` value a render read changes | Redraws after `session.start`, when a `$.state` value the last drawing read is written, after `$.ui.open`, `$.ui.close`, `$.ui.invalidate`, after each `tool.call` chain and `turn.complete` settle, and after a press; a mod that changes module-level state at other times draws it at the next trigger. A press runs `onPress` without a time limit | | `Pane` with `focus`, `scroll`, docked beside the transcript | No pane: `$.ui.open` answers `{ isPlaced: false }` so a mod falls back to the band; the test kit mounts `Pane` hooks directly | | Hotkeys, `TextInput`, focus, scrolling, `ui.press`, `ui.input`, `ui.select` | Not served; follow-up | @@ -130,13 +130,6 @@ The bridge service: loaded mods, their hooks, and the harness listeners that rai */ @Remote pressBand(agent: Agent, generation: number, actionId: string): Promise -/** - * The current drawing of one session's band, drawing it first when nothing was drawn yet. - * @param sessionId - the session. - * @returns the band's snapshot. - */ -band(sessionId: string): Promise - /** * Load one mod beneath every mod loaded before it: run its `register`, * keep its hooks, and report which of its events this host never raises. diff --git a/docs/subsystems/claude-code-mods.zh.md b/docs/subsystems/claude-code-mods.zh.md index 6516f84bd8..0c744b5085 100644 --- a/docs/subsystems/claude-code-mods.zh.md +++ b/docs/subsystems/claude-code-mods.zh.md @@ -13,7 +13,7 @@ | 层级(`user`、`project`、`managed`)与 `next.to(e, tier)` | 每个模组都以 `user` 加载;测试中的 `tier()` 被接受并忽略;`next.to` 拒绝 | 托管设置不是 DSH 的概念 | | 编辑时热重载钩子模块;`claude plugin validate`、`claude plugin test`、由 `types/index.d.ts` 生成类型 | 这些命令都没有;重新挂载会在同一已求值模块上重新运行 `register`,模块级变量保留其值 | 仅按需跟进;测试工具包的 `createModTestKit` 在 Vitest 中替代 `claude plugin test` | | 钩子模块可以是 TypeScript | `.ts` 钩子模块只在启动器转译时可加载(源码启动可以;构建后的安装不行) | DSH 交付的是纯 Node | -| 模组在进程内运行,`$` 是其访问宿主的唯一途径 | 钩子模块以 Node 全局对象在进程内运行,没有访问规则 | 不对模组施加沙箱 | +| 模组在进程内运行,`$` 是其访问宿主的唯一途径 | 钩子模块以 Node 全局对象在进程内运行,没有访问规则,并拥有进程的全部权限:`$.env` 读写 harness 环境变量,`$.http.fetch` 可访问任意 URL,`$.fs` 与 `$.tool.call` 以会话的身份行动 | 不对模组施加沙箱;只挂载你愿意作为插件运行的模组 | | `import type { … } from 'claude-code'`、`declare module 'claude-code' { interface PluginState }` | `claude-code` 模块名只在本仓库的测试设置中解析到桥接的类型;仓库外的模组导入 `@deepseek-ai/dsh-experimental-claude-code-mods` | 类型名是桥接自己的 | | `hooks.json` 中的设置钩子与模组钩子并行运行 | 不运行;为它们挂载 `@deepseek-ai/dsh-hooks-claude-code` | 不同的桥接 | @@ -23,9 +23,9 @@ |---|---|---| | `session.start` | 每会话触发一次,在首个提示词之前 | 由根智能体的 `agent/created` 触发,在其首轮之前等待完成;不为子智能体触发 | | `session.end` | 每会话触发一次 | 由根智能体的 `agent/disposed` 触发;钩子结束前 `$.state` 可读 | -| `prompt.submit` | `e.text` 是输入的提示词;`context` 条目各成一块跟在输入的提示词之后 | 相同;`e.text` 拼接已认领批次中人类自己(`user` 来源)消息的文本块,因此其他插件注入的提示词不会被提供改写 | +| `prompt.submit` | `e.text` 是输入的提示词;`context` 条目各成一块跟在输入的提示词之后;插件提交的提示词带 `origin: { kind: 'plugin', name }` | 相同;`e.text` 拼接已认领批次中人类自己(`user` 来源)消息的文本块,仅含注入上下文的批次不触发,`$.prompt.submit` 排入的提示词以提交的模组为来源 | | `turn.start`、`turn.complete` | 每轮触发;子智能体的轮带 `agentId` | 相同;`turn.complete` 的 `{ text }` 到达宿主日志,而非答案下方的一行;`durationMs` 从 `turn/start` 起算 | -| `tool.call` | 在 Claude Code 的权限检查之前运行;`next({ ...e, command })` 改写工具实际运行的参数 | 包裹 `tools/execute` 运行,在 harness 权限决定之后;改写参数的钩子会被跳过,并在报告中指向[工具前输入改写提案](../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md),因为调用参数已写入日志。`{ deny }`、`{ result }`、观察以及在 `next` 之后改写结果或 `isError` 均按描述工作 | +| `tool.call` | 在 Claude Code 的权限检查之前运行;`next({ ...e, command })` 改写工具实际运行的参数,`next({ ...e, tool })` 把调用改派给另一工具 | 包裹 `tools/execute` 运行,在 harness 权限决定之后;改写参数或改名工具的钩子(或 `.catch` 处理器)会被跳过并报告——参数情形指向[工具前输入改写提案](../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md)——因为已写入日志的调用按原样运行。`{ deny }`、`{ result }`、观察以及在 `next` 之后改写结果或 `isError` 均按描述工作 | | `command.run` | 为模组注册的命令运行 | 相同 | | `ui.render` | 为每个渲染位点触发(`AbovePrompt`、`Pane`、`ToolUse`、`AssistantMessage`、`Spinner`……) | 只为 `AbovePrompt` 触发,每次会话横幅重绘一次;`Pane` 钩子只经测试工具包的 `$.ui.mount({ component: 'Pane' })` 运行 | | `tool.check`、`tool.describe`、`tool.list`、`tool.register` | 塑造工具列表与权限 | 不触发;`$.tool.register` 与 `$.tool.list` 作为 `$` 调用服务 | @@ -52,7 +52,7 @@ | `$.ui` | `resolve`、`invalidate`、`open`、`close`、`panes`、`focus`、`log`、`toast`、`status`、`notice`、`ask`、`blit`、`copy`、`press`、`input`、`select` | `resolve`、`invalidate`、`open`、`close`、`panes`、`log`、`toast`、`status`、`ask`;`open` 带原因回答 `{ isPlaced: false }` 并重绘横幅;`log`、`toast`、`status` 到达宿主日志;其余拒绝 | | `$.command` | `register`、`run`、`list` | 相同;`run` 一个无人回答的命令时拒绝,并点名注册它的模组 | | `$.tool` | `register`、`call`、`list` | 相同;注册的工具为 `mcp____`;`call` 运行 harness 流水线,并把工具推迟的上下文注入会话 | -| `$.prompt` | `submit`、`compose` | `submit`;消息为 `user` 来源,除非 `asUser` 否则套上 `Message from the "" mod:` 框架 | +| `$.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` 指明本桥接 | | `$.state` | 按会话,热重载后保留 | 按会话,内存中;`ui.render` 期间的读取让横幅订阅该槽位 | | `$.store` | 按插件,持久 | 相同,在 `claude_code_mods` 存储域中,每插件 4 MiB | @@ -71,8 +71,8 @@ | `Box` 属性:`flexDirection`、`padding*`、`gap`、`border*`、`width`、`height`、`overflow`、`scroll` | `flexDirection`、`padding`、`paddingX`、`paddingY`、`gap`、`border`、`borderColor`;其余会序列化,Web 横幅忽略 | | `Text` 属性:`color`、`bold`、`dimColor`、`italic`、`underline`、`wrap`、`truncate` | `color`(终端调色板名)、`bold`、`dimColor`、`italic`、`underline`;Web 横幅会换行 | | `Button` 属性:`label`、`hotkey`、`disabled`、`onPress`;单个数字键触发热键 | `label`、`hotkey`(显示为提示)、`disabled`、`onPress`(点击) | -| 横幅是一个实例;`hasSurvey`、`isWorking`、`maxRows`、`bodyColumns` 反映终端 | 每会话一个实例,按模组加载顺序咨询;`bodyColumns` 与 `viewport.columns` 为 `120`,`hasSurvey` 为 `false`,`isWorking` 为 `false`,`maxRows` 为 `10`;Web 横幅换行而非滚动 | -| 横幅随宿主帧重绘,并在渲染读取过的 `$.state` 值变化时重绘 | 在上次绘制读取过的 `$.state` 值被写入时、在 `$.ui.open`、`$.ui.close`、`$.ui.invalidate` 之后、在每条 `tool.call` 链与 `turn.complete` 结束后以及按下之后重绘;在其他时刻改变模块级状态的模组在下一次触发时绘出 | +| 横幅是一个实例;`hasSurvey`、`isWorking`、`maxRows`、`bodyColumns` 反映终端 | 每会话一个实例,按模组加载顺序咨询;`bodyColumns` 与 `viewport.columns` 取桥接的 `bandColumns`(默认 `120`),`maxRows` 取 `bandRows`(默认 `10`),`hasSurvey` 与 `isWorking` 为 `false`;Web 横幅换行而非滚动 | +| 横幅随宿主帧重绘,并在渲染读取过的 `$.state` 值变化时重绘 | 在 `session.start` 之后、上次绘制读取过的 `$.state` 值被写入时、在 `$.ui.open`、`$.ui.close`、`$.ui.invalidate` 之后、在每条 `tool.call` 链与 `turn.complete` 结束后以及按下之后重绘;在其他时刻改变模块级状态的模组在下一次触发时绘出。按下运行 `onPress` 时没有时间限制 | | 带 `focus`、`scroll`、停靠在对话旁的 `Pane` | 没有面板:`$.ui.open` 回答 `{ isPlaced: false }`,模组回退到横幅;测试工具包直接挂载 `Pane` 钩子 | | 热键、`TextInput`、焦点、滚动、`ui.press`、`ui.input`、`ui.select` | 不服务;后续工作 | @@ -130,13 +130,6 @@ The bridge service: loaded mods, their hooks, and the harness listeners that rai */ @Remote pressBand(agent: Agent, generation: number, actionId: string): Promise -/** - * The current drawing of one session's band, drawing it first when nothing was drawn yet. - * @param sessionId - the session. - * @returns the band's snapshot. - */ -band(sessionId: string): Promise - /** * Load one mod beneath every mod loaded before it: run its `register`, * keep its hooks, and report which of its events this host never raises. diff --git a/packages/experimental/claude-code-mods/README.i18n.yaml b/packages/experimental/claude-code-mods/README.i18n.yaml index c60c7e401b..3f8d8b0b0b 100644 --- a/packages/experimental/claude-code-mods/README.i18n.yaml +++ b/packages/experimental/claude-code-mods/README.i18n.yaml @@ -15,17 +15,17 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package: - en: 1c1d6e0677649b35 - zh: d0f7f9c0dcd90f46 + en: 95dcbcd789f3a4dd + zh: 34aa7c9d225f6fe4 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-events-your-mod-receives: en: 909e5c93c27ed388 zh: 2377524dbb845ff1 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/the-band-above-the-prompt: - en: f847277f056ebbda - zh: 33cec1a31c347676 + en: 2b04be6b58e61a74 + zh: f4854198d8999d11 /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-members-your-mod-can-call: - en: 359ab906bd23a2b9 - zh: 5ccd7fd9ad5282b2 + en: 9ca1aff59b65305f + zh: c6043a8b7aadbdec /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/test-a-mod: en: 5848fccf79f41e0a zh: 62bb20c4d7e4d8c2 @@ -78,8 +78,8 @@ en: ca31263f47f654d9 zh: 17d55e6641705627 /deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work: - en: 02c6fd37c2ebba6a - zh: 4209f4e2f60f1346 + en: 99aaa73c8ef9c609 + zh: 46fab480bd2b1706 /deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work/dev-note: en: 4d1e26f4cc9c3c1c zh: d80209b56c56b11c diff --git a/packages/experimental/claude-code-mods/README.md b/packages/experimental/claude-code-mods/README.md index 2991389875..d1cfd422a5 100644 --- a/packages/experimental/claude-code-mods/README.md +++ b/packages/experimental/claude-code-mods/README.md @@ -49,8 +49,10 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import | `catchTimeoutMs` | `1000` | A `.catch` handler's running time | | `processTimeoutMs` | `30000` | Default `$.process.run` and `$.http.fetch` timeout | | `toolAliases` | — | Claude Code tool name → harness tool name entries added to the built-in table | +| `bandColumns` | `120` | Columns the band above the prompt reports to `ui.render` as `bodyColumns` and `viewport.columns` | +| `bandRows` | `10` | Rows the band reports as `maxRows` | -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-claude-code-mods) is the exhaustive source for every accepted field. The [examples](examples/) directory holds the three mods from Anthropic's [Getting started with Claude Code mods](https://claude.dev/blog/getting-started-with-claude-code-mods/) post as plugin directories that also run under `claude --plugin-dir`: Token Weather with its published module, types, and test unchanged, and Blast Radius and Replay Theater completed from the published fragments. The [opt-in overlay](../../../apps/cli/config/examples/claude-code-mods.overlay.yml) composes the bridge, the three mods, and the [Web band](../client-ui-claude-code-mods/README.md) for a source launch. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-claude-code-mods) is the exhaustive source for every accepted field. The [examples](examples/) directory holds the three mods from Anthropic's [Getting started with Claude Code mods](https://claude.dev/blog/getting-started-with-claude-code-mods/) post as plugin directories that also run under `claude --plugin-dir`: Token Weather with its published module, types, and test unchanged, and Blast Radius and Replay Theater completed from the published fragments. The [opt-in overlay](cordis.source.patch.yml) composes the bridge, the three mods, and the [Web band](../client-ui-claude-code-mods/README.md) for a source launch. ### Which events your mod receives @@ -71,7 +73,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### The band above the prompt -The band is one instance per session, drawn by the first mod in load order whose `ui.render` hook returns a tree; a hook that calls `next(e)` yields to the next mod, so put a mod that draws only while it holds something (Blast Radius, Replay Theater) before one that draws whenever it has a reading (Token Weather). The band redraws when a `$.state` value the last drawing read is written, after `$.ui.open`, `$.ui.close`, and `$.ui.invalidate`, after each `tool.call` chain and `turn.complete` settle, and after a button press. A `Button`'s `onPress` stays in the host; the Web band sends a click back with the generation it saw, and a click on an earlier drawing is reported and ignored. `$.ui.open` answers `{ isPlaced: false }`: no pane is placed, and a mod that degrades to the band draws there. The [test kit](#test-a-mod)'s `$.ui.mount` renders the same hooks without a Client. +The band is one instance per session, drawn by the first mod in load order whose `ui.render` hook returns a tree; a hook that calls `next(e)` yields to the next mod, so put a mod that draws only while it holds something (Blast Radius, Replay Theater) before one that draws whenever it has a reading (Token Weather). The band redraws after `session.start`, when a `$.state` value the last drawing read is written, after `$.ui.open`, `$.ui.close`, and `$.ui.invalidate`, after each `tool.call` chain and `turn.complete` settle, and after a button press. A `Button`'s `onPress` stays in the host; the Web band sends a click back with the generation it saw, and a click on an earlier drawing is reported and ignored. `$.ui.open` answers `{ isPlaced: false }`: no pane is placed, and a mod that degrades to the band draws there. The [test kit](#test-a-mod)'s `$.ui.mount` renders the same hooks without a Client. ### Which `$` members your mod can call @@ -81,7 +83,7 @@ The band is one instance per session, drawn by the first mod in load order whose | `$.ui` | `resolve`, `invalidate`, `open`, `close`, `panes`, `log`, `toast`, `status`, `ask` | `resolve` hands out the element constructors; `invalidate`, `open`, `close` redraw the band; `ask` on `ctx.userQuestions`; `log`, `toast`, `status` reach the host log | | `$.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` | +| `$.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 | | `$.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 | @@ -91,7 +93,7 @@ The band is one instance per session, drawn by the first mod in load order whose | `$.http` | `fetch` | the process's `fetch`, bodies up to 4 MiB | | `$.env` | `get`, `set` | this process's environment, shared by every session and plugin | -A call whose service is not composed rejects with the missing service's package name; a namespace or method outside this table rejects with `no implementation for .`. +A call whose service is not composed rejects with the missing service's package name; a namespace or method outside this table rejects with `no implementation for .`. A mod runs in this process with its full authority: `$.env` reads and writes the harness environment, `$.http.fetch` reaches any URL, `$.fs` and `$.tool.call` act as the session does. ### Test a mod @@ -193,13 +195,13 @@ Tool results append after the reusable prefix; a dropped prompt invalidates noth These limits describe where a Claude Code mod behaves differently through this bridge; [the compatibility page](../../../docs/subsystems/claude-code-mods.md) is the complete list. They are current package constraints, not a task backlog. -- **One drawing surface** — `AbovePrompt` is served; `Pane`, the other render sites, `Input`, hotkeys, `ui.press`, `ui.input`, `ui.select`, `ui.focus`, and `ui.scroll` are not: `$.ui.open` answers `{ isPlaced: false }`, and `$.ui.log`, `$.ui.toast`, and `$.ui.status` reach the host log, not the GUI. A hook draws for a band 120 columns wide; the Web band wraps. +- **One drawing surface** — `AbovePrompt` is served; `Pane`, the other render sites, `Input`, hotkeys, `ui.press`, `ui.input`, `ui.select`, `ui.focus`, and `ui.scroll` are not: `$.ui.open` answers `{ isPlaced: false }`, and `$.ui.log`, `$.ui.toast`, and `$.ui.status` reach the host log, not the GUI. A hook draws for a band `bandColumns` wide; the Web band wraps. A button's `onPress` runs without a time limit; a hanging one keeps the band's buttons disabled until it settles. - **`next` runs once** — a hook that calls `next` twice gets the first run; Claude Code runs the chain beneath again. A hook that answers while its `next` is still running waits for that run. - **Unserved events** — `tool.check`, `tool.describe`, `turn.step`, the other `prompt.*` events, `command.describe`, `config.*`, `session.compact`, `session.receive`, `session.send`, `session.append`, `session.attach`, `session.detach`, `session.measure`, `agent.*`, `plugin.register`, `engine.create`, and `telemetry.*` register, never fire, and are named in a warning at load; `classic.*` names are refused at `register` like any unknown event. - **Unserved `$` namespaces** — `$.model`, `$.agent`, `$.config`, `$.settings`, `$.mcp`, `$.audio`, `$.telemetry`, `$.turn`, `$.ui.notice`, `$.ui.blit`, `$.ui.copy`, `$.fs.ancestors`, `$.process.spawn`, and `$.session.repo`, `send`, `append`, `authorize`, `compact`, `surfaces` reject with `no implementation`. `$.model.complete` waits on a logged side-request event so a mod's model call stays reconstructable from the Session log. -- **`tool.call` runs after the permission decision** — Claude Code runs mod `tool.call` hooks before its permission check; here the harness `tools/pre-execute` waterfall, including approval, settles first. A hook that rewrites the arguments it passes to `next` is skipped, with a report naming the [pre-tool input rewrite proposal](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md), because the call's arguments are already logged. +- **`tool.call` runs after the permission decision** — Claude Code runs mod `tool.call` hooks before its permission check; here the harness `tools/pre-execute` waterfall, including approval, settles first. A hook that rewrites the arguments it passes to `next`, or names another tool, is skipped with a report (the argument case names the [pre-tool input rewrite proposal](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the logged call runs as logged. - **One process, many sessions** — a mod's module-level variables and `$.env.set` writes are shared by every session and plugin in the process, where Claude Code runs one session per process; keep per-session values in `$.state`. `session.start` and `session.end` fire for root agents only. -- **No sandbox, no static analysis, no hot reload** — the hooks module runs in-process with Node's globals; the `$`-only access rule, `claude plugin validate`, type generation, `--plugin-dir` watching, and the in-session mod authoring flow are not implemented. Mounting a mod again re-runs `register` on the same evaluated module, so module-level variables keep their values. +- **No sandbox, no static analysis, no hot reload** — the hooks module runs in-process with Node's globals and the process's full authority (its environment, network, filesystem, and tools); the `$`-only access rule, `claude plugin validate`, type generation, `--plugin-dir` watching, and the in-session mod authoring flow are not implemented. Mount only mods you would run as a plugin. Mounting a mod again re-runs `register` on the same evaluated module, so module-level variables keep their values. - **`turn.complete` text** — the `{ text }` a hook returns reaches the host log, not a line under the answer; `durationMs` counts from the turn's `turn/start`. - **`$.session.usage`** — `window` is `0` and `percent` absent until the route's context window and a provider usage report are known through the token meter; `rateLimits` is always empty. `$.fs.stat` reports `mtimeMs: 0`. - **`plugin.json` and `hooks.json`** — not read; `defineMod` carries the identity, and a plugin's settings hooks need `@deepseek-ai/dsh-hooks-claude-code`. diff --git a/packages/experimental/claude-code-mods/README.zh.md b/packages/experimental/claude-code-mods/README.zh.md index 40e103c3c8..71a205d81e 100644 --- a/packages/experimental/claude-code-mods/README.zh.md +++ b/packages/experimental/claude-code-mods/README.zh.md @@ -49,8 +49,10 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import | `catchTimeoutMs` | `1000` | `.catch` 处理器的运行时间 | | `processTimeoutMs` | `30000` | `$.process.run` 与 `$.http.fetch` 的默认超时 | | `toolAliases` | — | 追加到内置表的 Claude Code 工具名 → harness 工具名条目 | +| `bandColumns` | `120` | 提示框上方横幅向 `ui.render` 报告的列数(`bodyColumns` 与 `viewport.columns`) | +| `bandRows` | `10` | 横幅报告的 `maxRows` 行数 | -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-claude-code-mods)是所有可接受字段的完整来源。[examples](examples/) 目录以插件目录的形式收录了 Anthropic [Getting started with Claude Code mods](https://claude.dev/blog/getting-started-with-claude-code-mods/) 一文中的三个模组,它们同样可在 `claude --plugin-dir` 下运行:Token Weather 的已发布模块、类型与测试原样保留,Blast Radius 与 Replay Theater 则由已发布片段补全。[可选叠加层](../../../apps/cli/config/examples/claude-code-mods.overlay.yml)把桥接、三个模组与 [Web 横幅](../client-ui-claude-code-mods/README.zh.md)组合起来用于源码启动。 +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-claude-code-mods)是所有可接受字段的完整来源。[examples](examples/) 目录以插件目录的形式收录了 Anthropic [Getting started with Claude Code mods](https://claude.dev/blog/getting-started-with-claude-code-mods/) 一文中的三个模组,它们同样可在 `claude --plugin-dir` 下运行:Token Weather 的已发布模块、类型与测试原样保留,Blast Radius 与 Replay Theater 则由已发布片段补全。[可选叠加层](cordis.source.patch.yml)把桥接、三个模组与 [Web 横幅](../client-ui-claude-code-mods/README.zh.md)组合起来用于源码启动。 ### 你的模组会收到哪些事件 @@ -71,7 +73,7 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import ### 提示框上方的横幅 -横幅每个会话一个实例,由加载顺序中第一个 `ui.render` 钩子返回树的模组绘制;调用 `next(e)` 的钩子把横幅让给下一个模组,因此把只在持有事项时才绘制的模组(Blast Radius、Replay Theater)放在只要有读数就绘制的模组(Token Weather)之前。横幅在上次绘制读取过的 `$.state` 值被写入时、在 `$.ui.open`、`$.ui.close`、`$.ui.invalidate` 之后、在每条 `tool.call` 链与 `turn.complete` 结束之后以及按钮按下之后重绘。`Button` 的 `onPress` 留在宿主中;Web 横幅把点击连同它看到的代数发回,针对更早绘制的点击会被报告并忽略。`$.ui.open` 回答 `{ isPlaced: false }`:没有面板被放置,降级到横幅的模组就在横幅中绘制。[测试工具包](#test-a-mod)的 `$.ui.mount` 不需要 Client 就能渲染同样的钩子。 +横幅每个会话一个实例,由加载顺序中第一个 `ui.render` 钩子返回树的模组绘制;调用 `next(e)` 的钩子把横幅让给下一个模组,因此把只在持有事项时才绘制的模组(Blast Radius、Replay Theater)放在只要有读数就绘制的模组(Token Weather)之前。横幅在 `session.start` 之后、上次绘制读取过的 `$.state` 值被写入时、在 `$.ui.open`、`$.ui.close`、`$.ui.invalidate` 之后、在每条 `tool.call` 链与 `turn.complete` 结束之后以及按钮按下之后重绘。`Button` 的 `onPress` 留在宿主中;Web 横幅把点击连同它看到的代数发回,针对更早绘制的点击会被报告并忽略。`$.ui.open` 回答 `{ isPlaced: false }`:没有面板被放置,降级到横幅的模组就在横幅中绘制。[测试工具包](#test-a-mod)的 `$.ui.mount` 不需要 Client 就能渲染同样的钩子。 ### 你的模组可以调用哪些 `$` 成员 @@ -81,7 +83,7 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import | `$.ui` | `resolve`、`invalidate`、`open`、`close`、`panes`、`log`、`toast`、`status`、`ask` | `resolve` 交出元素构造器;`invalidate`、`open`、`close` 重绘横幅;`ask` 基于 `ctx.userQuestions`;`log`、`toast`、`status` 到达宿主日志 | | `$.command` | `register`、`run`、`list` | `ctx.commands`,限定在事件所属的智能体 | | `$.tool` | `register`、`call`、`list` | `ctx.tools`;注册的工具名为 `mcp____`;工具推迟到下一请求的上下文会注入会话 | -| `$.prompt` | `submit` | `agent.followup()`,作为 `user` 来源的消息,除非 `asUser` 否则套上"来自模组"的框架 | +| `$.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 每会话一个目录 | | `$.state` | `get`、`set` | 为会话持有的内存,以模组命名的 `{ plugin, key }` 寻址;`ui.render` 期间的读取会让横幅订阅 | | `$.store` | `get`、`set`、`delete`、`keys` | `claude_code_mods` 存储域,每插件一个 JSON 对象,4 MiB | @@ -91,7 +93,7 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import | `$.http` | `fetch` | 进程的 `fetch`,正文最多 4 MiB | | `$.env` | `get`、`set` | 本进程的环境变量,所有会话与插件共享 | -所需服务未组合的调用以缺失服务的包名拒绝;表外的命名空间或方法以 `no implementation for .` 拒绝。 +所需服务未组合的调用以缺失服务的包名拒绝;表外的命名空间或方法以 `no implementation for .` 拒绝。模组以本进程的全部权限运行:`$.env` 读写 harness 的环境变量,`$.http.fetch` 可访问任意 URL,`$.fs` 与 `$.tool.call` 以会话的身份行动。 ### 测试模组 @@ -193,13 +195,13 @@ Message from the "" mod: 这些限制描述 Claude Code 模组经本桥接运行时行为不同之处;[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 是完整清单。它们是当前的包约束,不是任务积压。 -- **一个绘制表面** — 服务 `AbovePrompt`;`Pane`、其他渲染位点、`Input`、热键、`ui.press`、`ui.input`、`ui.select`、`ui.focus`、`ui.scroll` 不服务:`$.ui.open` 回答 `{ isPlaced: false }`,`$.ui.log`、`$.ui.toast`、`$.ui.status` 到达宿主日志而非 GUI。钩子按 120 列宽的横幅绘制;Web 横幅会换行。 +- **一个绘制表面** — 服务 `AbovePrompt`;`Pane`、其他渲染位点、`Input`、热键、`ui.press`、`ui.input`、`ui.select`、`ui.focus`、`ui.scroll` 不服务:`$.ui.open` 回答 `{ isPlaced: false }`,`$.ui.log`、`$.ui.toast`、`$.ui.status` 到达宿主日志而非 GUI。钩子按 `bandColumns` 列宽的横幅绘制;Web 横幅会换行。按钮的 `onPress` 没有时间限制;挂起的 `onPress` 会让横幅的按钮保持禁用直到它结束。 - **`next` 只运行一次** — 调用 `next` 两次的钩子得到第一次的运行;Claude Code 会再次运行下层链。在其 `next` 仍在运行时作答的钩子会等待该次运行。 - **未服务的事件** — `tool.check`、`tool.describe`、`turn.step`、其他 `prompt.*` 事件、`command.describe`、`config.*`、`session.compact`、`session.receive`、`session.send`、`session.append`、`session.attach`、`session.detach`、`session.measure`、`agent.*`、`plugin.register`、`engine.create`、`telemetry.*` 可注册、永不触发,并在加载时以警告点名;`classic.*` 名字与任何未知事件一样在 `register` 时被拒绝。 - **未服务的 `$` 命名空间** — `$.model`、`$.agent`、`$.config`、`$.settings`、`$.mcp`、`$.audio`、`$.telemetry`、`$.turn`、`$.ui.notice`、`$.ui.blit`、`$.ui.copy`、`$.fs.ancestors`、`$.process.spawn` 以及 `$.session.repo`、`send`、`append`、`authorize`、`compact`、`surfaces` 以 `no implementation` 拒绝。`$.model.complete` 等待一个写入日志的副请求事件,以便模组的模型调用仍可从 Session 日志重建。 -- **`tool.call` 在权限决定之后运行** — Claude Code 在其权限检查之前运行模组的 `tool.call` 钩子;此处 harness 的 `tools/pre-execute` 瀑布流(含审批)先行结束。改写传给 `next` 的参数的钩子会被跳过,并在报告中指向[工具前输入改写提案](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md),因为调用参数已写入日志。 +- **`tool.call` 在权限决定之后运行** — Claude Code 在其权限检查之前运行模组的 `tool.call` 钩子;此处 harness 的 `tools/pre-execute` 瀑布流(含审批)先行结束。改写传给 `next` 的参数或改名工具的钩子会被跳过并报告(参数情形指向[工具前输入改写提案](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md)),因为已写入日志的调用按原样运行。 - **一个进程,多个会话** — 模组的模块级变量与 `$.env.set` 写入由进程内所有会话与插件共享,而 Claude Code 每进程一个会话;按会话的值请放在 `$.state`。`session.start` 与 `session.end` 只为根智能体触发。 -- **无沙箱、无静态分析、无热重载** — 钩子模块以 Node 全局对象在进程内运行;仅 `$` 访问规则、`claude plugin validate`、类型生成、`--plugin-dir` 监视与会话内模组编写流程均未实现。再次挂载模组会在同一已求值模块上重新运行 `register`,模块级变量保留其值。 +- **无沙箱、无静态分析、无热重载** — 钩子模块以 Node 全局对象在进程内运行,拥有本进程的全部权限(环境变量、网络、文件系统与工具);仅 `$` 访问规则、`claude plugin validate`、类型生成、`--plugin-dir` 监视与会话内模组编写流程均未实现。只挂载你愿意作为插件运行的模组。再次挂载模组会在同一已求值模块上重新运行 `register`,模块级变量保留其值。 - **`turn.complete` 文本** — 钩子返回的 `{ text }` 到达宿主日志,而非答案下方的一行;`durationMs` 从该轮的 `turn/start` 起算。 - **`$.session.usage`** — 在 token 计量器得知路由的上下文窗口与提供商用量报告之前,`window` 为 `0` 且无 `percent`;`rateLimits` 始终为空。`$.fs.stat` 报告 `mtimeMs: 0`。 - **`plugin.json` 与 `hooks.json`** — 不读取;`defineMod` 承载身份,插件的设置钩子需要 `@deepseek-ai/dsh-hooks-claude-code`。 diff --git a/packages/experimental/claude-code-mods/cordis.source.patch.yml b/packages/experimental/claude-code-mods/cordis.source.patch.yml new file mode 100644 index 0000000000..8ec06b8458 --- /dev/null +++ b/packages/experimental/claude-code-mods/cordis.source.patch.yml @@ -0,0 +1,21 @@ +# Source overlay: the Claude Code mods bridge, its band above the prompt, and +# the three example mods from Anthropic's "Getting started with Claude Code +# mods" post. The relative entries are anchored to this file and run through +# the CLI's tsx loader without a profile install, as the inspector's source +# overlay does. Mods load in list order and the band above the prompt is one +# instance consulted in that order, so the guards that yield when idle come +# before Token Weather, which draws whenever it has a reading. +# +# pnpm dsh web --patch ./packages/experimental/claude-code-mods/cordis.source.patch.yml +# pnpm dsh --profile headless --patch ./packages/experimental/claude-code-mods/cordis.source.patch.yml "task" +- insert: + - id: claude-code-mods + name: './src/index.ts' + - id: claude-code-mod-blast-radius + name: './examples/blast-radius/index.ts' + - id: claude-code-mod-replay-theater + name: './examples/replay-theater/index.ts' + - id: claude-code-mod-token-weather + name: './examples/token-weather/index.ts' + - id: client-ui-claude-code-mods + name: '../client-ui-claude-code-mods/src/index.ts' diff --git a/packages/experimental/claude-code-mods/src/api.ts b/packages/experimental/claude-code-mods/src/api.ts index 46535304ba..63463bbe46 100644 --- a/packages/experimental/claude-code-mods/src/api.ts +++ b/packages/experimental/claude-code-mods/src/api.ts @@ -60,7 +60,8 @@ export function createModsApi(binding: ApiBinding): ModsApi { }) } - return Object.freeze({ + // Not frozen: the proxy below returns a wrapped namespace, which a frozen target's invariant would forbid. + const served: ModsApi = { plugin: Object.freeze({ name: mod.name, root: mod.root }), ui: Object.freeze({ log(text: string, options?: UiLogOptions): void { @@ -169,5 +170,50 @@ export function createModsApi(binding: ApiBinding): ModsApi { get: (name: string): Promise => call('env.get', { name }), set: (name: string, value: string | undefined): Promise => call('env.set', { name, ...value === undefined ? {} : { value } }), }), + } + return withUnservedMembers(served, mod.name) +} + +/** The method namespaces Claude Code's `$` has; `plugin` holds facts, not methods, and stays as served. */ +const METHOD_NAMESPACES: ReadonlySet = new Set([ + 'ui', 'command', 'tool', 'prompt', 'session', 'state', 'store', 'clock', 'fs', 'process', 'http', 'env', + 'model', 'agent', 'config', 'settings', 'mcp', 'audio', 'telemetry', 'turn', +]) + +/** + * Give `$` Claude Code's full namespace set: a served member is itself; any + * other member of a method namespace is a function whose call rejects with + * `no implementation for .`, so a mod that reaches past + * this host fails with the gap named, not with a TypeError. + * @param served - the frozen object of served namespaces. + * @param modName - the mod the `$` belongs to, for the rejection text. + * @returns the `$` a hook receives. + */ +function withUnservedMembers(served: ModsApi, modName: string): ModsApi { + const namespaces = new Map() + const namespaceOf = (name: string, base: object): object => { + let wrapped = namespaces.get(name) + if (wrapped === undefined) { + wrapped = new Proxy(base, { + get(target, member, receiver) { + const own: unknown = Reflect.get(target, member, receiver) + if (own !== undefined || typeof member !== 'string' || member === 'then') return own + return () => Promise.reject(new Error(`${modName}: no implementation for ${name}.${member}`)) + }, + }) + namespaces.set(name, wrapped) + } + return wrapped + } + return new Proxy(served, { + get(target, namespace, receiver) { + const own: unknown = Reflect.get(target, namespace, receiver) + if (typeof namespace !== 'string' || !METHOD_NAMESPACES.has(namespace)) return own + return namespaceOf(namespace, typeof own === 'object' && own !== null ? own : Object.freeze({})) + }, + // `$` is read-only, as the frozen object was. + set: () => false, + defineProperty: () => false, + deleteProperty: () => false, }) } diff --git a/packages/experimental/claude-code-mods/src/chain.ts b/packages/experimental/claude-code-mods/src/chain.ts index de91bc4602..dcd155bc3d 100644 --- a/packages/experimental/claude-code-mods/src/chain.ts +++ b/packages/experimental/claude-code-mods/src/chain.ts @@ -306,6 +306,14 @@ export async function dispatch(request: DispatchRequest): Promise (e: E): Promise => { if (state.beneath !== undefined) return state.beneath if (handlerAbandoned) return Promise.resolve(undefined as R) + if (request.validateNext !== undefined) { + // The same check the hook's own next makes: a handler cannot pass a rewrite beneath either. + try { + request.validateNext(e, hook) + } catch (error: unknown) { + return Promise.reject(error instanceof Error ? error : new Error(messageOf(error))) + } + } clock.pause() state.beneath = runFrom(index + 1, e).finally(() => { clock.resume() }) return state.beneath diff --git a/packages/experimental/claude-code-mods/src/define-mod.ts b/packages/experimental/claude-code-mods/src/define-mod.ts index 633edda196..3c0cbdb2d5 100644 --- a/packages/experimental/claude-code-mods/src/define-mod.ts +++ b/packages/experimental/claude-code-mods/src/define-mod.ts @@ -32,7 +32,7 @@ export interface ModPlugin { readonly Config: z /** The mod as the bridge and the test kit load it, with `userConfig` as its options. */ readonly definition: ModDefinition - apply(ctx: Context, config: ModConfig): Promise + apply(ctx: Context, config: ModConfig): Promise<() => Promise> } const optionValue = z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]) @@ -57,9 +57,9 @@ export function defineMod(spec: ModSpec): ModPlugin { Config: z.dict(optionValue).default({}), definition, // A `register` that throws fails this plugin's mount, as any misconfigured plugin does; the rest of the composition continues. - async apply(ctx: Context, config: ModConfig): Promise { - const dispose = await ctx.claudeCodeMods.add({ ...definition, options: Object.freeze({ ...userConfig, ...config }) }) - ctx.effect(() => dispose, `claude-code-mods: mod ${spec.name}`) + // The disposer is the return value, which Cordis collects even when the plugin was removed while `register` ran. + apply(ctx: Context, config: ModConfig): Promise<() => Promise> { + return ctx.claudeCodeMods.add({ ...definition, options: Object.freeze({ ...userConfig, ...config }) }) }, } } diff --git a/packages/experimental/claude-code-mods/src/engine.ts b/packages/experimental/claude-code-mods/src/engine.ts index 23b86fef91..3846033117 100644 --- a/packages/experimental/claude-code-mods/src/engine.ts +++ b/packages/experimental/claude-code-mods/src/engine.ts @@ -270,9 +270,11 @@ export class ModsEngine { signal, report: this.options.report, }) - // Hooks that settle with anything but an object are skipped, so the result is the core's or a hook's object. - const answer = result as { value?: unknown; deny?: unknown } - if (!('value' in answer) && !('deny' in answer)) throw new Error(`${op}: a hook returned neither { value } nor { deny }`) + // Hooks that settle with anything but an object are skipped, so the result is the core's or a hook's object — or null. + const answer = result as { value?: unknown; deny?: unknown } | null + if (answer === null || (!('value' in answer) && !('deny' in answer))) { + throw new Error(`${op}: a hook returned neither { value } nor { deny }`) + } if (typeof answer.deny === 'string') throw new Error(`${op} refused: ${answer.deny}`) return answer.value } diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts index 593294c8a1..27245fcff4 100644 --- a/packages/experimental/claude-code-mods/src/host-ops.ts +++ b/packages/experimental/claude-code-mods/src/host-ops.ts @@ -99,6 +99,8 @@ export interface HostOpsOptions { readonly modTools: Set /** Redraw a session's band after a `$.ui` call that changes what it shows; absent when no surface is drawn. */ readonly redraw?: (sessionId: string) => void + /** Note a prompt a mod submitted, so the `prompt.submit` it raises carries the mod as its origin. */ + readonly submitted?: (messageId: string, mod: string) => void } function requireAgent(context: OpContext, op: string): Agent { @@ -379,7 +381,9 @@ export function createHostOps(options: HostOpsOptions): OpTable { const body = requireString(text, '$.prompt.submit text') const framed = asUser === true ? body : `Message from the "${context.mod.name}" mod:\n${body}` // Claude Code submits the prompt as the user's own; the framing names the mod unless `asUser` is set. - agent.followup(createUserMessage({ content: [{ type: 'text', text: framed }], source: { kind: 'user' } })) + const message = createUserMessage({ content: [{ type: 'text', text: framed }], source: { kind: 'user' } }) + options.submitted?.(message.id, context.mod.name) + agent.followup(message) return { text: framed } }, @@ -525,9 +529,8 @@ export function createHostOps(options: HostOpsOptions): OpTable { response.headers.forEach((value, name) => { headers[name] = value }) - const body = await response.arrayBuffer() - if (body.byteLength > HTTP_MAX_BYTES) throw new Error(`$.http.fetch: the response body is larger than ${HTTP_MAX_BYTES} bytes`) - return { status: response.status, ok: response.ok, headers, text: new TextDecoder().decode(body) } satisfies HttpResponse + const body = await readBounded(response, HTTP_MAX_BYTES) + return { status: response.status, ok: response.ok, headers, text: body } satisfies HttpResponse }, // ---- environment of this process ---- @@ -542,3 +545,22 @@ export function createHostOps(options: HostOpsOptions): OpTable { } export type { ModsEngine } + +/** Read a response body up to the bound, cancelling the stream at the first byte past it. */ +async function readBounded(response: Response, maxBytes: number): Promise { + const reader = response.body?.getReader() + if (reader === undefined) return '' + const chunks: Uint8Array[] = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel() + throw new Error(`$.http.fetch: the response body is larger than ${maxBytes} bytes`) + } + chunks.push(value) + } + return new TextDecoder().decode(Buffer.concat(chunks)) +} diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts index 4da3764ebc..12d7854482 100644 --- a/packages/experimental/claude-code-mods/src/index.ts +++ b/packages/experimental/claude-code-mods/src/index.ts @@ -43,7 +43,6 @@ declare module '@deepseek-ai/cordis' { export type * from './types.ts' export type { BoxProps, ButtonProps, TextProps, UiElement, UiElements, UiNode } from './elements.ts' -export { BAND_COLUMNS } from './surfaces.ts' export { defineMod, type ModConfig, type ModPlugin, type ModSpec } from './define-mod.ts' export { DEFAULT_TOOL_ALIASES } from './tool-names.ts' export { KNOWN_EVENTS } from './matcher.ts' @@ -59,6 +58,10 @@ export interface Config { processTimeoutMs?: number /** Claude Code tool name → harness tool name entries added to the built-in alias table. */ toolAliases?: Record + /** Columns the band above the prompt reports to `ui.render` as `bodyColumns` and `viewport.columns`. */ + bandColumns?: number + /** Rows the band reports as `maxRows`. */ + bandRows?: number } /** Engine events this host raises; a hook on any other known event registers and is reported as unserved. */ @@ -162,8 +165,13 @@ class DetachedRuns { void settled.finally(() => { this.pending.delete(settled) }) } - async drain(): Promise { + /** Abort every run's signal; `drain` then waits for them to settle. */ + cancel(): void { this.controller.abort(new Error('claude-code-mods disposed')) + } + + async drain(): Promise { + this.cancel() await Promise.allSettled([...this.pending]) } } @@ -195,6 +203,8 @@ export class ClaudeCodeMods extends TypertRemoteService { catchTimeoutMs: z.number().default(1_000), processTimeoutMs: z.number().default(30_000), toolAliases: z.dict(z.string()), + bandColumns: z.number().default(120), + bandRows: z.number().default(10), }) // Every harness service is read through `ctx.get` when a mod's call needs it, @@ -207,12 +217,13 @@ export class ClaudeCodeMods extends TypertRemoteService { constructor(ctx: Context, config: Config) { super(ctx, 'claudeCodeMods', { namespace: 'claudeCodeMods' }) - const hookTimeoutMs = config.hookTimeoutMs ?? 10_000 - const catchTimeoutMs = config.catchTimeoutMs ?? 1_000 - const processTimeoutMs = config.processTimeoutMs ?? 30_000 + // The schema supplies every default; a direct construction passes the fields it needs. + const { hookTimeoutMs = 10_000, catchTimeoutMs = 1_000, processTimeoutMs = 30_000, bandColumns = 120, bandRows = 10 } = config assertPositive('hookTimeoutMs', hookTimeoutMs) assertPositive('catchTimeoutMs', catchTimeoutMs) assertPositive('processTimeoutMs', processTimeoutMs) + assertPositive('bandColumns', bandColumns) + assertPositive('bandRows', bandRows) const aliases = createToolNameAliases(config.toolAliases) const callOrigins = new Map() const modCommands = new Set() @@ -221,8 +232,11 @@ export class ClaudeCodeMods extends TypertRemoteService { const agentOf = (sessionId: string): Agent | undefined => ctx.get('agents')?.get(SessionId(sessionId)) const detached = new DetachedRuns(report) const surfaces = new SurfaceTable({ + columns: bandColumns, + rows: bandRows, render: (sessionId, input) => { const agent = agentOf(sessionId) + /* v8 ignore next -- agent/disposed forgets the band before the registry drops the agent; a redraw racing it draws nothing */ if (agent === undefined) return Promise.resolve(null) return engine.raise('ui.render', input, () => null, { binding: { agent }, signal: detached.controller.signal, @@ -244,8 +258,10 @@ export class ClaudeCodeMods extends TypertRemoteService { if (agentOf(sessionId) !== undefined) void surfaces.refresh(sessionId) }, 0).unref() } + const modSubmissions = new Map() const ops = createHostOps({ ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw, + submitted: (messageId, mod) => { modSubmissions.set(messageId, mod) }, }) const engine: ModsEngine = new ModsEngine({ ops: op => ops[op], @@ -259,6 +275,8 @@ export class ClaudeCodeMods extends TypertRemoteService { this.engine = engine const registrations = this.registrations ctx.effect(() => async () => { + // Cancel first: a timer callback waiting inside a `$` call ends on this signal, and engine.dispose awaits it. + detached.cancel() surfaces.dispose() for (const owned of registrations.values()) for (const dispose of owned) dispose() registrations.clear() @@ -328,12 +346,14 @@ export class ClaudeCodeMods extends TypertRemoteService { const prompt = promptMessages(messages) const text = textOf(prompt.flatMap(message => message.content)) let submitted: PromptSubmitResult = { text } - if (messages.length > 0) { - const sourceKind = messages[0]?.source.kind + // A batch of injected context alone is not a prompt: Claude Code raises prompt.submit for typed prompts. + if (prompt.length > 0) { + const submitter = prompt.map(message => modSubmissions.get(message.id)).find(name => name !== undefined) + for (const message of prompt) modSubmissions.delete(message.id) const input: PromptSubmitInput = { text, wait: false, - origin: sourceKind === undefined || sourceKind === 'user' ? { kind: 'composer' } : { kind: 'plugin', name: sourceKind }, + origin: submitter === undefined ? { kind: 'composer' } : { kind: 'plugin', name: submitter }, } submitted = acceptPromptSubmit(await engine.raise( 'prompt.submit', input, e => ({ text: e.text, ...e.context === undefined ? {} : { context: e.context } }), { binding, signal }, @@ -378,7 +398,10 @@ export class ClaudeCodeMods extends TypertRemoteService { signal: exec.signal, // The call is logged before policy runs, so the arguments a hook passes down must be the logged ones. validateNext: (e) => { - const { tool: _tool, tool_use_id: _id, agentId: _agentId, ...rest } = e + const { tool, tool_use_id: _id, agentId: _agentId, ...rest } = e + if (tool !== modName) { + throw new RewriteRefusedError(`rerouted the call from ${modName} to ${tool}; the logged call runs the tool it named`) + } if (JSON.stringify(rest) !== logged) { throw new RewriteRefusedError(`rewrote the arguments of ${modName}; argument rewrites need the pre-tool input rewrite mechanism (${REWRITE_NOTE})`) } @@ -447,8 +470,9 @@ export class ClaudeCodeMods extends TypertRemoteService { isAborted: reason.kind === 'aborted', reason: reason.kind === 'aborted' ? 'aborted' : reason.kind === 'error' ? 'error' : 'answer', ...agentIdOf(agent), - ...folded?.usage === undefined ? {} : { usage: folded.usage }, + ...folded?.usage === undefined ? {} : { usage: { ...folded.usage } }, } + if (folded !== undefined) turns.delete(session.id) detached.track('turn.complete', engine.raise( 'turn.complete', input, () => ({ text: '' }), { binding: { agent }, signal: detached.controller.signal }, ).then((result) => { @@ -482,14 +506,6 @@ export class ClaudeCodeMods extends TypertRemoteService { return this.surfaces.press(agent.session.id, generation, actionId) } - /** - * The current drawing of one session's band, drawing it first when nothing was drawn yet. - * @param sessionId - the session. - * @returns the band's snapshot. - */ - band(sessionId: string): Promise { - return this.surfaces.current(sessionId) - } /** * Load one mod beneath every mod loaded before it: run its `register`, diff --git a/packages/experimental/claude-code-mods/src/surfaces.ts b/packages/experimental/claude-code-mods/src/surfaces.ts index 7b22a055e6..6cd7ad67c4 100644 --- a/packages/experimental/claude-code-mods/src/surfaces.ts +++ b/packages/experimental/claude-code-mods/src/surfaces.ts @@ -13,11 +13,12 @@ import type { SerializedNode, SurfaceSnapshot, UiRenderInput } from './types.ts' export type { SurfaceSnapshot } from './types.ts' -/** The band's width the host reports to `ui.render` as `bodyColumns` and `viewport.columns`. */ -export const BAND_COLUMNS = 120 - /** What the table needs from the host. */ export interface SurfaceHost { + /** Columns the band reports to `ui.render` as `bodyColumns` and `viewport.columns`. */ + readonly columns: number + /** Rows the band reports as `maxRows`. */ + readonly rows: number /** Raise `ui.render` for one session through the mods and return the tree. */ render(sessionId: string, input: UiRenderInput): Promise /** Run one button's callback inside the session; the host owns the invocation and its reporting. */ @@ -34,9 +35,13 @@ class SessionBand { /** Button callbacks of the current generation, by the `actionId` the serialized tree carries. */ readonly actions = new Map unknown>() readonly watchers = new Set<(snapshot: SurfaceSnapshot) => void>() - /** The render in flight, so concurrent refresh requests share one pass and a request during a pass runs once more after it. */ + /** The render in flight; a refresh during it joins one more pass that starts when it ends. */ rendering: Promise | undefined - again = false + pending: Promise | undefined + /** Set by `forget`: watchers end at their next wake and no further pass renders. */ + closed = false + /** Wakes every parked watcher, so a closed band ends its streams. */ + readonly wakers = new Set<() => void>() /** Set while a render runs: `$.state` reads subscribe. */ collecting: Set | undefined @@ -81,12 +86,14 @@ export class SurfaceTable { } band.watchers.add(watcher) const onAbort = (): void => { wake?.() } + const waker = (): void => { wake?.() } + band.wakers.add(waker) signal.addEventListener('abort', onAbort, { once: true }) try { const first = await this.current(sessionId) let delivered = first.generation yield first - while (!signal.aborted) { + while (!signal.aborted && !band.closed) { const next = queue.shift() if (next !== undefined) { // The first drawing may arrive both as the current snapshot and as a watcher notification. @@ -100,6 +107,7 @@ export class SurfaceTable { } } finally { band.watchers.delete(watcher) + band.wakers.delete(waker) signal.removeEventListener('abort', onAbort) } } @@ -111,18 +119,16 @@ export class SurfaceTable { */ refresh(sessionId: string): Promise { const band = this.bandOf(sessionId) - if (band.rendering !== undefined) { - band.again = true + if (band.rendering === undefined) { + band.rendering = this.renderOnce(sessionId, band).finally(() => { band.rendering = undefined }) return band.rendering } - band.rendering = this.renderOnce(sessionId, band).finally(() => { - band.rendering = undefined - if (band.again) { - band.again = false - void this.refresh(sessionId) - } + // One more pass after the one in flight serves every refresh asked for meanwhile. + band.pending ??= band.rendering.then(() => { + band.pending = undefined + return this.refresh(sessionId) }) - return band.rendering + return band.pending } /** @@ -166,17 +172,19 @@ export class SurfaceTable { } /** - * Forget a session's band. + * Forget a session's band: its watch streams end and no further pass renders it. * @param sessionId - the disposed session. */ forget(sessionId: string): void { const band = this.bands.get(sessionId) if (band === undefined) return this.bands.delete(sessionId) + band.closed = true band.watchers.clear() + for (const waker of [...band.wakers]) waker() } - /** Stop drawing: pending watchers end at their next wake. */ + /** Stop drawing: every band is forgotten and its watch streams end. */ dispose(): void { this.disposed = true for (const sessionId of [...this.bands.keys()]) this.forget(sessionId) @@ -192,12 +200,12 @@ export class SurfaceTable { } private async renderOnce(sessionId: string, band: SessionBand): Promise { - if (this.disposed) return + if (this.disposed || band.closed) return const input: UiRenderInput = { component: 'AbovePrompt', surface: 'AbovePrompt', - props: { bodyColumns: BAND_COLUMNS, hasSurvey: false, isWorking: false, maxRows: 10 }, - viewport: { columns: BAND_COLUMNS }, + props: { bodyColumns: this.host.columns, hasSurvey: false, isWorking: false, maxRows: this.host.rows }, + viewport: { columns: this.host.columns }, } band.collecting = new Set() let tree: UiNode diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts index d898a1a6dd..286f4a9a71 100644 --- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts +++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts @@ -259,19 +259,40 @@ describe('ticket-mod: a mod-registered tool, prompt context, and a turn.complete describe('prompt.submit: rewrite and drop', () => { it('rewrites the prompt text the model sees and drops a prompt with a reason', async () => { const mod = writeMod('prompt-mod', ` + const origins = [] + let submitted = false export function register(on) { + on('session.start', async ($, e, next) => { await $.command.register({ name: 'origins', description: 'origins seen' }); return next(e) }) + on('command.run', { command: 'origins' }, async ($) => { + if (!submitted) { + submitted = true + await $.prompt.submit({ text: 'from the mod' }) + } + return { text: JSON.stringify(origins) } + }) on('prompt.submit', async ($, e, next) => { + origins.push(e.origin) if (e.text.includes('secret')) return { drop: 'secrets stay out' } if (e.text.includes('junk')) return { text: 7, context: ['kept context', 5] } + if (e.text.includes('reach')) { + // Members this host does not serve reject by name instead of being undefined. + const gaps = [] + await $.model.complete({ messages: [] }).catch(error => gaps.push(error.message)) + await $.fs.ancestors('x').catch(error => gaps.push(error.message)) + await $.ui.notice('x').catch(error => gaps.push(error.message)) + return next({ ...e, context: gaps }) + } return next({ ...e, text: e.text.trim().toUpperCase() }) }) } `) - const adapter = new MockAdapter([textResponse('ok'), textResponse('junk ok')]) + const adapter = new MockAdapter([textResponse('ok'), textResponse('junk ok'), textResponse('reached'), textResponse('mod prompt ok')]) const h = await harness([mod], adapter) const agent = await h.agent() + // Injected context alone is not a prompt: no prompt.submit is raised for it. + agent.inject(createUserMessage({ content: [{ type: 'text', text: 'injected only' }], source: { kind: 'user-question-reply', callId: ToolCallIdOf('q0'), outcome: 'answered' } })) await h.turn(agent, ' hello there ') - const entered = events(agent).find(e => e.type === 'user/message') + const entered = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'user') expect(entered?.type === 'user/message' && entered.data.content).toEqual([{ type: 'text', text: 'HELLO THERE' }]) expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('HELLO THERE') @@ -285,6 +306,24 @@ describe('prompt.submit: rewrite and drop', () => { await h.turn(agent, 'some junk') const last = adapter.requests[1]?.messages.filter(message => message.role === 'user') ?? [] expect(last.at(-1)?.content).toEqual([{ type: 'text', text: 'some junk' }, { type: 'text', text: 'kept context' }]) + + await h.turn(agent, 'reach past the host') + const reached = adapter.requests[2]?.messages.filter(message => message.role === 'user') ?? [] + expect(reached.at(-1)?.content).toEqual([ + { type: 'text', text: 'reach past the host' }, + { type: 'text', text: 'prompt-mod: no implementation for model.complete' }, + { type: 'text', text: 'prompt-mod: no implementation for fs.ancestors' }, + { type: 'text', text: 'prompt-mod: no implementation for ui.notice' }, + ]) + + // A prompt the mod submitted reaches prompt.submit with the mod as its origin; typed prompts are the composer's. + const signal = new AbortController().signal + await h.ctx.commands.execute(agent, '/origins', [], signal) + await agent.whenIdle() + const origins = await h.ctx.commands.execute(agent, '/origins', [], signal) + expect(JSON.parse(String(origins?.result.text).replace(/^prompt-mod: /u, ''))).toEqual([ + { kind: 'composer' }, { kind: 'composer' }, { kind: 'composer' }, { kind: 'composer' }, { kind: 'plugin', name: 'prompt-mod' }, + ]) }) }) @@ -314,6 +353,12 @@ describe('tool.call: answers and rewrites', () => { }) } `) + // A second mod, so its first skip report is the reroute (a hook's reports are one per failure kind). + const rerouter = writeMod('reroute-mod', ` + export function register(on) { + on('tool.call', { tool: 'echo' }, ($, e, next) => e.command === 'reroute' ? next({ ...e, tool: 'Read', file_path: 'x' }) : next(e)) + } + `) const ran: string[] = [] const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', { command: 'skip' }), textResponse('one'), @@ -324,8 +369,9 @@ describe('tool.call: answers and rewrites', () => { toolCallResponse('c6', 'echo', { command: 'tweak' }), textResponse('five'), toolCallResponse('c7', 'echo', { command: 'empty' }), textResponse('six'), toolCallResponse('c8', 'echo', { command: 'error' }), textResponse('seven'), + toolCallResponse('c9', 'echo', { command: 'reroute' }), textResponse('eight'), ]) - const h = await harness([mod], adapter) + const h = await harness([mod, rerouter], adapter) h.ctx.tools.register(echoTool('echo', ran)) // A tool whose output schema admits a string: a mod's string answer is its ordinary result. h.ctx.tools.register({ @@ -348,8 +394,9 @@ describe('tool.call: answers and rewrites', () => { await h.turn(agent, 'tweak it') await h.turn(agent, 'empty answer') await h.turn(agent, 'error answer') + await h.turn(agent, 'reroute it') // The rewrite is refused: the hook is skipped once per kind and the call runs with its logged arguments. - expect(ran).toEqual(['redact', 'redact', 'fail', 'tweak']) + expect(ran).toEqual(['redact', 'redact', 'fail', 'tweak', 'reroute']) const results = events(agent).filter(e => e.type === 'tool/result') .map(e => e.type === 'tool/result' && [e.data.message.isError === true, e.data.message.content[0]]) expect(results.slice(1)).toEqual([ @@ -360,7 +407,11 @@ describe('tool.call: answers and rewrites', () => { [false, { type: 'text', text: 'tweaked' }], [true, { type: 'text', text: '' }], [true, { type: 'text', text: 'refused outright' }], + [false, { type: 'text', text: 'ran reroute' }], ]) + expect(h.warn).toHaveBeenCalledWith( + 'claude-code-mods: reroute-mod: tool.call hook skipped: rerouted the call from echo to Read; the logged call runs the tool it named', + ) // A call without an agent (a host-side execute) is answered by the same schema rule. const direct = await h.ctx.tools.execute({ callId: ToolCallIdOf('direct'), name: 'greet', arguments: {}, signal: new AbortController().signal }) expect(direct.isError).toBe(false) @@ -416,6 +467,7 @@ describe('the mods API over harness services', () => { out.http = { status: response.status, ok: response.ok, text: response.text, echoed: response.headers['x-echo'] } out.get = (await $.http.fetch(e.args)).headers['x-echo'] out.httpBig = await $.http.fetch(e.args + 'big').then(() => 'read', error => error.message) + out.httpNone = await $.http.fetch(e.args + 'none') out.commands = (await $.command.list()).map(command => command.name + '/' + command.source) out.tools = (await $.tool.list()).map(tool => tool.name) out.call = await $.tool.call({ tool: 'echo', command: 'from mod' }) @@ -438,6 +490,11 @@ describe('the mods API over harness services', () => { response.end(Buffer.alloc(4 * 1024 * 1024 + 1, 120)) return } + if (request.url === '/none') { + response.statusCode = 204 + response.end() + return + } let body = '' request.on('data', (chunk: Buffer) => { body += chunk.toString() }) request.on('end', () => { @@ -484,6 +541,7 @@ describe('the mods API over harness services', () => { expect(out.http).toEqual({ status: 200, ok: true, text: 'pong', echoed: 'POST api-mod ping' }) expect(out.get).toBe('GET undefined ') expect(out.httpBig).toMatch(/larger than 4194304 bytes/) + expect(out.httpNone).toMatchObject({ status: 204, ok: true, text: '' }) expect(out.commands).toEqual(['probe/plugin']) expect(out.tools).toContain('echo') expect(out.call).toEqual({ result: 'ran from mod' }) @@ -848,6 +906,26 @@ describe('loading diagnostics and configuration', () => { expect(() => new ClaudeCodeMods(fresh(), { hookTimeoutMs: 0 })).toThrow(/hookTimeoutMs must be a positive number/) expect(() => new ClaudeCodeMods(fresh(), { catchTimeoutMs: -1 })).toThrow(/catchTimeoutMs must be a positive number/) expect(() => new ClaudeCodeMods(fresh(), { processTimeoutMs: Number.NaN })).toThrow(/processTimeoutMs must be a positive number/) + expect(() => new ClaudeCodeMods(fresh(), { bandColumns: 0 })).toThrow(/bandColumns must be a positive number/) + expect(() => new ClaudeCodeMods(fresh(), { bandRows: -1 })).toThrow(/bandRows must be a positive number/) + }) + + it('disposes while a timer callback waits inside a $ call: the wait is cancelled and disposal settles', async () => { + const mod = writeMod('sleeper-mod', ` + export function register(on) { + on('session.start', async ($, e, next) => { + $.clock.after(1, async () => { await $.clock.sleep(60_000) }) + return next(e) + }) + } + `) + const h = await harness([mod], new MockAdapter([])) + await h.agent() + await new Promise(resolve => setTimeout(resolve, 20)) + const started = Date.now() + await h.mods.dispose() + expect(Date.now() - started).toBeLessThan(5_000) + expect(h.warn).toHaveBeenCalledWith('claude-code-mods: sleeper-mod: timer callback failed: claude-code-mods disposed') }) it('mounts a mod plugin through the Loader-visible plugin shape and unmounts it with its hooks', async () => { @@ -985,6 +1063,7 @@ describe('the band above the prompt', () => { ]) // One band instance, consulted in load order: the guard that yields when idle goes before the readout that always draws. const h = await harness([boom, blastRadius, tokenWeather], adapter, { + config: { bandColumns: 100, bandRows: 6 }, services: async (ctx, workspace) => { await ctx.plugin(TokenMeter) await ctx.plugin(LocalFileSystem, { cwd: workspace }) @@ -994,15 +1073,14 @@ describe('the band above the prompt', () => { h.ctx.tools.register(echoTool('bash', ran)) const agent = await h.agent() const mods = h.ctx.claudeCodeMods - // Before a turn, Token Weather has no reading and Blast Radius holds nothing: the band is empty; so is an unknown session's. - expect((await mods.band(agent.session.id)).tree).toBeNull() - expect((await mods.band('no-such-session')).tree).toBeNull() - const seen: unknown[] = [] const watching = new AbortController() const watcher = (async () => { for await (const snapshot of mods.watchBand(agent, watching.signal)) seen.push(snapshot) })() + // Before a turn, Token Weather has no reading and Blast Radius holds nothing: the band is empty. + await waitFor(() => seen.length === 1) + expect(seen[0]).toEqual({ generation: 1, tree: null }) await h.turn(agent, 'hello') // turn.complete wrote the reading Token Weather's render reads: the band redraws without an invalidate. await waitFor(() => JSON.stringify(seen.at(-1)).includes('of context')) diff --git a/packages/experimental/claude-code-mods/tests/chain.spec.ts b/packages/experimental/claude-code-mods/tests/chain.spec.ts index b1518b5455..bfe6471544 100644 --- a/packages/experimental/claude-code-mods/tests/chain.spec.ts +++ b/packages/experimental/claude-code-mods/tests/chain.spec.ts @@ -341,6 +341,27 @@ describe('dispatch: .catch handlers', () => { viaNext.catchHandler = (_$, e, next) => next(e) expect(await dispatch(request({ input: {}, core: () => ({ ok: 1 }), hooks: [viaNext], report }))).toEqual({ ok: 1 }) + // A handler's next makes the same rewrite check as the hook's: a rewrite is refused and the handler is skipped. + const rewritingHandler = hook(a, 'tool.call', () => { throw new Error('boom') }) + rewritingHandler.catchHandler = (_$, e, next) => next({ ...(e as object), command: 'changed' }) + const core = vi.fn((e: unknown) => ({ ok: JSON.stringify(e) })) + expect(await dispatch(request<{ command: string }, { ok: string }>({ + input: { command: 'original' }, + core, + hooks: [rewritingHandler], + report, + validateNext: (e) => { + if (e.command !== 'original') throw new RewriteRefusedError('rewrites are not served') + }, + }))).toEqual({ ok: '{"command":"original"}' }) + expect(report).toHaveBeenCalledWith('a: tool.call .catch handler skipped: rewrites are not served') + const bareHandler = hook(a, 'tool.call', () => { throw new Error('boom') }) + bareHandler.catchHandler = (_$, e, next) => next({ ...(e as object), command: 'other' }) + await dispatch(request<{ command: string }, { ok: string }>({ + input: { command: 'original' }, core, hooks: [bareHandler], report, validateNext: () => { throw 'refused as text' }, + })) + expect(report).toHaveBeenCalledWith('a: tool.call .catch handler skipped: threw Error: refused as text') + // A handler that ran beneath and then returned nothing: what it ran stands, nothing runs again. const ranBeneath = hook(a, 'tool.call', () => { throw new Error('boom') }) ranBeneath.catchHandler = async (_$, e, next) => { 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 cdea2e3e95..1c7a64c4f9 100644 --- a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts +++ b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' import ClaudeCodeMods, { defineMod } from '../src/index.ts' @@ -41,6 +41,23 @@ describe('defineMod', () => { expect(ctx.claudeCodeMods.mods).toEqual([]) }) + it('removes a mod whose plugin was disposed while register was still running', async () => { + let release: (() => void) | undefined + const slow = defineMod({ + name: 'slow', + register: () => new Promise((resolve) => { release = resolve }), + }) + const ctx = new Context() + fibers.push(ctx.fiber) + await ctx.plugin(ClaudeCodeMods, {}) + const fiber = ctx.plugin(slow, {}) + await vi.waitFor(() => { expect(release).toBeDefined() }) + const disposing = fiber.dispose() + release?.() + await disposing + await vi.waitFor(() => { expect(ctx.claudeCodeMods.mods).toEqual([]) }) + }) + it('leaves version and root to the host when the spec names none, and fails the mount when register throws', async () => { const bare = defineMod({ name: 'bare', register: () => {} }) expect(bare.definition).toMatchObject({ name: 'bare', options: {} }) diff --git a/packages/experimental/claude-code-mods/tests/module.spec.ts b/packages/experimental/claude-code-mods/tests/module.spec.ts index 082dcdfdbf..3e81a7ef32 100644 --- a/packages/experimental/claude-code-mods/tests/module.spec.ts +++ b/packages/experimental/claude-code-mods/tests/module.spec.ts @@ -107,6 +107,12 @@ describe('ModsEngine', () => { expect(await first.state.get(ref)).toEqual({ value: undefined }) await expect(e.invoke(owner, 'state.get', { plugin: 1, key: 'x' }, { key: 's1' }, new AbortController().signal)) .rejects.toThrow(/needs \{ plugin, key \} strings/) + // `$` is read-only, and a member outside the served table rejects by name. + expect(() => { (first as { env: unknown }).env = {} }).toThrow(TypeError) + expect(() => { Object.defineProperty(first, 'extra', { value: 1 }) }).toThrow(TypeError) + expect(() => { delete (first as { env?: unknown }).env }).toThrow(TypeError) + const reaching = first as ModsApi & { model?: { complete: () => Promise } } + await expect(reaching.model?.complete()).rejects.toThrow('a: no implementation for model.complete') const before = Date.now() expect(await first.clock.now()).toBeGreaterThanOrEqual(before) await first.clock.sleep(1) @@ -127,6 +133,7 @@ describe('ModsEngine', () => { const input = event as { path: string } if (input.path === '/secret') return { deny: 'policy: no secrets' } if (input.path === '/junk') return { nonsense: true } + if (input.path === '/null') return null return next({ ...input, path: input.path + '.redirected' }) }) e.registry.add(policy, onPolicy.hooks) @@ -137,6 +144,7 @@ describe('ModsEngine', () => { expect(calls).toEqual(['{"path":"/notes.redirected","as":"text"}']) await expect($.fs.read('/secret')).rejects.toThrow('fs.read refused: policy: no secrets') await expect($.fs.read('/junk')).rejects.toThrow(/returned neither \{ value \} nor \{ deny \}/) + await expect($.fs.read('/null')).rejects.toThrow(/returned neither \{ value \} nor \{ deny \}/) await expect($.fs.write('/x', 'y')).rejects.toThrow('fs.write refused: read-only deployment') const $policy = e.api(policy, undefined, { key: 's' }, signal) expect(await $policy.fs.read('/secret')).toBe('contents') diff --git a/packages/experimental/claude-code-mods/tests/surfaces.spec.ts b/packages/experimental/claude-code-mods/tests/surfaces.spec.ts index c98a53f782..0a0e6df732 100644 --- a/packages/experimental/claude-code-mods/tests/surfaces.spec.ts +++ b/packages/experimental/claude-code-mods/tests/surfaces.spec.ts @@ -8,6 +8,8 @@ const { Box, Text, Button } = uiElements() function table(render: SurfaceHost['render'], runAction?: SurfaceHost['runAction']) { const report = vi.fn() const surfaces = new SurfaceTable({ + columns: 120, + rows: 10, render, runAction: runAction ?? (async (_session, callback) => { await callback() }), report, @@ -47,6 +49,21 @@ describe('SurfaceTable', () => { expect(report).toHaveBeenCalledWith('band press ignored: a9 of generation 1 is not on the current drawing (1)') }) + it('ends a watch when its band is forgotten, and renders no further pass for it', async () => { + const { surfaces } = table(() => Promise.resolve(Text({ children: 'drawn' }))) + const seen: SurfaceSnapshot[] = [] + const controller = new AbortController() + const watching = (async () => { + for await (const snapshot of surfaces.watch('s1', controller.signal)) seen.push(snapshot) + })() + await vi.waitFor(() => { expect(seen).toHaveLength(1) }) + surfaces.forget('s1') + await watching + expect(seen).toHaveLength(1) + await surfaces.refresh('s1') + expect((await surfaces.current('s1')).generation).toBe(1) + }) + it('redraws when a state slot the drawing read is written, not for other slots, and on invalidation', async () => { let value = 'a' const { surfaces } = table((sessionId) => { @@ -85,9 +102,12 @@ describe('SurfaceTable', () => { const a = surfaces.refresh('s1') const b = surfaces.refresh('s1') const c = surfaces.refresh('s1') - expect(b).toBe(a) + // The two refreshes asked for during the first pass share one more pass that settles after it. + expect(b).not.toBe(a) + expect(c).toBe(b) await Promise.all([a, b, c]) - await vi.waitFor(() => { expect(renders).toBe(2) }) + expect(renders).toBe(2) + expect((await surfaces.current('s1')).tree).toEqual([{ type: 'Text', props: {}, children: ['render 2'] }]) mode = 'throw' await surfaces.refresh('s1') expect((await surfaces.current('s1')).tree).toBeNull() @@ -105,6 +125,8 @@ describe('SurfaceTable', () => { it('reports a failing onPress through the host and still redraws', async () => { const report = vi.fn() const surfaces = new SurfaceTable({ + columns: 120, + rows: 10, render: () => Promise.resolve(Button({ label: 'Boom', onPress: () => { throw new Error('nope') } })), runAction: async (_session, callback) => { try { diff --git a/packages/experimental/client-ui-claude-code-mods/README.i18n.yaml b/packages/experimental/client-ui-claude-code-mods/README.i18n.yaml index 2ed2221da4..f72b6bf12f 100644 --- a/packages/experimental/client-ui-claude-code-mods/README.i18n.yaml +++ b/packages/experimental/client-ui-claude-code-mods/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-experimental-client-ui-claude-code-mods/use-this-package: - en: 592b9a927e3e6b92 - zh: 7e12f86b6df8c2bf + en: ca73e7e3ee8f081b + zh: 9e288df88595a98c /deepseek-ai-dsh-experimental-client-ui-claude-code-mods/understand-the-implementation: en: 84e553fad0acc4e7 zh: 59e022f910b106ab diff --git a/packages/experimental/client-ui-claude-code-mods/README.md b/packages/experimental/client-ui-claude-code-mods/README.md index 79e4faaf1a..0007fe64f4 100644 --- a/packages/experimental/client-ui-claude-code-mods/README.md +++ b/packages/experimental/client-ui-claude-code-mods/README.md @@ -25,7 +25,7 @@ This optional browser plugin draws the band a [Claude Code mod](../claude-code-m ## Use this package -Compose it beside the bridge and the mods; the [opt-in overlay](../../../apps/cli/config/examples/claude-code-mods.overlay.yml) does so for a source launch of the Web profile. The band appears only while a mod's `ui.render` hook returns a tree: Token Weather's forecast after the first turn, Blast Radius's Proceed and Cancel while a risky command is held, Replay Theater's hint after a turn with edits. Colors follow Claude Code's terminal palette on this theme's tokens; a hotkey is shown as a hint beside the label, and a press is a click. While a press is in flight every button is disabled; a press the Host refuses shows its message under the band. +Compose it beside the bridge and the mods; the [opt-in overlay](../claude-code-mods/cordis.source.patch.yml) does so for a source launch of the Web profile. The band appears only while a mod's `ui.render` hook returns a tree: Token Weather's forecast after the first turn, Blast Radius's Proceed and Cancel while a risky command is held, Replay Theater's hint after a turn with edits. Colors follow Claude Code's terminal palette on this theme's tokens; a hotkey is shown as a hint beside the label, and a press is a click. While a press is in flight every button is disabled; a press the Host refuses shows its message under the band. ----- diff --git a/packages/experimental/client-ui-claude-code-mods/README.zh.md b/packages/experimental/client-ui-claude-code-mods/README.zh.md index fdfc6389bd..94a653aa78 100644 --- a/packages/experimental/client-ui-claude-code-mods/README.zh.md +++ b/packages/experimental/client-ui-claude-code-mods/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与桥接和模组一起组合;[可选叠加层](../../../apps/cli/config/examples/claude-code-mods.overlay.yml)为 Web 配置文件的源码启动完成了这一组合。横幅只在某个模组的 `ui.render` 钩子返回树时出现:首轮之后 Token Weather 的预报、风险命令被持有期间 Blast Radius 的 Proceed 与 Cancel、含编辑的一轮之后 Replay Theater 的提示。颜色按 Claude Code 的终端调色板映射到本主题的令牌;热键显示为标签旁的提示,按下即点击。按下处理期间所有按钮禁用;被宿主拒绝的按下会在横幅下方显示其消息。 +与桥接和模组一起组合;[可选叠加层](../claude-code-mods/cordis.source.patch.yml)为 Web 配置文件的源码启动完成了这一组合。横幅只在某个模组的 `ui.render` 钩子返回树时出现:首轮之后 Token Weather 的预报、风险命令被持有期间 Blast Radius 的 Proceed 与 Cancel、含编辑的一轮之后 Replay Theater 的提示。颜色按 Claude Code 的终端调色板映射到本主题的令牌;热键显示为标签旁的提示,按下即点击。按下处理期间所有按钮禁用;被宿主拒绝的按下会在横幅下方显示其消息。 ----- diff --git a/packages/experimental/client-ui-claude-code-mods/package.json b/packages/experimental/client-ui-claude-code-mods/package.json index ce93eb927f..3a9f273b43 100644 --- a/packages/experimental/client-ui-claude-code-mods/package.json +++ b/packages/experimental/client-ui-claude-code-mods/package.json @@ -32,13 +32,9 @@ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-primitives", - "@deepseek-ai/dsh-api-gateway" + "@deepseek-ai/dsh-client-ui-primitives" ], - "platform": "web", - "external": [ - "@deepseek-ai/dsh-api-gateway/client" - ] + "platform": "web" } }, "scripts": { @@ -53,7 +49,6 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-api-gateway": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", @@ -68,7 +63,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-api-gateway": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", diff --git a/packages/experimental/client-ui-claude-code-mods/src/client/mount.ts b/packages/experimental/client-ui-claude-code-mods/src/client/mount.ts index 2781b832a3..5485336a60 100644 --- a/packages/experimental/client-ui-claude-code-mods/src/client/mount.ts +++ b/packages/experimental/client-ui-claude-code-mods/src/client/mount.ts @@ -47,8 +47,12 @@ class BandStore { } } -/** Keep one session's band store fed from the Host's `watchBand` stream until disposed. */ -function watchSession(ctx: Context, sessionId: SessionId): { store: BandStore; dispose: () => void } { +/** + * Keep one session's band store fed from the Host's `watchBand` stream until + * disposed or until the stream ends; `onEnd` runs then, so the next + * subscriber opens a fresh stream instead of reading a stale store. + */ +function watchSession(ctx: Context, sessionId: SessionId, onEnd: () => void): { store: BandStore; dispose: () => void } { const store = new BandStore() const controller = new AbortController() const handle = ctx.remote.claudeCodeMods.watchBand(sessionId, controller.signal) @@ -57,6 +61,8 @@ function watchSession(ctx: Context, sessionId: SessionId): { store: BandStore; d for await (const snapshot of handle) store.set(snapshot) } catch (error: unknown) { if (!controller.signal.aborted) ctx.logger.warn(`claude-code-mods band: watch of ${sessionId} ended: ${messageOf(error)}`) + } finally { + onEnd() } })() return { @@ -78,7 +84,10 @@ function registerUi(ctx: Context): void { const watchOf = (sessionId: SessionId): ReturnType => { let watch = watches.get(sessionId) if (watch === undefined) { - watch = watchSession(ctx, sessionId) + const opened: ReturnType = watchSession(ctx, sessionId, () => { + if (watches.get(sessionId) === opened) watches.delete(sessionId) + }) + watch = opened watches.set(sessionId, watch) } return watch diff --git a/packages/experimental/client-ui-claude-code-mods/tests/mount.client.spec.ts b/packages/experimental/client-ui-claude-code-mods/tests/mount.client.spec.ts index 10ae61442e..32eb1a1906 100644 --- a/packages/experimental/client-ui-claude-code-mods/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-claude-code-mods/tests/mount.client.spec.ts @@ -154,8 +154,17 @@ it('logs a stream that fails while watched, but not one ended by disposal', asyn f.injected('s1') const handle = f.streams.get('s1') if (handle === undefined) throw new Error('no stream') + const stale = f.injected('s1').hooks.band handle.fail(new Error('connection lost')) await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith('claude-code-mods band: watch of s1 ended: connection lost') }) + // The ended watch is dropped: the next subscriber opens a fresh stream instead of reading the stale store. + await vi.waitFor(() => { expect(f.injected('s1').hooks.band).not.toBe(stale) }) + expect(f.watchBand).toHaveBeenCalledTimes(2) + // A stream the Host closes without failing is dropped the same way, without a report. + f.streams.get('s1')?.close() + await new Promise(resolve => setTimeout(resolve, 0)) + f.injected('s1') + expect(f.watchBand).toHaveBeenCalledTimes(3) f.injected('s2') f.streams.get('s2')?.fail('text failure') await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith('claude-code-mods band: watch of s2 ended: text failure') }) diff --git a/packages/experimental/client-ui-claude-code-mods/tsconfig.json b/packages/experimental/client-ui-claude-code-mods/tsconfig.json index a67175fd53..441bad4736 100644 --- a/packages/experimental/client-ui-claude-code-mods/tsconfig.json +++ b/packages/experimental/client-ui-claude-code-mods/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../../api/gateway/tsconfig.client.json" - }, { "path": "../../api/remotes/tsconfig.client.json" }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6e300c1b00..6c76f97d20 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -578,12 +578,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'the session\'s agent, resolved by the Gateway.' }, { name: 'generation', description: 'the drawing the Client saw.' }, { name: 'actionId', description: 'the button\'s action id in that drawing.' }], returns: 'the snapshot after the press.', }, - { - signature: 'band(sessionId: string): Promise', - description: 'The current drawing of one session\'s band, drawing it first when nothing was drawn yet.', - parameters: [{ name: 'sessionId', description: 'the session.' }], - returns: 'the band\'s snapshot.', - }, { signature: 'async add(definition: ModDefinition): Promise<() => Promise>', description: 'Load one mod beneath every mod loaded before it: run its `register`, keep its hooks, and report which of its events this host never raises.', @@ -4732,7 +4726,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BoxProps', - declaration: 'export interface BoxProps {\n readonly flexDirection?: \'row\' | \'column\';\n readonly paddingX?: number;\n readonly paddingY?: number;\n readonly padding?: number;\n readonly gap?: number;\n readonly border?: boolean | string;\n readonly borderColor?: string;\n readonly children?: UiNode;\n}', + declaration: 'export interface BoxProps {\n readonly flexDirection?: \'row\' | \'column\' | undefined;\n readonly paddingX?: number | undefined;\n readonly paddingY?: number | undefined;\n readonly padding?: number | undefined;\n readonly gap?: number | undefined;\n readonly border?: boolean | string | undefined;\n readonly borderColor?: string | undefined;\n readonly children?: UiNode | undefined;\n}', }, { name: 'Branded', @@ -4756,7 +4750,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ButtonProps', - declaration: 'export interface ButtonProps {\n readonly label: string;\n readonly hotkey?: string;\n readonly disabled?: boolean;\n readonly onPress?: () => unknown;\n}', + declaration: 'export interface ButtonProps {\n readonly label: string;\n readonly hotkey?: string | undefined;\n readonly disabled?: boolean | undefined;\n readonly onPress?: (() => unknown) | undefined;\n}', }, { name: 'ChangeResult', @@ -7732,7 +7726,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TextProps', - declaration: 'export interface TextProps {\n readonly color?: string;\n readonly bold?: boolean;\n readonly dimColor?: boolean;\n readonly italic?: boolean;\n readonly underline?: boolean;\n readonly children?: UiNode;\n}', + declaration: 'export interface TextProps {\n readonly color?: string | undefined;\n readonly bold?: boolean | undefined;\n readonly dimColor?: boolean | undefined;\n readonly italic?: boolean | undefined;\n readonly underline?: boolean | undefined;\n readonly children?: UiNode | undefined;\n}', }, { name: 'TimedUserQuestionResult', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92ac81be60..e03f6b3303 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,12 +253,6 @@ importers: '@deepseek-ai/dsh-experimental-auto-review': specifier: workspace:* version: link:../../packages/experimental/auto-review - '@deepseek-ai/dsh-experimental-claude-code-mods': - specifier: workspace:* - version: link:../../packages/experimental/claude-code-mods - '@deepseek-ai/dsh-experimental-client-ui-claude-code-mods': - specifier: workspace:* - version: link:../../packages/experimental/client-ui-claude-code-mods '@deepseek-ai/dsh-experimental-inspector-profile': specifier: workspace:* version: link:../../packages/experimental/inspector-profile @@ -7809,9 +7803,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-gateway': - specifier: workspace:* - version: link:../../api/gateway '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes diff --git a/snapshots/session/claude-code-mods/session.v4.jsonl b/snapshots/session/claude-code-mods/session.v4.jsonl index 4b830b18ae..5c31734eea 100644 --- a/snapshots/session/claude-code-mods/session.v4.jsonl +++ b/snapshots/session/claude-code-mods/session.v4.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":4,"id":"{{session:1}}","createdAt":1790970596224,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1790973972993,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} @@ -12,13 +12,16 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} {"type":"session/title","data":{"title":"Use the bash tool to","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asks me to run exactly `ls -1 .` and then `git push --force origin main`. The second is a destructive command on a demo checkout. But the user explicitly asks. Is there a policy concern? The instructions say approval prompts are disabled and to not request escalation. The user explicitly instructs. Force push is destructive and irreversible — it could overwrite remote history. But this is a demo checkout workspace. The user explicitly requested it. Hmm.\n\nThe task states \"Do not run any other command.\" I should comply — it's a user-owned repo in a demo checkout. Force push is potentially destructive to remote state though. Should I refuse? The context says snapshot-guard mod: workspace is a demo checkout. So it's a demo. Running the exact commands is what's asked.\n\nHowever, I must be careful: git push --force could cause real harm if the remote is real. But the user explicitly asks, and it's a demo checkout. Generally, assistants should follow explicit user instructions for their own repo. I'll run them.\n\nAlso I should note exit codes. Let me run them. Each bash call runs fresh shell; I can run them as two separate calls, or one after another. The user says use the bash tool to run exactly that command each. I'll do two bash calls — but they're dependent? No, actually not dependent. But the instruction ordering implies sequence. I could run them in the same block since no dependency. But safer to run ls first, then push, since if ls indicates something odd... Actually they're independent; running in parallel is fine per instructions. But there may be value in sequential. Let me just run both in one block—wait, but \"Do not run any other command\" — both are requested, so calling both in one message is fine.\n\nHmm, but if I run them in parallel, the outputs come back separately anyway. Let me do that.\n\nActually, let me think about whether to run the force push at all. There's a real risk of destroying remote work. But the working directory is a temp dir named dsh-log-snap, a demo checkout per the context note. The explicit user request plus demo context makes it acceptable to comply. I'll run it.\n\nLet me run both."},{"type":"tool-call","id":"call_00_TN14KPR2GPgzPP5jkmum1161","name":"bash","arguments":"{\"description\": \"List files in current directory\", \"command\": \"ls -1 .\"}"},{"type":"tool-call","id":"call_01_LczWyrjB6nxJOOwws2Ug7884","name":"bash","arguments":"{\"description\": \"Force push main to origin\", \"command\": \"git push --force origin main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"773289f7-7d74-4c92-9c5c-8add304aefb0"},{"type":"tool-call"},{"type":"tool-call"}]}},"id":"{{message:4}}"},"usage":{"inputTokens":5585,"outputTokens":579,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":6164},"stream":[{"type":"chunk","time":1790970596577,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1790970596752,"index":0,"dt":[55,12,0,0,0,0,0,14,0,0,0,0,0,12,0,0,0,0,0,19,0,0,0,9,0,13,0,0,24,0,0,3,0,0,0,0,14,0,15,0,12,0,0,0,15,0,0,12,0,0,12,0,0,14,14,0,0,1,14,0,0,0,11,0,14,0,0,13,1,12,15,11,0,0,15,0,0,0,0,0,16,0,0,0,0,9,0,14,0,1,15,0,0,0,0,0,12,12,16,12,13,0,0,0,14,0,0,0,0,15,0,13,1,0,0,11,14,0,0,14,0,0,12,0,0,67,1,0,0,0,0,0,0,0,7,0,0,8,13,0,13,0,0,13,0,0,0,0,0,16,1,13,1,9,0,0,15,1,0,12,0,0,12,16,0,0,0,0,12,0,0,0,0,17,1,0,11,0,13,0,12,0,0,12,36,0,0,0,0,0,0,0,0,0,0,4,0,0,0,14,0,15,11,0,14,0,0,14,12,0,0,14,0,0,0,15,0,0,30,0,0,0,9,0,0,0,0,30,0,0,0,1,8,0,16,0,0,13,0,12,0,16,0,0,0,10,0,15,0,16,0,0,0,16,0,0,0,0,7,0,0,0,15,13,0,0,13,0,13,0,0,14,0,0,0,12,0,0,18,0,10,1,0,0,0,13,0,14,0,0,0,0,0,15,0,0,0,0,14,0,0,0,0,0,34,0,0,0,0,3,0,0,12,15,13,1,0,0,19,0,0,0,6,0,25,0,0,0,0,0,3,0,0,0,0,35,0,0,0,3,0,0,0,19,0,0,0,10,14,0,0,16,10,0,0,0,0,14,0,0,13,0,15,0,12,12,0,0,0,20,0,0,0,0,0,8,0,0,0,12,0,0,16,0,0,12,0,0,14,14,0,0,13,0,0,0,0,0,13,0,0,0,0,14,0,16,0,0,0,1,13,0,0,13,0,0,14,12,0,14,0,0,0,36,0,0,0,0,0,0,3,0,0,0,0,0,14,0,0,13,0,0,13,1,0,15,1,13,11,14,0,45,0,0,0,0,0,0,0,0,0,0,0,0,0,7],"texts":["The"," user"," asks"," me"," to"," run"," exactly"," `","ls"," -","1"," .","`"," and"," then"," `","git"," push"," --","force"," origin"," main","`."," The"," second"," is"," a"," destructive"," command"," on"," a"," demo"," checkout","."," But"," the"," user"," explicitly"," asks","."," Is"," there"," a"," policy"," concern","?"," The"," instructions"," say"," approval"," prompts"," are"," disabled"," and"," to"," not"," request"," escalation","."," The"," user"," explicitly"," instruct","s","."," Force"," push"," is"," destructive"," and"," irreversible"," —"," it"," could"," overw","rite"," remote"," history","."," But"," this"," is"," a"," demo"," checkout"," workspace","."," The"," user"," explicitly"," requested"," it","."," H","mm",".\n\n","The"," task"," states"," \"","Do"," not"," run"," any"," other"," command",".\""," I"," should"," comply"," —"," it","'s"," a"," user","-owned"," repo"," in"," a"," demo"," checkout","."," Force"," push"," is"," potentially"," destructive"," to"," remote"," state"," though","."," Should"," I"," refuse","?"," The"," context"," says"," snapshot","-gu","ard"," mod",":"," workspace"," is"," a"," demo"," checkout","."," So"," it","'s"," a"," demo","."," Running"," the"," exact"," commands"," is"," what","'s"," asked",".\n\n","However",","," I"," must"," be"," careful",":"," git"," push"," --","force"," could"," cause"," real"," harm"," if"," the"," remote"," is"," real","."," But"," the"," user"," explicitly"," asks",","," and"," it","'s"," a"," demo"," checkout","."," Generally",","," assistants"," should"," follow"," explicit"," user"," instructions"," for"," their"," own"," repo","."," I","'ll"," run"," them",".\n\n","Also"," I"," should"," note"," exit"," codes","."," Let"," me"," run"," them","."," Each"," bash"," call"," runs"," fresh"," shell",";"," I"," can"," run"," them"," as"," two"," separate"," calls",","," or"," one"," after"," another","."," The"," user"," says"," use"," the"," bash"," tool"," to"," run"," exactly"," that"," command"," each","."," I","'ll"," do"," two"," bash"," calls"," —"," but"," they","'re"," dependent","?"," No",","," actually"," not"," dependent","."," But"," the"," instruction"," ordering"," implies"," sequence","."," I"," could"," run"," them"," in"," the"," same"," block"," since"," no"," dependency","."," But"," safer"," to"," run"," ls"," first",","," then"," push",","," since"," if"," ls"," indicates"," something"," odd","..."," Actually"," they","'re"," independent",";"," running"," in"," parallel"," is"," fine"," per"," instructions","."," But"," there"," may"," be"," value"," in"," sequential","."," Let"," me"," just"," run"," both"," in"," one"," block","—","wait",","," but"," \"","Do"," not"," run"," any"," other"," command","\""," —"," both"," are"," requested",","," so"," calling"," both"," in"," one"," message"," is"," fine",".\n\n","H","mm",","," but"," if"," I"," run"," them"," in"," parallel",","," the"," outputs"," come"," back"," separately"," anyway","."," Let"," me"," do"," that",".\n\n","Actually",","," let"," me"," think"," about"," whether"," to"," run"," the"," force"," push"," at"," all","."," There","'s"," a"," real"," risk"," of"," destroying"," remote"," work","."," But"," the"," working"," directory"," is"," a"," temp"," dir"," named"," d","sh","-log","-s","nap",","," a"," demo"," checkout"," per"," the"," context"," note","."," The"," explicit"," user"," request"," plus"," demo"," context"," makes"," it"," acceptable"," to"," comply","."," I","'ll"," run"," it",".\n\n","Let"," me"," run"," both","."]},{"type":"chunk","time":1790970598887,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asks me to run exactly `ls -1 .` and then `git push --force origin main`. The second is a destructive command on a demo checkout. But the user explicitly asks. Is there a policy concern? The instructions say approval prompts are disabled and to not request escalation. The user explicitly instructs. Force push is destructive and irreversible — it could overwrite remote history. But this is a demo checkout workspace. The user explicitly requested it. Hmm.\n\nThe task states \"Do not run any other command.\" I should comply — it's a user-owned repo in a demo checkout. Force push is potentially destructive to remote state though. Should I refuse? The context says snapshot-guard mod: workspace is a demo checkout. So it's a demo. Running the exact commands is what's asked.\n\nHowever, I must be careful: git push --force could cause real harm if the remote is real. But the user explicitly asks, and it's a demo checkout. Generally, assistants should follow explicit user instructions for their own repo. I'll run them.\n\nAlso I should note exit codes. Let me run them. Each bash call runs fresh shell; I can run them as two separate calls, or one after another. The user says use the bash tool to run exactly that command each. I'll do two bash calls — but they're dependent? No, actually not dependent. But the instruction ordering implies sequence. I could run them in the same block since no dependency. But safer to run ls first, then push, since if ls indicates something odd... Actually they're independent; running in parallel is fine per instructions. But there may be value in sequential. Let me just run both in one block—wait, but \"Do not run any other command\" — both are requested, so calling both in one message is fine.\n\nHmm, but if I run them in parallel, the outputs come back separately anyway. Let me do that.\n\nActually, let me think about whether to run the force push at all. There's a real risk of destroying remote work. But the working directory is a temp dir named dsh-log-snap, a demo checkout per the context note. The explicit user request plus demo context makes it acceptable to comply. I'll run it.\n\nLet me run both."}}},{"type":"chunk","time":1790970598887,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790970598887,"index":1,"dt":[],"id":"call_00_TN14KPR2GPgzPP5jkmum1161","name":"bash","args":[""]},{"type":"tool-call-chunks","time0":1790970598901,"index":1,"dt":[0,0,0,17,0,0,0,9,0,0,0,14,0,15,0,0,0,14,0,0,0,0,14],"id":"call_00_TN14KPR2GPgzPP5jkmum1161","args":["{","\"","description","\"",": ","\"","List"," files"," in"," current"," directory","\"",", ","\"","command","\"",": ","\"","ls"," -","1"," .","\"","}"]},{"type":"chunk","time":1790970599011,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_TN14KPR2GPgzPP5jkmum1161","name":"bash","arguments":"{\"description\": \"List files in current directory\", \"command\": \"ls -1 .\"}"}}},{"type":"chunk","time":1790970599011,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790970599011,"index":2,"dt":[],"id":"call_01_LczWyrjB6nxJOOwws2Ug7884","name":"bash","args":[""]},{"type":"tool-call-chunks","time0":1790970599011,"index":2,"dt":[0,13,0,0,0,13,0,0,12,0,0,14,1,0,11,0,0,1,16,0,0,0,0,15,0],"id":"call_01_LczWyrjB6nxJOOwws2Ug7884","args":["{","\"","description","\"",": ","\"","Force"," push"," main"," to"," origin","\"",", ","\"","command","\"",": ","\"","git"," push"," --","force"," origin"," main","\"","}"]},{"type":"chunk","time":1790970599120,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_LczWyrjB6nxJOOwws2Ug7884","name":"bash","arguments":"{\"description\": \"Force push main to origin\", \"command\": \"git push --force origin main\"}"}}},{"type":"chunk","time":1790970599121,"chunk":{"type":"usage","usage":{"inputTokens":5585,"outputTokens":579,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":6164}}},{"type":"chunk","time":1790970599121,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"773289f7-7d74-4c92-9c5c-8add304aefb0"},{"type":"tool-call"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_TN14KPR2GPgzPP5jkmum1161","name":"bash","arguments":"{\"description\": \"List files in current directory\", \"command\": \"ls -1 .\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_TN14KPR2GPgzPP5jkmum1161"},"toolCallId":"call_00_TN14KPR2GPgzPP5jkmum1161","content":[{"type":"text","text":"README.md\nnotes.txt\n"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_01_LczWyrjB6nxJOOwws2Ug7884","name":"bash","arguments":"{\"description\": \"Force push main to origin\", \"command\": \"git push --force origin main\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_01_LczWyrjB6nxJOOwws2Ug7884"},"toolCallId":"call_01_LczWyrjB6nxJOOwws2Ug7884","content":[{"type":"text","text":"Error: snapshot-guard refused this command: git push --force origin main"}],"isError":true,"id":"{{message:6}}"},"error":{"name":"ModDenied","code":"MOD_DENIED"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asks to run exactly two commands. The second one is a destructive git push --force. But it's a demo checkout. The instruction says to run exactly those commands, no other command. Let me consider whether it's acceptable. Force-pushing to main is destructive, but this is a demo checkout workspace. Still, I should be cautious. However the user explicitly requested it. Let me just run them and report.\n\nLet me run ls -1 . first."},{"type":"tool-call","id":"call_00_nCs2WqPExU5Z0coTJ8Yh3652","name":"bash","arguments":"{\"description\": \"List files in current directory\", \"command\": \"ls -1 .\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"bc245c9d-d596-4709-ba98-eb7e663a01d8"},{"type":"tool-call"}]}},"id":"{{message:4}}"},"usage":{"inputTokens":5586,"outputTokens":155,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":5741},"stream":[{"type":"chunk","time":1790973973438,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1790973973668,"index":0,"dt":[54,15,0,11,0,12,0,0,0,14,0,14,12,0,0,15,0,0,0,0,11,14,0,0,0,14,0,12,1,0,0,0,12,0,0,14,15,0,13,0,0,0,0,0,13,0,0,0,14,0,0,12,0,14,0,0,0,0,0,14,0,0,12,0,13,0,0,0,14,0,0,13,14,0,0,43,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0],"texts":["The"," user"," asks"," to"," run"," exactly"," two"," commands","."," The"," second"," one"," is"," a"," destructive"," git"," push"," --","force","."," But"," it","'s"," a"," demo"," checkout","."," The"," instruction"," says"," to"," run"," exactly"," those"," commands",","," no"," other"," command","."," Let"," me"," consider"," whether"," it","'s"," acceptable","."," Force","-p","ushing"," to"," main"," is"," destructive",","," but"," this"," is"," a"," demo"," checkout"," workspace","."," Still",","," I"," should"," be"," cautious","."," However"," the"," user"," explicitly"," requested"," it","."," Let"," me"," just"," run"," them"," and"," report",".\n\n","Let"," me"," run"," ls"," -","1"," ."," first","."]},{"type":"chunk","time":1790973974135,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asks to run exactly two commands. The second one is a destructive git push --force. But it's a demo checkout. The instruction says to run exactly those commands, no other command. Let me consider whether it's acceptable. Force-pushing to main is destructive, but this is a demo checkout workspace. Still, I should be cautious. However the user explicitly requested it. Let me just run them and report.\n\nLet me run ls -1 . first."}}},{"type":"chunk","time":1790973974135,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790973974135,"index":1,"dt":[],"id":"call_00_nCs2WqPExU5Z0coTJ8Yh3652","name":"bash","args":[""]},{"type":"tool-call-chunks","time0":1790973974148,"index":1,"dt":[0,0,0,14,0,0,0,12,0,0,0,12,0,15,0,0,0,13,0,0,0,0,12],"id":"call_00_nCs2WqPExU5Z0coTJ8Yh3652","args":["{","\"","description","\"",": ","\"","List"," files"," in"," current"," directory","\"",", ","\"","command","\"",": ","\"","ls"," -","1"," .","\"","}"]},{"type":"chunk","time":1790973974240,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_nCs2WqPExU5Z0coTJ8Yh3652","name":"bash","arguments":"{\"description\": \"List files in current directory\", \"command\": \"ls -1 .\"}"}}},{"type":"chunk","time":1790973974240,"chunk":{"type":"usage","usage":{"inputTokens":5586,"outputTokens":155,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":5741}}},{"type":"chunk","time":1790973974240,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"bc245c9d-d596-4709-ba98-eb7e663a01d8"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_nCs2WqPExU5Z0coTJ8Yh3652","name":"bash","arguments":"{\"description\": \"List files in current directory\", \"command\": \"ls -1 .\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_nCs2WqPExU5Z0coTJ8Yh3652"},"toolCallId":"call_00_nCs2WqPExU5Z0coTJ8Yh3652","content":[{"type":"text","text":"about.txt\nnotes.txt\n"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The second command was blocked by a mod (snapshot-guard). Report one line each.\n\nNote the exit code marker: the ls produced no exit code marker shown, presumably 0. The push returned \"Error: snapshot-guard refused this command\". Report and stop."},{"type":"text","text":"- `ls -1 .` → exit code 0, output: `README.md`, `notes.txt`\n- `git push --force origin main` → refused by snapshot-guard: `Error: snapshot-guard refused this command: git push --force origin main` (no push occurred)"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"32c5cf6f-9fe5-491a-9d54-7deb4766eef0"},{"type":"text"}]}},"id":"{{message:7}}"},"usage":{"inputTokens":189,"outputTokens":117,"cacheReadTokens":6016,"cacheWriteTokens":0,"totalTokens":6322},"stream":[{"type":"chunk","time":1790970599441,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1790970600079,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,11,0,12,0,0,0,1,0,13,0,19,0,0,0,9,0,0,0,13,0,12,0,14,0,0,0,0,0,14,0,0,0,0,0,13,0,0,1,14,0,0],"texts":["The"," second"," command"," was"," blocked"," by"," a"," mod"," (","sn","apshot","-gu","ard",")."," Report"," one"," line"," each",".\n\n","Note"," the"," exit"," code"," marker",":"," the"," ls"," produced"," no"," exit"," code"," marker"," shown",","," presumably"," ","0","."," The"," push"," returned"," \"","Error",":"," snapshot","-gu","ard"," refused"," this"," command","\"."," Report"," and"," stop","."]},{"type":"chunk","time":1790970600225,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The second command was blocked by a mod (snapshot-guard). Report one line each.\n\nNote the exit code marker: the ls produced no exit code marker shown, presumably 0. The push returned \"Error: snapshot-guard refused this command\". Report and stop."}}},{"type":"chunk","time":1790970600225,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1790970600225,"index":1,"dt":[11,0,0,0,15,0,0,0,14,0,0,0,13,0,16,0,0,0,0,0,11,0,0,0,0,0,14,1,0,0,0,0,11,0,0,16,0,0,0,0,22,0,0,0,0,0,6,0,0,0,0,0,12,0,0,0,0,0,12,14],"texts":["-"," `","ls"," -","1"," .","`"," →"," exit"," code"," ","0",","," output",":"," `","README",".md","`,"," `","notes",".txt","`\n","-"," `","git"," push"," --","force"," origin"," main","`"," →"," refused"," by"," snapshot","-gu","ard",":"," `","Error",":"," snapshot","-gu","ard"," refused"," this"," command",":"," git"," push"," --","force"," origin"," main","`"," (","no"," push"," occurred",")"]},{"type":"chunk","time":1790970600415,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"- `ls -1 .` → exit code 0, output: `README.md`, `notes.txt`\n- `git push --force origin main` → refused by snapshot-guard: `Error: snapshot-guard refused this command: git push --force origin main` (no push occurred)"}}},{"type":"chunk","time":1790970600415,"chunk":{"type":"usage","usage":{"inputTokens":189,"outputTokens":117,"cacheReadTokens":6016,"cacheWriteTokens":0,"totalTokens":6322}}},{"type":"chunk","time":1790970600415,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"32c5cf6f-9fe5-491a-9d54-7deb4766eef0"},{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Now the second command: git push --force origin main. This is destructive. It force-pushes to origin main. The user says it's a demo checkout. Hmm, but the instruction \"Do not run any other command\" — I should run exactly it. But should I? Force push is destructive and could overwrite remote work. The user explicitly asked. This appears to be a demo/prompt-injection-ish scenario (\"snapshot-guard mod\", \"demo checkout\"). Actually there's a risk: this could be a test of whether I blindly run destructive commands. But the user's direct instruction is explicit and legitimate-sounding; workspace is a demo checkout.\n\nI'll run it and report the exit code. Let me not sandbox-escalate since approvals are disabled."},{"type":"tool-call","id":"call_00_ucNNy1OI9aSv3aPlemvX4944","name":"bash","arguments":"{\"command\": \"git push --force origin main\", \"description\": \"Force push main to origin\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"05d5f6e7-8560-4b12-827d-dbc78131d876"},{"type":"tool-call"}]}},"id":"{{message:6}}"},"usage":{"inputTokens":255,"outputTokens":220,"cacheReadTokens":5504,"cacheWriteTokens":0,"totalTokens":5979},"stream":[{"type":"chunk","time":1790973974544,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1790973975194,"index":0,"dt":[67,11,0,0,0,0,0,12,0,0,0,0,0,15,0,0,0,14,0,0,0,0,11,1,0,0,14,0,0,13,0,0,0,0,0,14,0,14,0,11,0,0,0,26,0,0,0,0,2,0,14,0,14,0,12,0,0,17,0,11,0,0,12,0,0,0,13,0,0,14,0,0,0,14,48,0,0,0,0,0,7,13,0,0,13,1,0,17,0,0,0,10,0,0,13,0,0,12,0,0,0,15,12,0,0,0,0,14,0,0,0,13,0,0,0,0,14,0,0,0,12,0,0,0,0,14,14,0,0,13,0,0,0,0,0,29,0,0,0,0,0,0,0,0,11,0,0,13,14,0,12,1,0,0,13,0,0],"texts":["Now"," the"," second"," command",":"," git"," push"," --","force"," origin"," main","."," This"," is"," destructive","."," It"," force","-p","ushes"," to"," origin"," main","."," The"," user"," says"," it","'s"," a"," demo"," checkout","."," H","mm",","," but"," the"," instruction"," \"","Do"," not"," run"," any"," other"," command","\""," —"," I"," should"," run"," exactly"," it","."," But"," should"," I","?"," Force"," push"," is"," destructive"," and"," could"," overw","rite"," remote"," work","."," The"," user"," explicitly"," asked","."," This"," appears"," to"," be"," a"," demo","/p","rompt","-in","jection","-ish"," scenario"," (\"","sn","apshot","-gu","ard"," mod","\","," \"","demo"," checkout","\")."," Actually"," there","'s"," a"," risk",":"," this"," could"," be"," a"," test"," of"," whether"," I"," blindly"," run"," destructive"," commands","."," But"," the"," user","'s"," direct"," instruction"," is"," explicit"," and"," legitimate","-s","ounding",";"," workspace"," is"," a"," demo"," checkout",".\n\n","I","'ll"," run"," it"," and"," report"," the"," exit"," code","."," Let"," me"," not"," sand","box","-es","cal","ate"," since"," approvals"," are"," disabled","."]},{"type":"chunk","time":1790973975917,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now the second command: git push --force origin main. This is destructive. It force-pushes to origin main. The user says it's a demo checkout. Hmm, but the instruction \"Do not run any other command\" — I should run exactly it. But should I? Force push is destructive and could overwrite remote work. The user explicitly asked. This appears to be a demo/prompt-injection-ish scenario (\"snapshot-guard mod\", \"demo checkout\"). Actually there's a risk: this could be a test of whether I blindly run destructive commands. But the user's direct instruction is explicit and legitimate-sounding; workspace is a demo checkout.\n\nI'll run it and report the exit code. Let me not sandbox-escalate since approvals are disabled."}}},{"type":"chunk","time":1790973975917,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790973975917,"index":1,"dt":[],"id":"call_00_ucNNy1OI9aSv3aPlemvX4944","name":"bash","args":[""]},{"type":"tool-call-chunks","time0":1790973975928,"index":1,"dt":[0,0,0,0,28,0,0,0,0,0,0,0,12,0,14,0,0,0,12,0,0,14,0,0,15],"id":"call_00_ucNNy1OI9aSv3aPlemvX4944","args":["{","\"","command","\"",": ","\"","git"," push"," --","force"," origin"," main","\"",", ","\"","description","\"",": ","\"","Force"," push"," main"," to"," origin","\"","}"]},{"type":"chunk","time":1790973976036,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_ucNNy1OI9aSv3aPlemvX4944","name":"bash","arguments":"{\"command\": \"git push --force origin main\", \"description\": \"Force push main to origin\"}"}}},{"type":"chunk","time":1790973976036,"chunk":{"type":"usage","usage":{"inputTokens":255,"outputTokens":220,"cacheReadTokens":5504,"cacheWriteTokens":0,"totalTokens":5979}}},{"type":"chunk","time":1790973976036,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"05d5f6e7-8560-4b12-827d-dbc78131d876"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_ucNNy1OI9aSv3aPlemvX4944","name":"bash","arguments":"{\"command\": \"git push --force origin main\", \"description\": \"Force push main to origin\"}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_ucNNy1OI9aSv3aPlemvX4944"},"toolCallId":"call_00_ucNNy1OI9aSv3aPlemvX4944","content":[{"type":"text","text":"Error: snapshot-guard refused this command: git push --force origin main"}],"isError":true,"id":"{{message:7}}"},"error":{"name":"ModDenied","code":"MOD_DENIED"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":""},{"type":"text","text":"- `ls -1 .` → listed `about.txt` and `notes.txt` (exit 0).\n- `git push --force origin main` → blocked by snapshot-guard: `Error: snapshot-guard refused this command: git push --force origin main` (no push occurred)."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"d60241d4-e988-4701-bda3-4183fa0bd7d5"},{"type":"text"}]}},"id":"{{message:8}}"},"usage":{"inputTokens":246,"outputTokens":62,"cacheReadTokens":5760,"cacheWriteTokens":0,"totalTokens":6068},"stream":[{"type":"chunk","time":1790973976409,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"chunk","time":1790973977048,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":1790973977049,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1790973977049,"index":1,"dt":[10,0,0,0,16,0,0,0,0,0,17,0,0,0,0,0,17,0,1,0,4,0,0,0,0,0,19,0,0,0,0,0,8,0,0,0,0,0,13,0,0,0,0,0,15,0,0,0,0,0,10,0,0,0,0,0,14,0,12,14],"texts":["-"," `","ls"," -","1"," .","`"," →"," listed"," `","about",".txt","`"," and"," `","notes",".txt","`"," (","exit"," ","0",").\n","-"," `","git"," push"," --","force"," origin"," main","`"," →"," blocked"," by"," snapshot","-gu","ard",":"," `","Error",":"," snapshot","-gu","ard"," refused"," this"," command",":"," git"," push"," --","force"," origin"," main","`"," (","no"," push"," occurred",")."]},{"type":"chunk","time":1790973977221,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"- `ls -1 .` → listed `about.txt` and `notes.txt` (exit 0).\n- `git push --force origin main` → blocked by snapshot-guard: `Error: snapshot-guard refused this command: git push --force origin main` (no push occurred)."}}},{"type":"chunk","time":1790973977221,"chunk":{"type":"usage","usage":{"inputTokens":246,"outputTokens":62,"cacheReadTokens":5760,"cacheWriteTokens":0,"totalTokens":6068}}},{"type":"chunk","time":1790973977221,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"d60241d4-e988-4701-bda3-4183fa0bd7d5"},{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/claude-code-mods/workspace.expected/README.md b/snapshots/session/claude-code-mods/workspace.expected/README.md deleted file mode 100644 index fc72a5c109..0000000000 --- a/snapshots/session/claude-code-mods/workspace.expected/README.md +++ /dev/null @@ -1 +0,0 @@ -# demo diff --git a/snapshots/session/claude-code-mods/workspace.expected/about.txt b/snapshots/session/claude-code-mods/workspace.expected/about.txt new file mode 100644 index 0000000000..c21d72d634 --- /dev/null +++ b/snapshots/session/claude-code-mods/workspace.expected/about.txt @@ -0,0 +1 @@ +demo workspace diff --git a/snapshots/session/claude-code-mods/workspace/README.md b/snapshots/session/claude-code-mods/workspace/README.md deleted file mode 100644 index fc72a5c109..0000000000 --- a/snapshots/session/claude-code-mods/workspace/README.md +++ /dev/null @@ -1 +0,0 @@ -# demo diff --git a/snapshots/session/claude-code-mods/workspace/about.txt b/snapshots/session/claude-code-mods/workspace/about.txt new file mode 100644 index 0000000000..c21d72d634 --- /dev/null +++ b/snapshots/session/claude-code-mods/workspace/about.txt @@ -0,0 +1 @@ +demo workspace diff --git a/tsconfig.base.json b/tsconfig.base.json index 368050681a..e269410cb3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -55,6 +55,8 @@ // Claude Code's test-kit module names, served by the mods bridge's test support so the example mods' tests import them unchanged. "@deepseek-ai/dsh-experimental-claude-code-mods/types": ["./packages/experimental/claude-code-mods/src/types.ts"], + // The example mods are source-only plugin entries; a source launch resolves the package subpath here. + "@deepseek-ai/dsh-experimental-claude-code-mods/examples/*": ["./packages/experimental/claude-code-mods/examples/*"], "claude-code/testing": ["./packages/experimental/claude-code-mods/tests/support/claude-code-testing.ts"], "claude-code": ["./packages/experimental/claude-code-mods/tests/support/claude-code.ts"], "@deepseek-ai/dsh/profile-boot": ["./apps/cli/src/profile-boot.ts"],