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