Merge remote-tracking branch 'origin/master' into codex/trim-ai-prose

# Conflicts:
#	docs/AGENTS.md
#	docs/config-catalog.md
#	packages/bash/bash-sandbox/src/index.ts
#	packages/bash/bash/src/session-mode.ts
#	packages/bash/tool-bash/README.md
#	packages/code-runtime/code-runtime-worker/README.md
#	packages/compact/compact/src/index.ts
#	packages/core/agent-core/README.md
#	packages/hooks/hooks-claude/src/config.ts
#	packages/hooks/hooks-claude/src/index.ts
#	packages/hooks/hooks-codex/src/config.ts
#	packages/hooks/hooks-codex/src/index.ts
#	packages/llm/llm/README.md
#	packages/session-persistence/session-persistence-jsonl/README.md
#	packages/session-persistence/session-persistence/README.md
#	packages/skill/skill-local/README.md
#	packages/support/acp-snapshot/README.md
#	packages/support/invariants/src/index.ts
#	packages/ui/acp/README.md
#	packages/ui/jsonrpc-agent/README.md
#	packages/ui/jsonrpc/README.md
#	packages/ui/permission/README.md
#	packages/ui/user-approval/README.md
#	packages/ui/user-interaction/README.md
#	packages/web/web-search-deepseek/README.md
This commit is contained in:
Tianyi Cui
2026-07-14 14:37:16 +08:00
105 changed files with 2343 additions and 339 deletions

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-hooks-claude
A cordis plugin that runs a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns CC's per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md).
A cordis plugin that runs the supported command-hook subset of a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns the bridge's CC-shaped per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md).
A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only to run UNMODIFIED external CC hooks faithfully**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only as a compatibility path for the mapped CC command-hook subset**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
## Config
@@ -26,7 +26,7 @@ In a `cordis.yml`:
projectDir: .
```
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
@@ -50,8 +50,28 @@ The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session s
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself.
## Deferred (faithful-but-degraded)
## Model Experience
- **`updatedInput` (tool-input rewrite)** is logged + warned, **not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
- **`systemMessage`** (a hook's user-facing warning) is logged + warned, **not surfaced** — there is no user-message channel on these seams yet (only model-facing `additionalContext`). The shared merge collects it; the bridge does not yet render it.
- **Stop loop-guard.** CC breaks an infinite force-continue with `stop_hook_active` (true once a Stop hook has fired this run) plus a max-consecutive cap; both are deferred (`TODO(stop-loop-guard)`). Today `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands.
### Hook-provided context
**What the model sees**: `SessionStart`, accepted prompt, post-tool, and live in-process subagent-start hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. Remote-child injection has no local target.
**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent in later conversation requests until compaction.
### Blocked prompt or tool outcome
**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. `systemMessage` and `updatedInput` are logged or warned but are not model-visible in this implementation.
**Token effect**: Blocking a prompt removes that prompt's request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
## Known Limitations and Deferred Work
- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
- **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`.
- **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout.
- **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `updatedToolOutput` and `updatedMCPToolOutput` are unsupported and `tool_response` is flattened to text.
- **`SubagentStart` and `SubagentStop` are partial:** both report a constant `agent_type` of `general-purpose` and use the child session id where Claude Code reports the parent session. Start context is best-effort and can only reach a live in-process child, while stop is observe-only and cannot block the subagent or feed it context. Start omits `transcript_path`; stop also omits `agent_transcript_path`, `last_assistant_message`, `background_tasks`, and `session_crons` and always reports `stop_hook_active: false`.
- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message`, `background_tasks`, and `session_crons` are omitted, and the consecutive-block cap is not implemented (`TODO(stop-loop-guard)`). An unconditionally blocking hook therefore force-continues every step unless it self-limits.
- **Common payload and output fields are partial:** mapped event payloads omit `prompt_id`, `transcript_path`, `permission_mode`, and `effort` where Claude Code would provide them. `systemMessage` is logged + warned but not surfaced; `{"continue": false}` is recorded but does not halt the run; `suppressOutput`, `stopReason`, and `terminalSequence` are not applied (`TODO(hook-continue-false)`).
- **Handler and config support is partial:** only shell-form command handlers run. `http`, `mcp_tool`, `prompt`, and `agent` handlers are skipped; command-handler options such as `args`, `async`, `asyncRewake`, `shell`, `if`, `once`, and `statusMessage` are not honored. Matching handlers run serially and are not deduplicated, whereas Claude Code runs them in parallel and deduplicates identical handlers. One process-level `configPath` is parsed once at load; Claude Code's layered project, user, plugin, and policy discovery and live reload are not implemented (`TODO(per-session-hook-config)`).

