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:
@@ -29,6 +29,11 @@ Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a
|
||||
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
|
||||
|
||||
## Input rewrite is parsed but not honored
|
||||
## Model Experience
|
||||
|
||||
`HookOutput.updatedInput` carries a hook's requested tool-input rewrite (CC `updatedInput`), but the harness does not honor it yet — 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)). A bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.
|
||||
Indirectly, through `dsh-hooks-claude` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`HookOutput.updatedInput` is parsed but 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)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.
|
||||
- **An invalid matcher regex matches nothing, silently** — `matchesMatcher` never throws; surfacing the error needs a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`).
|
||||
|
||||
@@ -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)`).
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# @deepseek-ai/dsh-hooks-codex
|
||||
|
||||
A cordis plugin that runs a user's existing **Codex** `hooks.json` on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-specific payloads, matcher mode, and decision mapping.
|
||||
A cordis plugin that runs the supported subset of a user's existing **Codex** hook config on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-shaped payloads, matcher mode, and decision mapping.
|
||||
|
||||
Codex's hook protocol is a deliberate **subset** of Claude Code's (same `hooks.json` shape):
|
||||
This bridge implements a deliberate subset of Codex's current hook protocol:
|
||||
|
||||
- **Five hook points only:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent / notification / compaction hooks.
|
||||
- **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`.
|
||||
- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex).
|
||||
- **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline.
|
||||
- **No env vars and no command substitution** (a literal `${…}` in a command survives verbatim).
|
||||
- **A block-only decision model** — `allow`/`ask` are not honored; a hook can only block, never pre-approve.
|
||||
- **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell).
|
||||
- **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input.
|
||||
|
||||
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only to run UNMODIFIED external Codex hooks faithfully (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; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -32,7 +32,7 @@ In a `cordis.yml`:
|
||||
model: deepseek-v4
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse.
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
|
||||
|
||||
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
|
||||
|
||||
@@ -54,8 +54,27 @@ A tool call's payload carries the real `tool_name` (the same value the matcher t
|
||||
|
||||
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`).
|
||||
|
||||
## Deferred
|
||||
## Model Experience
|
||||
|
||||
**Stop loop-guard** (`TODO(stop-loop-guard)`): as in CC, a Stop hook that unconditionally blocks would force-continue every step (`stop_hook_active` is always `false` here); the loop-guard is deferred. A hook author must self-limit until it lands.
|
||||
### Hook-provided context
|
||||
|
||||
**`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`).
|
||||
**What the model sees**: `SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering.
|
||||
|
||||
**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent 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`. Codex `systemMessage` is not surfaced.
|
||||
|
||||
**Token effect**: Blocking a prompt removes its 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 (5 of Codex's current 10):** `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, and `SubagentStop`. Config for these events is silently dropped during parsing. The comparison baseline is Codex's [official hook reference](https://learn.chatgpt.com/docs/hooks).
|
||||
- **`SessionStart` is partial:** plain stdout and JSON `additionalContext` work, but the hook runs detached, so context can miss the first request (`TODO(session-start-gating)`).
|
||||
- **`UserPromptSubmit` is partial:** blocking plus plain-stdout or JSON context work, but the common `systemMessage` and `{"continue": false}` controls are not enforced.
|
||||
- **`PreToolUse` is partial:** blocking works, but `additionalContext`, `permissionDecision: "allow"`, and `updatedInput` are ignored. Every tool is represented as `tool_input: { command }`, so non-shell tool arguments are not faithfully exposed to the hook.
|
||||
- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `{"continue": false}` is not enforced, non-shell tool arguments are reduced to `{ command }`, and structured tool output is flattened to text in `tool_response`.
|
||||
- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message` is always `null`, and `{"continue": false}` is not enforced. An unconditionally blocking hook therefore force-continues every step unless it self-limits (`TODO(stop-loop-guard)`).
|
||||
- **Common payload and output fields are partial:** every mapped event reports `transcript_path: null`, the statically configured `model`, and `permission_mode: "default"` instead of current Codex runtime values. `systemMessage` is logged + warned but not surfaced, and `{"continue": false}` is recorded but does not apply Codex's event-specific stop behavior (`TODO(hook-continue-false)`).
|
||||
- **Config loading and execution are partial:** one process-level `configPath` is parsed at load; Codex's active user, project, session, system/managed, and plugin layers, trust controls, and inline `config.toml` hook form are not implemented (`TODO(per-session-hook-config)`). Only synchronous `command` handlers run, current metadata such as `statusMessage` and `commandWindows` is ignored, and matching handlers run serially rather than with Codex's concurrent launch semantics.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/** The five hook points Codex's engine supports. */
|
||||
/** The five Codex hook points this bridge supports. */
|
||||
export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
|
||||
|
||||
/** A parsed Codex config: event name → its matcher groups (command hooks only). */
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
* Bridge for unmodified Codex command hooks on harness interception seams. It
|
||||
* supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only
|
||||
* matchers, snake_case payloads without a trailing newline, no hook environment
|
||||
* or command substitution, and block-only decisions; allow/ask do not grant.
|
||||
* or command substitution, and no pre-tool approval or rewrite path; only
|
||||
* blocking decisions are honored. Shared execution and parsing live in
|
||||
* `dsh-hook-protocol`; see the
|
||||
* [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* @module @deepseek-ai/dsh-hooks-codex
|
||||
*/
|
||||
|
||||
@@ -37,7 +40,7 @@ export const inject = ['bash']
|
||||
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative
|
||||
* Path to a Codex `hooks.json`. Process-level: read once at load, a relative
|
||||
* path resolves against the process launch cwd.
|
||||
* TODO(per-session-hook-config): per-session project-local discovery from each
|
||||
* `session/new.cwd` is not yet implemented.
|
||||
@@ -73,8 +76,7 @@ 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
|
||||
@@ -107,12 +109,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), not the executor
|
||||
// default (the server launch dir) — a hook reading a relative file or `pwd` must see the
|
||||
// user's project tree.
|
||||
// Run hooks in the agent's session workspace so relative paths address the
|
||||
// user's project rather than the server launch directory.
|
||||
const workdir = opts.agent?.session.header.cwd
|
||||
for (const group of groups) {
|
||||
// Codex matches with PURE regex (no literal fast path).
|
||||
// Codex always interprets matchers as regexes; it has no literal fast path.
|
||||
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue
|
||||
for (const hook of group.hooks) {
|
||||
const handlerId = nextHandlerId(point)
|
||||
@@ -128,7 +129,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
defaultTimeoutMs,
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
|
||||
trailingNewline: false, // Codex writes stdin without a trailing newline.
|
||||
// Discard a `hookSpecificOutput` block naming a different event.
|
||||
expectedEventName: point,
|
||||
}, () => performance.now())
|
||||
@@ -181,7 +182,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
/* jscpd:ignore-end */
|
||||
})
|
||||
|
||||
// UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).
|
||||
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
|
||||
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
|
||||
const turn = lastTurn(agent)
|
||||
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
|
||||
@@ -232,9 +233,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
|
||||
// Stop → ContinuationDecision. A blocking Stop hook forces continuation.
|
||||
// TODO(stop-loop-guard): like CC, a Stop hook that unconditionally blocks would
|
||||
// force-continue every step (`stop_hook_active` is always false here); the
|
||||
// loop-guard (stop_hook_active + a max-consecutive cap) is deferred.
|
||||
// TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can
|
||||
// avoid continuing the same turn indefinitely. It is always false here, so an
|
||||
// unconditionally blocking hook force-continues every step until it self-limits.
|
||||
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
|
||||
const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -15,10 +15,9 @@ import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Full-loop Codex-bridge tests: scripted mock MODEL + REAL loop + REAL bash +
|
||||
* REAL `dsh-hooks-codex` running REAL shell scripts from a temp `hooks.json`.
|
||||
* Codex dialect specifics exercised here: regex matcher (substring), block-only
|
||||
* decisions, the five-event subset.
|
||||
* Full-loop Codex bridge tests with a mock model, the real loop and bash
|
||||
* executor, and shell hooks from a temporary config. Covers regex matching,
|
||||
* block-only decisions, and the five-event subset.
|
||||
*/
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -90,36 +89,30 @@ describe('hooks-codex bridge', () => {
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
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('codex blocked it'))).toBe(true)
|
||||
// recorded under the codex dialect
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true)
|
||||
})
|
||||
|
||||
it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
|
||||
const dir = configDir()
|
||||
// Block exactly ONCE (a marker file), then allow — without a one-shot guard a
|
||||
// hook that always exits 2 would force-continue forever (the deferred
|
||||
// stop_hook_active loop-guard is the real fix; here we self-limit so the test
|
||||
// exercises the continue path without looping).
|
||||
// Block once with a marker; until the loop guard lands, an always-blocking
|
||||
// hook would never let this test finish.
|
||||
const marker = join(dir, 'fired')
|
||||
const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`)
|
||||
writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] })
|
||||
|
||||
// Step 1 has no tool calls → would stop; the Stop hook forces step 2.
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The Stop hook's reason became next-step steering → a second model request ran.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
|
||||
})
|
||||
|
||||
it('only the five Codex events are honored — a SubagentStop entry is ignored', async () => {
|
||||
it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => {
|
||||
const dir = configDir()
|
||||
const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
// SubagentStop is NOT a Codex event; it must be dropped (no crash, no effect).
|
||||
writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
@@ -127,7 +120,6 @@ describe('hooks-codex bridge', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// Ran normally; the unknown event was dropped at parse.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -193,8 +185,8 @@ describe('hooks-codex bridge', () => {
|
||||
// Disposal reaches quiescence 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('SessionStart hook failed'))
|
||||
})
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts'
|
||||
|
||||
describe('parseCodexConfig', () => {
|
||||
it('honors only the five Codex events, dropping unknown ones', () => {
|
||||
it('honors only the five bridge-supported Codex events, dropping the rest', () => {
|
||||
const { config } = parseCodexConfig({
|
||||
PreToolUse: [{ hooks: [{ type: 'command', command: 'a.sh' }] }],
|
||||
SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // not a Codex event
|
||||
Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // not a Codex event
|
||||
SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // current Codex event, unsupported by this bridge
|
||||
Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // unknown to current Codex
|
||||
})
|
||||
expect(Object.keys(config)).toEqual(['PreToolUse'])
|
||||
expect(CODEX_EVENTS).toContain('PreToolUse')
|
||||
@@ -18,7 +18,7 @@ describe('parseCodexConfig', () => {
|
||||
Stop: [{ hooks: [{ type: 'command', command: '${NOT_SUBSTITUTED}/s.sh', timeout: 10 }] }],
|
||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'u.sh', timeoutSec: 20 }] }],
|
||||
})
|
||||
// Codex does NO substitution — the literal ${…} survives.
|
||||
// The parser performs no config-time substitution; shell expansion happens later.
|
||||
expect(config.Stop).toEqual([{ hooks: [{ command: '${NOT_SUBSTITUTED}/s.sh', timeoutSec: 10 }] }])
|
||||
expect(config.UserPromptSubmit).toEqual([{ hooks: [{ command: 'u.sh', timeoutSec: 20 }] }])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user