fix(experimental): close the mods bridge review findings

- Disposal cancels detached runs before the engine waits for timer callbacks,
  so a callback parked in a $ call ends instead of blocking unload.
- A forgotten band wakes and ends its watch streams; a refresh asked for
  during a pass joins one more pass that settles after it; the Web band drops
  an ended stream so the next subscriber opens a fresh one.
- $ has Claude Code's namespaces: an unserved member rejects with
  'no implementation for <namespace>.<member>' instead of a TypeError.
- A .catch handler's next makes the rewrite check; a tool rename is refused
  like an argument rewrite; a null op answer is reported as no answer;
  defineMod returns its disposer from apply; the live turn record is copied
  before the chain freezes it; prompt.submit is raised only for batches with
  a human message and names the submitting mod as origin.
- $.http.fetch bounds the body while streaming; bandColumns/bandRows are
  Config; the client package drops its stale gateway edges; the source
  overlay lives in the package and anchors its entries to the file, and the
  dsh installation no longer depends on the experimental packages.
This commit is contained in:
Tianyi Cui
2026-10-03 05:18:32 +08:00
parent f87c957624
commit 233032b8f4
41 changed files with 434 additions and 192 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
"packages/typert/generator/tests/fixtures/type-model/**", // tsgolint rejects this fixture's preserved project shapes before rules run. "packages/typert/generator/tests/fixtures/type-model/**", // tsgolint rejects this fixture's preserved project shapes before rules run.
"website/.generated/**", "website/.generated/**",
"vendor/**", // Vendored source keeps upstream style and idioms. "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. "native/**", // The imported landlock-run subtree has its own gates; see native/README.md.
"**/*.js", "**/*.js",
"**/*.mjs", "**/*.mjs",
@@ -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'
+1 -3
View File
@@ -102,9 +102,7 @@
"@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/dsh-atomic-write": "workspace:*",
"@deepseek-ai/dsh-agent-preset": "workspace:*", "@deepseek-ai/dsh-agent-preset": "workspace:*",
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-auto-review": "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:*"
}, },
"devDependencies": { "devDependencies": {
"@agentclientprotocol/sdk": "1.4.0", "@agentclientprotocol/sdk": "1.4.0",
+5 -1
View File
@@ -946,7 +946,7 @@ export interface StagehandModelConfig {
## `@deepseek-ai/dsh-experimental-claude-code-mods` ## `@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 ```ts config-catalog
/** Plugin config: the limits mod hooks run under. */ /** Plugin config: the limits mod hooks run under. */
@@ -959,6 +959,10 @@ export interface Config {
processTimeoutMs?: number processTimeoutMs?: number
/** Claude Code tool name → harness tool name entries added to the built-in alias table. */ /** Claude Code tool name → harness tool name entries added to the built-in alias table. */
toolAliases?: Record<string, string> toolAliases?: Record<string, string>
/** 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
} }
``` ```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods --> <!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods -->
+5 -1
View File
@@ -948,7 +948,7 @@ export interface StagehandModelConfig {
## `@deepseek-ai/dsh-experimental-claude-code-mods` ## `@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 ```ts config-catalog
/** Plugin config: the limits mod hooks run under. */ /** Plugin config: the limits mod hooks run under. */
@@ -961,6 +961,10 @@ export interface Config {
processTimeoutMs?: number processTimeoutMs?: number
/** Claude Code tool name → harness tool name entries added to the built-in alias table. */ /** Claude Code tool name → harness tool name entries added to the built-in alias table. */
toolAliases?: Record<string, string> toolAliases?: Record<string, string>
/** 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
} }
``` ```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods --> <!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods -->
+1 -2
View File
@@ -1287,7 +1287,6 @@ flowchart TD
pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace 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_experimental_agent_team
pkg_experimental_client_ui_agent_team --> pkg_session 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_remotes
pkg_experimental_client_ui_claude_code_mods --> pkg_api_session_controller pkg_experimental_client_ui_claude_code_mods --> pkg_api_session_controller
pkg_experimental_client_ui_claude_code_mods --> pkg_client_locale 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-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) | | [`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-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-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) | | [`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) | | [`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) |
+1 -2
View File
@@ -1289,7 +1289,6 @@ flowchart TD
pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace 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_experimental_agent_team
pkg_experimental_client_ui_agent_team --> pkg_session 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_remotes
pkg_experimental_client_ui_claude_code_mods --> pkg_api_session_controller pkg_experimental_client_ui_claude_code_mods --> pkg_api_session_controller
pkg_experimental_client_ui_claude_code_mods --> pkg_client_locale 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-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) | | [`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-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-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) | | [`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) | | [`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) |
+8 -8
View File
@@ -6,20 +6,20 @@
en: e520df3bf44e1160 en: e520df3bf44e1160
zh: 557956eaf6faf4a7 zh: 557956eaf6faf4a7
/claude-code-mods-compatibility/packaging-and-loading: /claude-code-mods-compatibility/packaging-and-loading:
en: 2c36fb0f324f8a6d en: 75cf021ce8babaab
zh: 69723bf6e3a953e5 zh: caa20540f8bce11a
/claude-code-mods-compatibility/events: /claude-code-mods-compatibility/events:
en: 4932edf66891e29b en: cea776ec0063c311
zh: 0a08ca2fcc233cd2 zh: 6b8c8b32f2f7ede9
/claude-code-mods-compatibility/the-chain: /claude-code-mods-compatibility/the-chain:
en: 111c186536345342 en: 111c186536345342
zh: 8ddc81c78c05a840 zh: 8ddc81c78c05a840
/claude-code-mods-compatibility/members: /claude-code-mods-compatibility/members:
en: 7b4984a5bdf5029b en: 1f1d39f01937b39c
zh: 114b91a050cc8b85 zh: 6365f4d47ea0ba8f
/claude-code-mods-compatibility/drawing: /claude-code-mods-compatibility/drawing:
en: ad63be0ce30bb705 en: 1bd1a60970fcf426
zh: 15811dc20f5f9ce8 zh: 8f6a8e069f742fb7
/claude-code-mods-compatibility/testing: /claude-code-mods-compatibility/testing:
en: 8b3530b79e09a067 en: 8b3530b79e09a067
zh: c041e63f7fd8de5a zh: c041e63f7fd8de5a
+6 -13
View File
@@ -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 | | 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 | | 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 | | 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 | | `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 | | `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.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 | | `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` | | `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 | | `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' })` | | `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 | | `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 | | `$.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 | | `$.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__<plugin>__<tool>`; `call` runs the harness pipeline and injects a tool's deferred contexts into the session | | `$.tool` | `register`, `call`, `list` | Same; a registered tool is `mcp__<plugin>__<tool>`; `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 "<plugin>" mod:` unless `asUser` | | `$.prompt` | `submit`, `compose` | `submit`; the message is `user`-sourced, framed `Message from the "<plugin>" mod:` unless `asUser`, and the `prompt.submit` it raises names the mod as origin |
| `$.session` | `id`, `cwd`, `root`, `repo`, `model`, `turns`, `messages`, `usage`, `version`, `send`, `append`, `authorize`, `compact`, `surfaces` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version`; `cwd` and `root` both report the session workspace; `usage.context.window` is `0` and `percent` absent until the token meter knows the route's window; `rateLimits` is empty; `version` names this bridge | | `$.session` | `id`, `cwd`, `root`, `repo`, `model`, `turns`, `messages`, `usage`, `version`, `send`, `append`, `authorize`, `compact`, `surfaces` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version`; `cwd` 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 | | `$.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 | | `$.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 | | `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 | | `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) | | `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 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 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 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 | | `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 | | 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<SurfaceSnapshot> @Remote pressBand(agent: Agent, generation: number, actionId: string): Promise<SurfaceSnapshot>
/**
* 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<SurfaceSnapshot>
/** /**
* Load one mod beneath every mod loaded before it: run its `register`, * 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. * keep its hooks, and report which of its events this host never raises.
+6 -13
View File
@@ -13,7 +13,7 @@
| 层级(`user`、`project`、`managed`)与 `next.to(e, tier)` | 每个模组都以 `user` 加载;测试中的 `tier()` 被接受并忽略;`next.to` 拒绝 | 托管设置不是 DSH 的概念 | | 层级(`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` | | 编辑时热重载钩子模块;`claude plugin validate`、`claude plugin test`、由 `types/index.d.ts` 生成类型 | 这些命令都没有;重新挂载会在同一已求值模块上重新运行 `register`,模块级变量保留其值 | 仅按需跟进;测试工具包的 `createModTestKit` 在 Vitest 中替代 `claude plugin test` |
| 钩子模块可以是 TypeScript | `.ts` 钩子模块只在启动器转译时可加载(源码启动可以;构建后的安装不行) | DSH 交付的是纯 Node | | 钩子模块可以是 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` | 类型名是桥接自己的 | | `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` | 不同的桥接 | | `hooks.json` 中的设置钩子与模组钩子并行运行 | 不运行;为它们挂载 `@deepseek-ai/dsh-hooks-claude-code` | 不同的桥接 |
@@ -23,9 +23,9 @@
|---|---|---| |---|---|---|
| `session.start` | 每会话触发一次,在首个提示词之前 | 由根智能体的 `agent/created` 触发,在其首轮之前等待完成;不为子智能体触发 | | `session.start` | 每会话触发一次,在首个提示词之前 | 由根智能体的 `agent/created` 触发,在其首轮之前等待完成;不为子智能体触发 |
| `session.end` | 每会话触发一次 | 由根智能体的 `agent/disposed` 触发;钩子结束前 `$.state` 可读 | | `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` 起算 | | `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` | 为模组注册的命令运行 | 相同 | | `command.run` | 为模组注册的命令运行 | 相同 |
| `ui.render` | 为每个渲染位点触发(`AbovePrompt`、`Pane`、`ToolUse`、`AssistantMessage`、`Spinner`……) | 只为 `AbovePrompt` 触发,每次会话横幅重绘一次;`Pane` 钩子只经测试工具包的 `$.ui.mount({ component: 'Pane' })` 运行 | | `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` 作为 `$` 调用服务 | | `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` 到达宿主日志;其余拒绝 | | `$.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` 一个无人回答的命令时拒绝,并点名注册它的模组 | | `$.command` | `register`、`run`、`list` | 相同;`run` 一个无人回答的命令时拒绝,并点名注册它的模组 |
| `$.tool` | `register`、`call`、`list` | 相同;注册的工具为 `mcp__<plugin>__<tool>`;`call` 运行 harness 流水线,并把工具推迟的上下文注入会话 | | `$.tool` | `register`、`call`、`list` | 相同;注册的工具为 `mcp__<plugin>__<tool>`;`call` 运行 harness 流水线,并把工具推迟的上下文注入会话 |
| `$.prompt` | `submit`、`compose` | `submit`;消息为 `user` 来源,除非 `asUser` 否则套上 `Message from the "<plugin>" mod:` 框架 | | `$.prompt` | `submit`、`compose` | `submit`;消息为 `user` 来源,除非 `asUser` 否则套上 `Message from the "<plugin>" mod:` 框架,它触发的 `prompt.submit` 以该模组为来源 |
| `$.session` | `id`、`cwd`、`root`、`repo`、`model`、`turns`、`messages`、`usage`、`version`、`send`、`append`、`authorize`、`compact`、`surfaces` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version`;`cwd` 与 `root` 都报告会话工作区;在 token 计量器得知路由窗口前 `usage.context.window` 为 `0` 且无 `percent`;`rateLimits` 为空;`version` 指明本桥接 | | `$.session` | `id`、`cwd`、`root`、`repo`、`model`、`turns`、`messages`、`usage`、`version`、`send`、`append`、`authorize`、`compact`、`surfaces` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version`;`cwd` 与 `root` 都报告会话工作区;在 token 计量器得知路由窗口前 `usage.context.window` 为 `0` 且无 `percent`;`rateLimits` 为空;`version` 指明本桥接 |
| `$.state` | 按会话,热重载后保留 | 按会话,内存中;`ui.render` 期间的读取让横幅订阅该槽位 | | `$.state` | 按会话,热重载后保留 | 按会话,内存中;`ui.render` 期间的读取让横幅订阅该槽位 |
| `$.store` | 按插件,持久 | 相同,在 `claude_code_mods` 存储域中,每插件 4 MiB | | `$.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 横幅忽略 | | `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 横幅会换行 | | `Text` 属性:`color`、`bold`、`dimColor`、`italic`、`underline`、`wrap`、`truncate` | `color`(终端调色板名)、`bold`、`dimColor`、`italic`、`underline`;Web 横幅会换行 |
| `Button` 属性:`label`、`hotkey`、`disabled`、`onPress`;单个数字键触发热键 | `label`、`hotkey`(显示为提示)、`disabled`、`onPress`(点击) | | `Button` 属性:`label`、`hotkey`、`disabled`、`onPress`;单个数字键触发热键 | `label`、`hotkey`(显示为提示)、`disabled`、`onPress`(点击) |
| 横幅是一个实例;`hasSurvey`、`isWorking`、`maxRows`、`bodyColumns` 反映终端 | 每会话一个实例,按模组加载顺序咨询;`bodyColumns` 与 `viewport.columns` 为 `120`,`hasSurvey` 为 `false`,`isWorking` 为 `false`,`maxRows` 为 `10`;Web 横幅换行而非滚动 | | 横幅是一个实例;`hasSurvey`、`isWorking`、`maxRows`、`bodyColumns` 反映终端 | 每会话一个实例,按模组加载顺序咨询;`bodyColumns` 与 `viewport.columns` 取桥接的 `bandColumns`(默认 `120`),`maxRows` 取 `bandRows`(默认 `10`),`hasSurvey` 与 `isWorking` 为 `false`;Web 横幅换行而非滚动 |
| 横幅随宿主帧重绘,并在渲染读取过的 `$.state` 值变化时重绘 | 在上次绘制读取过的 `$.state` 值被写入时、在 `$.ui.open`、`$.ui.close`、`$.ui.invalidate` 之后、在每条 `tool.call` 链与 `turn.complete` 结束后以及按下之后重绘;在其他时刻改变模块级状态的模组在下一次触发时绘出 | | 横幅随宿主帧重绘,并在渲染读取过的 `$.state` 值变化时重绘 | 在 `session.start` 之后、上次绘制读取过的 `$.state` 值被写入时、在 `$.ui.open`、`$.ui.close`、`$.ui.invalidate` 之后、在每条 `tool.call` 链与 `turn.complete` 结束后以及按下之后重绘;在其他时刻改变模块级状态的模组在下一次触发时绘出。按下运行 `onPress` 时没有时间限制 |
| 带 `focus`、`scroll`、停靠在对话旁的 `Pane` | 没有面板:`$.ui.open` 回答 `{ isPlaced: false }`,模组回退到横幅;测试工具包直接挂载 `Pane` 钩子 | | 带 `focus`、`scroll`、停靠在对话旁的 `Pane` | 没有面板:`$.ui.open` 回答 `{ isPlaced: false }`,模组回退到横幅;测试工具包直接挂载 `Pane` 钩子 |
| 热键、`TextInput`、焦点、滚动、`ui.press`、`ui.input`、`ui.select` | 不服务;后续工作 | | 热键、`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<SurfaceSnapshot> @Remote pressBand(agent: Agent, generation: number, actionId: string): Promise<SurfaceSnapshot>
/**
* 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<SurfaceSnapshot>
/** /**
* Load one mod beneath every mod loaded before it: run its `register`, * 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. * keep its hooks, and report which of its events this host never raises.
@@ -15,17 +15,17 @@
en: d152484eb41ac6b4 en: d152484eb41ac6b4
zh: 09388d293f9be9cb zh: 09388d293f9be9cb
/deepseek-ai-dsh-experimental-claude-code-mods/use-this-package: /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package:
en: 1c1d6e0677649b35 en: 95dcbcd789f3a4dd
zh: d0f7f9c0dcd90f46 zh: 34aa7c9d225f6fe4
/deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-events-your-mod-receives: /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-events-your-mod-receives:
en: 909e5c93c27ed388 en: 909e5c93c27ed388
zh: 2377524dbb845ff1 zh: 2377524dbb845ff1
/deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/the-band-above-the-prompt: /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/the-band-above-the-prompt:
en: f847277f056ebbda en: 2b04be6b58e61a74
zh: 33cec1a31c347676 zh: f4854198d8999d11
/deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-members-your-mod-can-call: /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/which-members-your-mod-can-call:
en: 359ab906bd23a2b9 en: 9ca1aff59b65305f
zh: 5ccd7fd9ad5282b2 zh: c6043a8b7aadbdec
/deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/test-a-mod: /deepseek-ai-dsh-experimental-claude-code-mods/use-this-package/test-a-mod:
en: 5848fccf79f41e0a en: 5848fccf79f41e0a
zh: 62bb20c4d7e4d8c2 zh: 62bb20c4d7e4d8c2
@@ -78,8 +78,8 @@
en: ca31263f47f654d9 en: ca31263f47f654d9
zh: 17d55e6641705627 zh: 17d55e6641705627
/deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work: /deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work:
en: 02c6fd37c2ebba6a en: 99aaa73c8ef9c609
zh: 4209f4e2f60f1346 zh: 46fab480bd2b1706
/deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work/dev-note: /deepseek-ai-dsh-experimental-claude-code-mods/known-limitations-and-deferred-work/dev-note:
en: 4d1e26f4cc9c3c1c en: 4d1e26f4cc9c3c1c
zh: d80209b56c56b11c zh: d80209b56c56b11c
@@ -49,8 +49,10 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import
| `catchTimeoutMs` | `1000` | A `.catch` handler's running time | | `catchTimeoutMs` | `1000` | A `.catch` handler's running time |
| `processTimeoutMs` | `30000` | Default `$.process.run` and `$.http.fetch` timeout | | `processTimeoutMs` | `30000` | Default `$.process.run` and `$.http.fetch` timeout |
| `toolAliases` | — | Claude Code tool name → harness tool name entries added to the built-in table | | `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 ### Which events your mod receives
@@ -71,7 +73,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
<a id="the-band-above-the-prompt"></a> <a id="the-band-above-the-prompt"></a>
### The band above the prompt ### 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 ### 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 | | `$.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 | | `$.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__<plugin>__<tool>`; contexts a tool defers to the next request are injected into the session | | `$.tool` | `register`, `call`, `list` | `ctx.tools`; a registered tool is named `mcp__<plugin>__<tool>`; 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 | | `$.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 | | `$.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 | | `$.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 | | `$.http` | `fetch` | the process's `fetch`, bodies up to 4 MiB |
| `$.env` | `get`, `set` | this process's environment, shared by every session and plugin | | `$.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 <namespace>.<method>`. 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 <namespace>.<method>`. 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.
<a id="test-a-mod"></a> <a id="test-a-mod"></a>
### Test a mod ### 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. 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. - **`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 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. - **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. - **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`. - **`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`. - **`$.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`. - **`plugin.json` and `hooks.json`** — not read; `defineMod` carries the identity, and a plugin's settings hooks need `@deepseek-ai/dsh-hooks-claude-code`.
@@ -49,8 +49,10 @@ export default defineMod({ name: 'token-weather', version: '0.1.0', root: import
| `catchTimeoutMs` | `1000` | `.catch` 处理器的运行时间 | | `catchTimeoutMs` | `1000` | `.catch` 处理器的运行时间 |
| `processTimeoutMs` | `30000` | `$.process.run` 与 `$.http.fetch` 的默认超时 | | `processTimeoutMs` | `30000` | `$.process.run` 与 `$.http.fetch` 的默认超时 |
| `toolAliases` | — | 追加到内置表的 Claude Code 工具名 → harness 工具名条目 | | `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
<a id="the-band-above-the-prompt"></a> <a id="the-band-above-the-prompt"></a>
### 提示框上方的横幅 ### 提示框上方的横幅
横幅每个会话一个实例,由加载顺序中第一个 `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` 到达宿主日志 | | `$.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`,限定在事件所属的智能体 | | `$.command` | `register`、`run`、`list` | `ctx.commands`,限定在事件所属的智能体 |
| `$.tool` | `register`、`call`、`list` | `ctx.tools`;注册的工具名为 `mcp__<plugin>__<tool>`;工具推迟到下一请求的上下文会注入会话 | | `$.tool` | `register`、`call`、`list` | `ctx.tools`;注册的工具名为 `mcp__<plugin>__<tool>`;工具推迟到下一请求的上下文会注入会话 |
| `$.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 每会话一个目录 | | `$.session` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version` | 智能体的 Session 及 `turnBoundary`、`contextPressure` 投影;`cwd` 与 `root` 都报告会话工作区,即 harness 每会话一个目录 |
| `$.state` | `get`、`set` | 为会话持有的内存,以模组命名的 `{ plugin, key }` 寻址;`ui.render` 期间的读取会让横幅订阅 | | `$.state` | `get`、`set` | 为会话持有的内存,以模组命名的 `{ plugin, key }` 寻址;`ui.render` 期间的读取会让横幅订阅 |
| `$.store` | `get`、`set`、`delete`、`keys` | `claude_code_mods` 存储域,每插件一个 JSON 对象,4 MiB | | `$.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 | | `$.http` | `fetch` | 进程的 `fetch`,正文最多 4 MiB |
| `$.env` | `get`、`set` | 本进程的环境变量,所有会话与插件共享 | | `$.env` | `get`、`set` | 本进程的环境变量,所有会话与插件共享 |
所需服务未组合的调用以缺失服务的包名拒绝;表外的命名空间或方法以 `no implementation for <namespace>.<method>` 拒绝。 所需服务未组合的调用以缺失服务的包名拒绝;表外的命名空间或方法以 `no implementation for <namespace>.<method>` 拒绝。模组以本进程的全部权限运行:`$.env` 读写 harness 的环境变量,`$.http.fetch` 可访问任意 URL,`$.fs` 与 `$.tool.call` 以会话的身份行动。
<a id="test-a-mod"></a> <a id="test-a-mod"></a>
### 测试模组 ### 测试模组
@@ -193,13 +195,13 @@ Message from the "<plugin>" mod:
这些限制描述 Claude Code 模组经本桥接运行时行为不同之处;[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 是完整清单。它们是当前的包约束,不是任务积压。 这些限制描述 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` 仍在运行时作答的钩子会等待该次运行。 - **`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` 时被拒绝。 - **未服务的事件** — `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 日志重建。 - **未服务的 `$` 命名空间** — `$.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` 只为根智能体触发。 - **一个进程,多个会话** — 模组的模块级变量与 `$.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` 起算。 - **`turn.complete` 文本** — 钩子返回的 `{ text }` 到达宿主日志,而非答案下方的一行;`durationMs` 从该轮的 `turn/start` 起算。
- **`$.session.usage`** — 在 token 计量器得知路由的上下文窗口与提供商用量报告之前,`window` 为 `0` 且无 `percent`;`rateLimits` 始终为空。`$.fs.stat` 报告 `mtimeMs: 0`。 - **`$.session.usage`** — 在 token 计量器得知路由的上下文窗口与提供商用量报告之前,`window` 为 `0` 且无 `percent`;`rateLimits` 始终为空。`$.fs.stat` 报告 `mtimeMs: 0`。
- **`plugin.json` 与 `hooks.json`** — 不读取;`defineMod` 承载身份,插件的设置钩子需要 `@deepseek-ai/dsh-hooks-claude-code`。 - **`plugin.json` 与 `hooks.json`** — 不读取;`defineMod` 承载身份,插件的设置钩子需要 `@deepseek-ai/dsh-hooks-claude-code`。
@@ -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'
@@ -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 }), plugin: Object.freeze({ name: mod.name, root: mod.root }),
ui: Object.freeze({ ui: Object.freeze({
log(text: string, options?: UiLogOptions): void { log(text: string, options?: UiLogOptions): void {
@@ -169,5 +170,50 @@ export function createModsApi(binding: ApiBinding): ModsApi {
get: (name: string): Promise<string | undefined> => call('env.get', { name }), get: (name: string): Promise<string | undefined> => call('env.get', { name }),
set: (name: string, value: string | undefined): Promise<void> => call('env.set', { name, ...value === undefined ? {} : { value } }), set: (name: string, value: string | undefined): Promise<void> => 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<string> = 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 <namespace>.<member>`, 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<string, object>()
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,
}) })
} }
@@ -306,6 +306,14 @@ export async function dispatch<E, R>(request: DispatchRequest<E, R>): Promise<R>
(e: E): Promise<R> => { (e: E): Promise<R> => {
if (state.beneath !== undefined) return state.beneath if (state.beneath !== undefined) return state.beneath
if (handlerAbandoned) return Promise.resolve(undefined as R) 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() clock.pause()
state.beneath = runFrom(index + 1, e).finally(() => { clock.resume() }) state.beneath = runFrom(index + 1, e).finally(() => { clock.resume() })
return state.beneath return state.beneath
@@ -32,7 +32,7 @@ export interface ModPlugin {
readonly Config: z<ModConfig> readonly Config: z<ModConfig>
/** The mod as the bridge and the test kit load it, with `userConfig` as its options. */ /** The mod as the bridge and the test kit load it, with `userConfig` as its options. */
readonly definition: ModDefinition readonly definition: ModDefinition
apply(ctx: Context, config: ModConfig): Promise<void> apply(ctx: Context, config: ModConfig): Promise<() => Promise<void>>
} }
const optionValue = z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]) 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({}), Config: z.dict(optionValue).default({}),
definition, definition,
// A `register` that throws fails this plugin's mount, as any misconfigured plugin does; the rest of the composition continues. // 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<void> { // The disposer is the return value, which Cordis collects even when the plugin was removed while `register` ran.
const dispose = await ctx.claudeCodeMods.add({ ...definition, options: Object.freeze({ ...userConfig, ...config }) }) apply(ctx: Context, config: ModConfig): Promise<() => Promise<void>> {
ctx.effect(() => dispose, `claude-code-mods: mod ${spec.name}`) return ctx.claudeCodeMods.add({ ...definition, options: Object.freeze({ ...userConfig, ...config }) })
}, },
} }
} }
@@ -270,9 +270,11 @@ export class ModsEngine<B> {
signal, signal,
report: this.options.report, 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. // 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 } const answer = result as { value?: unknown; deny?: unknown } | null
if (!('value' in answer) && !('deny' in answer)) throw new Error(`${op}: a hook returned neither { value } nor { deny }`) 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}`) if (typeof answer.deny === 'string') throw new Error(`${op} refused: ${answer.deny}`)
return answer.value return answer.value
} }
@@ -99,6 +99,8 @@ export interface HostOpsOptions {
readonly modTools: Set<string> readonly modTools: Set<string>
/** Redraw a session's band after a `$.ui` call that changes what it shows; absent when no surface is drawn. */ /** 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 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<AgentBinding>, op: string): Agent { function requireAgent(context: OpContext<AgentBinding>, op: string): Agent {
@@ -379,7 +381,9 @@ export function createHostOps(options: HostOpsOptions): OpTable<AgentBinding> {
const body = requireString(text, '$.prompt.submit text') const body = requireString(text, '$.prompt.submit text')
const framed = asUser === true ? body : `Message from the "${context.mod.name}" mod:\n${body}` 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. // 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 } return { text: framed }
}, },
@@ -525,9 +529,8 @@ export function createHostOps(options: HostOpsOptions): OpTable<AgentBinding> {
response.headers.forEach((value, name) => { response.headers.forEach((value, name) => {
headers[name] = value headers[name] = value
}) })
const body = await response.arrayBuffer() const body = await readBounded(response, HTTP_MAX_BYTES)
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: body } satisfies HttpResponse
return { status: response.status, ok: response.ok, headers, text: new TextDecoder().decode(body) } satisfies HttpResponse
}, },
// ---- environment of this process ---- // ---- environment of this process ----
@@ -542,3 +545,22 @@ export function createHostOps(options: HostOpsOptions): OpTable<AgentBinding> {
} }
export type { ModsEngine } 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<string> {
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))
}
@@ -43,7 +43,6 @@ declare module '@deepseek-ai/cordis' {
export type * from './types.ts' export type * from './types.ts'
export type { BoxProps, ButtonProps, TextProps, UiElement, UiElements, UiNode } from './elements.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 { defineMod, type ModConfig, type ModPlugin, type ModSpec } from './define-mod.ts'
export { DEFAULT_TOOL_ALIASES } from './tool-names.ts' export { DEFAULT_TOOL_ALIASES } from './tool-names.ts'
export { KNOWN_EVENTS } from './matcher.ts' export { KNOWN_EVENTS } from './matcher.ts'
@@ -59,6 +58,10 @@ export interface Config {
processTimeoutMs?: number processTimeoutMs?: number
/** Claude Code tool name → harness tool name entries added to the built-in alias table. */ /** Claude Code tool name → harness tool name entries added to the built-in alias table. */
toolAliases?: Record<string, string> toolAliases?: Record<string, string>
/** 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. */ /** 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) }) void settled.finally(() => { this.pending.delete(settled) })
} }
async drain(): Promise<void> { /** Abort every run's signal; `drain` then waits for them to settle. */
cancel(): void {
this.controller.abort(new Error('claude-code-mods disposed')) this.controller.abort(new Error('claude-code-mods disposed'))
}
async drain(): Promise<void> {
this.cancel()
await Promise.allSettled([...this.pending]) await Promise.allSettled([...this.pending])
} }
} }
@@ -195,6 +203,8 @@ export class ClaudeCodeMods extends TypertRemoteService {
catchTimeoutMs: z.number().default(1_000), catchTimeoutMs: z.number().default(1_000),
processTimeoutMs: z.number().default(30_000), processTimeoutMs: z.number().default(30_000),
toolAliases: z.dict(z.string()), 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, // 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) { constructor(ctx: Context, config: Config) {
super(ctx, 'claudeCodeMods', { namespace: 'claudeCodeMods' }) super(ctx, 'claudeCodeMods', { namespace: 'claudeCodeMods' })
const hookTimeoutMs = config.hookTimeoutMs ?? 10_000 // The schema supplies every default; a direct construction passes the fields it needs.
const catchTimeoutMs = config.catchTimeoutMs ?? 1_000 const { hookTimeoutMs = 10_000, catchTimeoutMs = 1_000, processTimeoutMs = 30_000, bandColumns = 120, bandRows = 10 } = config
const processTimeoutMs = config.processTimeoutMs ?? 30_000
assertPositive('hookTimeoutMs', hookTimeoutMs) assertPositive('hookTimeoutMs', hookTimeoutMs)
assertPositive('catchTimeoutMs', catchTimeoutMs) assertPositive('catchTimeoutMs', catchTimeoutMs)
assertPositive('processTimeoutMs', processTimeoutMs) assertPositive('processTimeoutMs', processTimeoutMs)
assertPositive('bandColumns', bandColumns)
assertPositive('bandRows', bandRows)
const aliases = createToolNameAliases(config.toolAliases) const aliases = createToolNameAliases(config.toolAliases)
const callOrigins = new Map<string, LoadedMod>() const callOrigins = new Map<string, LoadedMod>()
const modCommands = new Set<string>() const modCommands = new Set<string>()
@@ -221,8 +232,11 @@ export class ClaudeCodeMods extends TypertRemoteService {
const agentOf = (sessionId: string): Agent | undefined => ctx.get('agents')?.get(SessionId(sessionId)) const agentOf = (sessionId: string): Agent | undefined => ctx.get('agents')?.get(SessionId(sessionId))
const detached = new DetachedRuns(report) const detached = new DetachedRuns(report)
const surfaces = new SurfaceTable({ const surfaces = new SurfaceTable({
columns: bandColumns,
rows: bandRows,
render: (sessionId, input) => { render: (sessionId, input) => {
const agent = agentOf(sessionId) 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) if (agent === undefined) return Promise.resolve(null)
return engine.raise<UiRenderInput, UiRenderResult>('ui.render', input, () => null, { return engine.raise<UiRenderInput, UiRenderResult>('ui.render', input, () => null, {
binding: { agent }, signal: detached.controller.signal, binding: { agent }, signal: detached.controller.signal,
@@ -244,8 +258,10 @@ export class ClaudeCodeMods extends TypertRemoteService {
if (agentOf(sessionId) !== undefined) void surfaces.refresh(sessionId) if (agentOf(sessionId) !== undefined) void surfaces.refresh(sessionId)
}, 0).unref() }, 0).unref()
} }
const modSubmissions = new Map<string, string>()
const ops = createHostOps({ const ops = createHostOps({
ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw, ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw,
submitted: (messageId, mod) => { modSubmissions.set(messageId, mod) },
}) })
const engine: ModsEngine<AgentBinding> = new ModsEngine<AgentBinding>({ const engine: ModsEngine<AgentBinding> = new ModsEngine<AgentBinding>({
ops: op => ops[op], ops: op => ops[op],
@@ -259,6 +275,8 @@ export class ClaudeCodeMods extends TypertRemoteService {
this.engine = engine this.engine = engine
const registrations = this.registrations const registrations = this.registrations
ctx.effect(() => async () => { 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() surfaces.dispose()
for (const owned of registrations.values()) for (const dispose of owned) dispose() for (const owned of registrations.values()) for (const dispose of owned) dispose()
registrations.clear() registrations.clear()
@@ -328,12 +346,14 @@ export class ClaudeCodeMods extends TypertRemoteService {
const prompt = promptMessages(messages) const prompt = promptMessages(messages)
const text = textOf(prompt.flatMap(message => message.content)) const text = textOf(prompt.flatMap(message => message.content))
let submitted: PromptSubmitResult = { text } let submitted: PromptSubmitResult = { text }
if (messages.length > 0) { // A batch of injected context alone is not a prompt: Claude Code raises prompt.submit for typed prompts.
const sourceKind = messages[0]?.source.kind 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 = { const input: PromptSubmitInput = {
text, text,
wait: false, 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<PromptSubmitInput, PromptSubmitResult>( submitted = acceptPromptSubmit(await engine.raise<PromptSubmitInput, PromptSubmitResult>(
'prompt.submit', input, e => ({ text: e.text, ...e.context === undefined ? {} : { context: e.context } }), { binding, signal }, '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, signal: exec.signal,
// The call is logged before policy runs, so the arguments a hook passes down must be the logged ones. // The call is logged before policy runs, so the arguments a hook passes down must be the logged ones.
validateNext: (e) => { 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) { if (JSON.stringify(rest) !== logged) {
throw new RewriteRefusedError(`rewrote the arguments of ${modName}; argument rewrites need the pre-tool input rewrite mechanism (${REWRITE_NOTE})`) 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', isAborted: reason.kind === 'aborted',
reason: reason.kind === 'aborted' ? 'aborted' : reason.kind === 'error' ? 'error' : 'answer', reason: reason.kind === 'aborted' ? 'aborted' : reason.kind === 'error' ? 'error' : 'answer',
...agentIdOf(agent), ...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<TurnCompleteInput, TurnCompleteResult>( detached.track('turn.complete', engine.raise<TurnCompleteInput, TurnCompleteResult>(
'turn.complete', input, () => ({ text: '' }), { binding: { agent }, signal: detached.controller.signal }, 'turn.complete', input, () => ({ text: '' }), { binding: { agent }, signal: detached.controller.signal },
).then((result) => { ).then((result) => {
@@ -482,14 +506,6 @@ export class ClaudeCodeMods extends TypertRemoteService {
return this.surfaces.press(agent.session.id, generation, actionId) 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<SurfaceSnapshot> {
return this.surfaces.current(sessionId)
}
/** /**
* Load one mod beneath every mod loaded before it: run its `register`, * Load one mod beneath every mod loaded before it: run its `register`,
@@ -13,11 +13,12 @@ import type { SerializedNode, SurfaceSnapshot, UiRenderInput } from './types.ts'
export type { SurfaceSnapshot } 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. */ /** What the table needs from the host. */
export interface SurfaceHost { 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. */ /** Raise `ui.render` for one session through the mods and return the tree. */
render(sessionId: string, input: UiRenderInput): Promise<UiNode> render(sessionId: string, input: UiRenderInput): Promise<UiNode>
/** Run one button's callback inside the session; the host owns the invocation and its reporting. */ /** 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. */ /** Button callbacks of the current generation, by the `actionId` the serialized tree carries. */
readonly actions = new Map<string, () => unknown>() readonly actions = new Map<string, () => unknown>()
readonly watchers = new Set<(snapshot: SurfaceSnapshot) => void>() 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<void> | undefined rendering: Promise<void> | undefined
again = false pending: Promise<void> | 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. */ /** Set while a render runs: `$.state` reads subscribe. */
collecting: Set<string> | undefined collecting: Set<string> | undefined
@@ -81,12 +86,14 @@ export class SurfaceTable {
} }
band.watchers.add(watcher) band.watchers.add(watcher)
const onAbort = (): void => { wake?.() } const onAbort = (): void => { wake?.() }
const waker = (): void => { wake?.() }
band.wakers.add(waker)
signal.addEventListener('abort', onAbort, { once: true }) signal.addEventListener('abort', onAbort, { once: true })
try { try {
const first = await this.current(sessionId) const first = await this.current(sessionId)
let delivered = first.generation let delivered = first.generation
yield first yield first
while (!signal.aborted) { while (!signal.aborted && !band.closed) {
const next = queue.shift() const next = queue.shift()
if (next !== undefined) { if (next !== undefined) {
// The first drawing may arrive both as the current snapshot and as a watcher notification. // The first drawing may arrive both as the current snapshot and as a watcher notification.
@@ -100,6 +107,7 @@ export class SurfaceTable {
} }
} finally { } finally {
band.watchers.delete(watcher) band.watchers.delete(watcher)
band.wakers.delete(waker)
signal.removeEventListener('abort', onAbort) signal.removeEventListener('abort', onAbort)
} }
} }
@@ -111,18 +119,16 @@ export class SurfaceTable {
*/ */
refresh(sessionId: string): Promise<void> { refresh(sessionId: string): Promise<void> {
const band = this.bandOf(sessionId) const band = this.bandOf(sessionId)
if (band.rendering !== undefined) { if (band.rendering === undefined) {
band.again = true band.rendering = this.renderOnce(sessionId, band).finally(() => { band.rendering = undefined })
return band.rendering return band.rendering
} }
band.rendering = this.renderOnce(sessionId, band).finally(() => { // One more pass after the one in flight serves every refresh asked for meanwhile.
band.rendering = undefined band.pending ??= band.rendering.then(() => {
if (band.again) { band.pending = undefined
band.again = false return this.refresh(sessionId)
void 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. * @param sessionId - the disposed session.
*/ */
forget(sessionId: string): void { forget(sessionId: string): void {
const band = this.bands.get(sessionId) const band = this.bands.get(sessionId)
if (band === undefined) return if (band === undefined) return
this.bands.delete(sessionId) this.bands.delete(sessionId)
band.closed = true
band.watchers.clear() 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 { dispose(): void {
this.disposed = true this.disposed = true
for (const sessionId of [...this.bands.keys()]) this.forget(sessionId) 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<void> { private async renderOnce(sessionId: string, band: SessionBand): Promise<void> {
if (this.disposed) return if (this.disposed || band.closed) return
const input: UiRenderInput = { const input: UiRenderInput = {
component: 'AbovePrompt', component: 'AbovePrompt',
surface: 'AbovePrompt', surface: 'AbovePrompt',
props: { bodyColumns: BAND_COLUMNS, hasSurvey: false, isWorking: false, maxRows: 10 }, props: { bodyColumns: this.host.columns, hasSurvey: false, isWorking: false, maxRows: this.host.rows },
viewport: { columns: BAND_COLUMNS }, viewport: { columns: this.host.columns },
} }
band.collecting = new Set() band.collecting = new Set()
let tree: UiNode let tree: UiNode
@@ -259,19 +259,40 @@ describe('ticket-mod: a mod-registered tool, prompt context, and a turn.complete
describe('prompt.submit: rewrite and drop', () => { describe('prompt.submit: rewrite and drop', () => {
it('rewrites the prompt text the model sees and drops a prompt with a reason', async () => { it('rewrites the prompt text the model sees and drops a prompt with a reason', async () => {
const mod = writeMod('prompt-mod', ` const mod = writeMod('prompt-mod', `
const origins = []
let submitted = false
export function register(on) { 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) => { on('prompt.submit', async ($, e, next) => {
origins.push(e.origin)
if (e.text.includes('secret')) return { drop: 'secrets stay out' } 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('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() }) 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 h = await harness([mod], adapter)
const agent = await h.agent() 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 ') 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(entered?.type === 'user/message' && entered.data.content).toEqual([{ type: 'text', text: 'HELLO THERE' }])
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('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') await h.turn(agent, 'some junk')
const last = adapter.requests[1]?.messages.filter(message => message.role === 'user') ?? [] 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' }]) 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 ran: string[] = []
const adapter = new MockAdapter([ const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { command: 'skip' }), textResponse('one'), 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('c6', 'echo', { command: 'tweak' }), textResponse('five'),
toolCallResponse('c7', 'echo', { command: 'empty' }), textResponse('six'), toolCallResponse('c7', 'echo', { command: 'empty' }), textResponse('six'),
toolCallResponse('c8', 'echo', { command: 'error' }), textResponse('seven'), 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)) h.ctx.tools.register(echoTool('echo', ran))
// A tool whose output schema admits a string: a mod's string answer is its ordinary result. // A tool whose output schema admits a string: a mod's string answer is its ordinary result.
h.ctx.tools.register({ h.ctx.tools.register({
@@ -348,8 +394,9 @@ describe('tool.call: answers and rewrites', () => {
await h.turn(agent, 'tweak it') await h.turn(agent, 'tweak it')
await h.turn(agent, 'empty answer') await h.turn(agent, 'empty answer')
await h.turn(agent, 'error 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. // 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') 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]]) .map(e => e.type === 'tool/result' && [e.data.message.isError === true, e.data.message.content[0]])
expect(results.slice(1)).toEqual([ expect(results.slice(1)).toEqual([
@@ -360,7 +407,11 @@ describe('tool.call: answers and rewrites', () => {
[false, { type: 'text', text: 'tweaked' }], [false, { type: 'text', text: 'tweaked' }],
[true, { type: 'text', text: '' }], [true, { type: 'text', text: '' }],
[true, { type: 'text', text: 'refused outright' }], [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. // 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 }) const direct = await h.ctx.tools.execute({ callId: ToolCallIdOf('direct'), name: 'greet', arguments: {}, signal: new AbortController().signal })
expect(direct.isError).toBe(false) 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.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.get = (await $.http.fetch(e.args)).headers['x-echo']
out.httpBig = await $.http.fetch(e.args + 'big').then(() => 'read', error => error.message) 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.commands = (await $.command.list()).map(command => command.name + '/' + command.source)
out.tools = (await $.tool.list()).map(tool => tool.name) out.tools = (await $.tool.list()).map(tool => tool.name)
out.call = await $.tool.call({ tool: 'echo', command: 'from mod' }) 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)) response.end(Buffer.alloc(4 * 1024 * 1024 + 1, 120))
return return
} }
if (request.url === '/none') {
response.statusCode = 204
response.end()
return
}
let body = '' let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString() }) request.on('data', (chunk: Buffer) => { body += chunk.toString() })
request.on('end', () => { 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.http).toEqual({ status: 200, ok: true, text: 'pong', echoed: 'POST api-mod ping' })
expect(out.get).toBe('GET undefined ') expect(out.get).toBe('GET undefined ')
expect(out.httpBig).toMatch(/larger than 4194304 bytes/) 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.commands).toEqual(['probe/plugin'])
expect(out.tools).toContain('echo') expect(out.tools).toContain('echo')
expect(out.call).toEqual({ result: 'ran from mod' }) 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(), { 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(), { 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(), { 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 () => { 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. // 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, { const h = await harness([boom, blastRadius, tokenWeather], adapter, {
config: { bandColumns: 100, bandRows: 6 },
services: async (ctx, workspace) => { services: async (ctx, workspace) => {
await ctx.plugin(TokenMeter) await ctx.plugin(TokenMeter)
await ctx.plugin(LocalFileSystem, { cwd: workspace }) await ctx.plugin(LocalFileSystem, { cwd: workspace })
@@ -994,15 +1073,14 @@ describe('the band above the prompt', () => {
h.ctx.tools.register(echoTool('bash', ran)) h.ctx.tools.register(echoTool('bash', ran))
const agent = await h.agent() const agent = await h.agent()
const mods = h.ctx.claudeCodeMods 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 seen: unknown[] = []
const watching = new AbortController() const watching = new AbortController()
const watcher = (async () => { const watcher = (async () => {
for await (const snapshot of mods.watchBand(agent, watching.signal)) seen.push(snapshot) 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') await h.turn(agent, 'hello')
// turn.complete wrote the reading Token Weather's render reads: the band redraws without an invalidate. // 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')) await waitFor(() => JSON.stringify(seen.at(-1)).includes('of context'))
@@ -341,6 +341,27 @@ describe('dispatch: .catch handlers', () => {
viaNext.catchHandler = (_$, e, next) => next(e) viaNext.catchHandler = (_$, e, next) => next(e)
expect(await dispatch(request({ input: {}, core: () => ({ ok: 1 }), hooks: [viaNext], report }))).toEqual({ ok: 1 }) 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. // 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') }) const ranBeneath = hook(a, 'tool.call', () => { throw new Error('boom') })
ranBeneath.catchHandler = async (_$, e, next) => { ranBeneath.catchHandler = async (_$, e, next) => {
@@ -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 { Context, type Fiber } from '@deepseek-ai/cordis'
import ClaudeCodeMods, { defineMod } from '../src/index.ts' import ClaudeCodeMods, { defineMod } from '../src/index.ts'
@@ -41,6 +41,23 @@ describe('defineMod', () => {
expect(ctx.claudeCodeMods.mods).toEqual([]) 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<void>((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 () => { 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: () => {} }) const bare = defineMod({ name: 'bare', register: () => {} })
expect(bare.definition).toMatchObject({ name: 'bare', options: {} }) expect(bare.definition).toMatchObject({ name: 'bare', options: {} })
@@ -107,6 +107,12 @@ describe('ModsEngine', () => {
expect(await first.state.get(ref)).toEqual({ value: undefined }) 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)) await expect(e.invoke(owner, 'state.get', { plugin: 1, key: 'x' }, { key: 's1' }, new AbortController().signal))
.rejects.toThrow(/needs \{ plugin, key \} strings/) .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<unknown> } }
await expect(reaching.model?.complete()).rejects.toThrow('a: no implementation for model.complete')
const before = Date.now() const before = Date.now()
expect(await first.clock.now()).toBeGreaterThanOrEqual(before) expect(await first.clock.now()).toBeGreaterThanOrEqual(before)
await first.clock.sleep(1) await first.clock.sleep(1)
@@ -127,6 +133,7 @@ describe('ModsEngine', () => {
const input = event as { path: string } const input = event as { path: string }
if (input.path === '/secret') return { deny: 'policy: no secrets' } if (input.path === '/secret') return { deny: 'policy: no secrets' }
if (input.path === '/junk') return { nonsense: true } if (input.path === '/junk') return { nonsense: true }
if (input.path === '/null') return null
return next({ ...input, path: input.path + '.redirected' }) return next({ ...input, path: input.path + '.redirected' })
}) })
e.registry.add(policy, onPolicy.hooks) e.registry.add(policy, onPolicy.hooks)
@@ -137,6 +144,7 @@ describe('ModsEngine', () => {
expect(calls).toEqual(['{"path":"/notes.redirected","as":"text"}']) 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('/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('/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') await expect($.fs.write('/x', 'y')).rejects.toThrow('fs.write refused: read-only deployment')
const $policy = e.api(policy, undefined, { key: 's' }, signal) const $policy = e.api(policy, undefined, { key: 's' }, signal)
expect(await $policy.fs.read('/secret')).toBe('contents') expect(await $policy.fs.read('/secret')).toBe('contents')
@@ -8,6 +8,8 @@ const { Box, Text, Button } = uiElements()
function table(render: SurfaceHost['render'], runAction?: SurfaceHost['runAction']) { function table(render: SurfaceHost['render'], runAction?: SurfaceHost['runAction']) {
const report = vi.fn() const report = vi.fn()
const surfaces = new SurfaceTable({ const surfaces = new SurfaceTable({
columns: 120,
rows: 10,
render, render,
runAction: runAction ?? (async (_session, callback) => { await callback() }), runAction: runAction ?? (async (_session, callback) => { await callback() }),
report, report,
@@ -47,6 +49,21 @@ describe('SurfaceTable', () => {
expect(report).toHaveBeenCalledWith('band press ignored: a9 of generation 1 is not on the current drawing (1)') 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 () => { it('redraws when a state slot the drawing read is written, not for other slots, and on invalidation', async () => {
let value = 'a' let value = 'a'
const { surfaces } = table((sessionId) => { const { surfaces } = table((sessionId) => {
@@ -85,9 +102,12 @@ describe('SurfaceTable', () => {
const a = surfaces.refresh('s1') const a = surfaces.refresh('s1')
const b = surfaces.refresh('s1') const b = surfaces.refresh('s1')
const c = 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 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' mode = 'throw'
await surfaces.refresh('s1') await surfaces.refresh('s1')
expect((await surfaces.current('s1')).tree).toBeNull() 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 () => { it('reports a failing onPress through the host and still redraws', async () => {
const report = vi.fn() const report = vi.fn()
const surfaces = new SurfaceTable({ const surfaces = new SurfaceTable({
columns: 120,
rows: 10,
render: () => Promise.resolve(Button({ label: 'Boom', onPress: () => { throw new Error('nope') } })), render: () => Promise.resolve(Button({ label: 'Boom', onPress: () => { throw new Error('nope') } })),
runAction: async (_session, callback) => { runAction: async (_session, callback) => {
try { try {
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4 en: d152484eb41ac6b4
zh: 09388d293f9be9cb zh: 09388d293f9be9cb
/deepseek-ai-dsh-experimental-client-ui-claude-code-mods/use-this-package: /deepseek-ai-dsh-experimental-client-ui-claude-code-mods/use-this-package:
en: 592b9a927e3e6b92 en: ca73e7e3ee8f081b
zh: 7e12f86b6df8c2bf zh: 9e288df88595a98c
/deepseek-ai-dsh-experimental-client-ui-claude-code-mods/understand-the-implementation: /deepseek-ai-dsh-experimental-client-ui-claude-code-mods/understand-the-implementation:
en: 84e553fad0acc4e7 en: 84e553fad0acc4e7
zh: 59e022f910b106ab zh: 59e022f910b106ab
@@ -25,7 +25,7 @@ This optional browser plugin draws the band a [Claude Code mod](../claude-code-m
<a id="use-this-package"></a> <a id="use-this-package"></a>
## Use this package ## 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.
----- -----
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a> <a id="use-this-package"></a>
## 使用本包 ## 使用本包
与桥接和模组一起组合;[可选叠加层](../../../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 的终端调色板映射到本主题的令牌;热键显示为标签旁的提示,按下即点击。按下处理期间所有按钮禁用;被宿主拒绝的按下会在横幅下方显示其消息。
----- -----
@@ -32,13 +32,9 @@
"@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-primitives"
"@deepseek-ai/dsh-api-gateway"
], ],
"platform": "web", "platform": "web"
"external": [
"@deepseek-ai/dsh-api-gateway/client"
]
} }
}, },
"scripts": { "scripts": {
@@ -53,7 +49,6 @@
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-api-gateway": "workspace:*",
"@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*",
"@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*",
@@ -68,7 +63,6 @@
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-api-gateway": "workspace:*",
"@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*",
"@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*",
@@ -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 store = new BandStore()
const controller = new AbortController() const controller = new AbortController()
const handle = ctx.remote.claudeCodeMods.watchBand(sessionId, controller.signal) 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) for await (const snapshot of handle) store.set(snapshot)
} catch (error: unknown) { } catch (error: unknown) {
if (!controller.signal.aborted) ctx.logger.warn(`claude-code-mods band: watch of ${sessionId} ended: ${messageOf(error)}`) if (!controller.signal.aborted) ctx.logger.warn(`claude-code-mods band: watch of ${sessionId} ended: ${messageOf(error)}`)
} finally {
onEnd()
} }
})() })()
return { return {
@@ -78,7 +84,10 @@ function registerUi(ctx: Context): void {
const watchOf = (sessionId: SessionId): ReturnType<typeof watchSession> => { const watchOf = (sessionId: SessionId): ReturnType<typeof watchSession> => {
let watch = watches.get(sessionId) let watch = watches.get(sessionId)
if (watch === undefined) { if (watch === undefined) {
watch = watchSession(ctx, sessionId) const opened: ReturnType<typeof watchSession> = watchSession(ctx, sessionId, () => {
if (watches.get(sessionId) === opened) watches.delete(sessionId)
})
watch = opened
watches.set(sessionId, watch) watches.set(sessionId, watch)
} }
return watch return watch
@@ -154,8 +154,17 @@ it('logs a stream that fails while watched, but not one ended by disposal', asyn
f.injected('s1') f.injected('s1')
const handle = f.streams.get('s1') const handle = f.streams.get('s1')
if (handle === undefined) throw new Error('no stream') if (handle === undefined) throw new Error('no stream')
const stale = f.injected('s1').hooks.band
handle.fail(new Error('connection lost')) handle.fail(new Error('connection lost'))
await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith('claude-code-mods band: watch of s1 ended: 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.injected('s2')
f.streams.get('s2')?.fail('text failure') f.streams.get('s2')?.fail('text failure')
await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith('claude-code-mods band: watch of s2 ended: text failure') }) await vi.waitFor(() => { expect(f.warn).toHaveBeenCalledWith('claude-code-mods band: watch of s2 ended: text failure') })
@@ -11,9 +11,6 @@
{ {
"path": "../../../vendor/cordis" "path": "../../../vendor/cordis"
}, },
{
"path": "../../api/gateway/tsconfig.client.json"
},
{ {
"path": "../../api/remotes/tsconfig.client.json" "path": "../../api/remotes/tsconfig.client.json"
}, },
@@ -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.' }], 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.', returns: 'the snapshot after the press.',
}, },
{
signature: 'band(sessionId: string): Promise<SurfaceSnapshot>',
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<void>>', signature: 'async add(definition: ModDefinition): Promise<() => Promise<void>>',
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.', 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', 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', name: 'Branded',
@@ -4756,7 +4750,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'ButtonProps', 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', name: 'ChangeResult',
@@ -7732,7 +7726,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'TextProps', 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', name: 'TimedUserQuestionResult',
-9
View File
@@ -253,12 +253,6 @@ importers:
'@deepseek-ai/dsh-experimental-auto-review': '@deepseek-ai/dsh-experimental-auto-review':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/experimental/auto-review 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': '@deepseek-ai/dsh-experimental-inspector-profile':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/experimental/inspector-profile version: link:../../packages/experimental/inspector-profile
@@ -7809,9 +7803,6 @@ importers:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:~ specifier: workspace:~
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
'@deepseek-ai/dsh-api-gateway':
specifier: workspace:*
version: link:../../api/gateway
'@deepseek-ai/dsh-api-remotes': '@deepseek-ai/dsh-api-remotes':
specifier: workspace:* specifier: workspace:*
version: link:../../api/remotes version: link:../../api/remotes
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
# demo
@@ -0,0 +1 @@
demo workspace
@@ -1 +0,0 @@
# demo
@@ -0,0 +1 @@
demo workspace
+2
View File
@@ -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. // 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"], "@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/testing": ["./packages/experimental/claude-code-mods/tests/support/claude-code-testing.ts"],
"claude-code": ["./packages/experimental/claude-code-mods/tests/support/claude-code.ts"], "claude-code": ["./packages/experimental/claude-code-mods/tests/support/claude-code.ts"],
"@deepseek-ai/dsh/profile-boot": ["./apps/cli/src/profile-boot.ts"], "@deepseek-ai/dsh/profile-boot": ["./apps/cli/src/profile-boot.ts"],