View File

@@ -1,7 +1,8 @@
/**
* Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s.
* Command hooks run after `${CLAUDE_PLUGIN_ROOT}` substitution. Other supported hook types are
* parsed but skipped with a warning, matching the bridge's faithful-but-degraded policy.
* Only command hooks run; other hook types are returned as skipped so the
* bridge can warn. Plugin-root and project-directory substitutions are applied
* to commands at parse time.
* @module @deepseek-ai/dsh-hooks-claude/config
*/

View File

@@ -1,9 +1,11 @@
/**
* Bridge for unmodified Claude Code command hooks on harness interception
* seams. It supports SessionStart, prompt/tool pre/post, Stop, and subagent
* start/stop; owns Claude payloads, environment and plugin-root substitution;
* and logs but does not honor `updatedInput`. Bespoke behavior should use typed
* native plugins on the same seams.
* start/stop. It owns Claude payloads, environment, substitution, and decision
* mapping; shared execution and parsing live in `dsh-hook-protocol`.
* `updatedInput` is logged and warned but not honored. Bespoke behavior should
* use typed native plugins on the same seams; see the
* [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md).
* @module @deepseek-ai/dsh-hooks-claude
*/
@@ -41,7 +43,7 @@ export const inject = ['bash']
export interface Config {
/**
* Path to a `hooks.json` or a settings file whose `hooks` key holds the config.
* PROCESS-LEVEL: read once at load, a relative path resolves against the process
* Process-level: read once at load, a relative path resolves against the process
* launch cwd, so one config applies to the whole process.
* TODO(per-session-hook-config): per-session discovery of a project-local
* `hooks.json` from each `session/new.cwd` is not yet implemented.
@@ -90,14 +92,11 @@ function assertPositiveInteger(name: string, value: number): void {
}
export function apply(ctx: Context, config: Config): void {
// Validate the cap BEFORE the config-file parse: a bad value must fail the
// load loudly, not be skipped by the parse-failure early return.
// Validate before config parsing so a bad value cannot be hidden by its early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
// --- Parse the config ONCE at load. A read/parse failure is contained: the
// bridge logs and registers nothing rather than crashing boot (a typo'd path
// must not take the agent down). ---
// Parse once at load. A read or parse failure logs and registers nothing.
let parsed: ClaudeHookConfig = {}
try {
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
@@ -114,10 +113,8 @@ export function apply(ctx: Context, config: Config): void {
return
}
// --- The emit-shaped points (SessionStart, SubagentStart, SubagentStop) run detached — no
// seam awaits them — so every run chain is tracked and disposal aborts still-running hook
// processes, then drains the continuations (docs/defensive-patterns.md: dispose must reach
// quiescence).
// Emit-shaped points run detached, so track their chains; disposal aborts
// active hooks and drains continuations before resolving.
const detached = createDetachedRuns()
ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')
@@ -138,11 +135,11 @@ export function apply(ctx: Context, config: Config): void {
): Promise<MergedHookOutcome> {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
// Run the hook in the AGENT'S session workspace (the `session/new` cwd on the session
// Run the hook in the agent's session workspace (the `session/new` cwd on the session
// header), not the executor default (the ACP server's launch dir).
const workdir = opts.agent?.session.header.cwd
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session
// workspace (the same dir the hook RUNS in).
// workspace (the same dir the hook runs in).
const projectDir = config.projectDir ?? workdir
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
for (const group of groups) {

View File

@@ -15,11 +15,8 @@ import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL
* bash executor, and the REAL `dsh-hooks-claude` bridge runs REAL shell hook
* scripts written to a temp dir — only the model is mocked (the "prefer the real
* implementation" rule). Each test writes a `hooks.json` + executable scripts,
* loads the bridge pointed at them, and asserts the hook's effect on the loop.
* Full-loop Claude bridge tests with a mock model, the real loop and bash
* executor, and shell hooks from a temporary config.
*/
const dirs: string[] = []
@@ -193,7 +190,6 @@ describe('hooks-claude bridge — PostToolUse', () => {
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
// PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback.
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true)
})
@@ -331,8 +327,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
// Disposal reaches quiescence: it returns only after the aborted run settles and the process
// is reaped, so `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain.
expect(() => process.kill(pid, 0)).toThrow()
// The aborted run resolves as a non-blocking error (runHook never rejects),
// so the drained continuation must NOT have logged a failure.
// runHook resolves an aborted run as a non-blocking error, so draining must
// not log a rejected continuation.
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
})
})