deepseek-harness/packages/hooks/hooks-codex/src/index.ts

305 lines
14 KiB
TypeScript
Raw Normal View History

feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
/**
* 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.
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
* @module @deepseek-ai/dsh-hooks-codex
*/
import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
appendHookInvoked,
appendHookResult,
createDetachedRuns,
DEFAULT_HOOK_TIMEOUT_MS,
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
matchesMatcher,
mergeHookOutputs,
runHook,
type HookOutput,
type MatcherGroup,
type MergedHookOutcome,
} from '@deepseek-ai/dsh-hook-protocol'
import { parseCodexConfig, type CodexHookConfig } from './config.ts'
export const name = 'hooks-codex'
export const inject = ['bash']
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
export interface Config {
fix(hooks): run hooks in the session cwd; honest process-level config + best-effort session-start; surface systemMessage drop Address review on the bridges: - Hook cwd (blocking): the bridges never passed a workdir to runHook, so hooks ran in the executor default (the ACP server launch dir), not the session cwd — a hook doing `pwd`/relative reads/marker writes operated in the wrong tree. Both bridges now thread the agent's session `header.cwd` (the session/new.cwd) as the hook workdir for agent-scoped points. Regression per bridge: server cwd ≠ session cwd, a `pwd` hook proves it ran in the session workspace (proven red without the workdir). - Example config honesty (blocking): `configPath: ./hooks.json` is read ONCE at load against the PROCESS cwd, not per-session — the comment/README now say so explicitly (a project-local per-session hooks.json is not discovered; TODO(per-session-hook-config)). The hooks-run-in-session-cwd fix above is the distinct, separately-documented half. - Session-start timing (blocking): agent/session-start is a synchronous emit and the hook runs on a detached .then, so injected context is BEST-EFFORT — not guaranteed before the first request. Downgrade the contract in code comments + README + RFC (TODO(session-start-gating)) rather than implying "first request sees it", and add a no-wait regression that asserts the safe properties without pre-waiting for the inject. - systemMessage (non-blocking): the merge collects merged.systemMessages but no bridge surfaced it. Warn per hook (like updatedInput) and document it as deferred in both READMEs + the RFC; tests assert the warn + non-surfacing.
2026-07-01 16:34:28 +08:00
/**
* 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.
*/
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
configPath: string
/** The model name stamped on every payload (Codex includes `model` on each event). */
model?: string
/** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */
defaultTimeoutMs?: number
Expose audited hardcoded tunables as plugin config The audit swept every packages/*/* plugin for the new AGENTS.md convention (no hardcoded tunables in plugins) and exposes each finding as a defaulted, validated Config field. Defaults are the previously hardcoded values throughout, so no deployment or golden changes. - tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes, readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow — read-render already documented that the consumer applies the caps, so they become explicit per-request fields. - tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the schemastery default). Also fixes the stale GREP_LIMIT references in search.ts and the web-capability-seam RFC (no such constant exists). - bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The RunInternals.graceMs test seam is gone: graceMs is now a required SpawnSpec field filled from config, so tests exercise the real config path and the defaults live in exactly one place. - subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec fields become required for the same one-defaulting-layer reason. - session-persistence-sqlite: journalMode ('wal' default; the rollback-journal modes serve filesystems where WAL's shared-memory files do not work, e.g. network mounts). - hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted hook/result stderr summary. The duplicated summarize() helpers merge into hook-protocol's summarizeStderr(stderr, maxChars), beside the HookResultRecord field it feeds, with the bound parameterized the same way runHook's defaultTimeoutMs already is. - compact-basic: charsPerToken for the token estimator (default 4, the English-text heuristic; CJK-heavy deployments need ~1-2 or compaction fires far too late). Also corrects the BasicCompactService class doc, which claimed defaults the required-field config never had. - fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead FsIoInternals.streamMinSize seam — the read-routing bound lives in the consumer (tool-fs), where it is now config. This is item 1 of the proposed prune-write-only-fs-surface RFC, annotated accordingly. Every new field gets range validation (following the existing assertPositiveFinite pattern), a README row, and tests covering the configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
/** Character cap for the `hook/result` event's persisted stderr summary. */
stderrSummaryMaxChars?: number
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
}
export const Config: z<Config> = z.object({
configPath: z.string().required(),
model: z.string().default(''),
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
})
let handlerCounter = 0
function nextHandlerId(point: string): string {
return `codex:${point}:${++handlerCounter}`
}
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-codex' }
Fix review findings: validate the hooks cap, integer read caps, doc drift, config plumb-through test A Codex review pass on the draft caught four real gaps and two solid suggestions; all addressed except one pushed back on the merits: - hooks-claude/hooks-codex: stderrSummaryMaxChars was the one new knob with NO range validation — a negative/NaN cap would silently misbehave inside slice(). Both bridges now assert a positive integer at the TOP of apply() (before the config-file parse's early return, so a bad value fails the load loudly), with rejection tests. - tool-fs: the read caps count lines/chars/bytes, so positive-FINITE was too loose (a fractional readLimit would flow into windowing arithmetic and the schema description). All four now require a positive integer, matching tool-web's cap. - Doc drift the gates cannot catch: tool-web's README tools table still named WEB_SEARCH_MAX_RESULTS as the mechanism; compact-basic's README/module doc and the compaction-capability-seam RFC still described estimation as fixed char/4 rather than the charsPerToken default. - subagent-acp: the dispose graces were tested only at the startAcpRun level, so a regression that stopped threading plugin config into AcpRunSpec would have survived. A provider-path test now drives the trap-escalation scenario through ctx.subagents.start with small config graces and bounds dispose at 4s. Pushed back on: converting compact-basic's charsPerToken to a schemastery field. The package's whole config is deliberately hand-rolled (resolveConfig, every threshold REQUIRED with no default — a documented design posture); one schemastery field beside it would be incoherent. The knob is cordis.yml-reachable, defaulted, and validated, which is what the convention requires; migrating the package to schemastery wholesale is pre-existing config-surface hygiene out of this change's scope.
2026-07-04 18:06:35 +08:00
/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`hooks-codex: ${name} must be a positive integer`)
}
}
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
export function apply(ctx: Context, config: Config): void {
Fix review findings: validate the hooks cap, integer read caps, doc drift, config plumb-through test A Codex review pass on the draft caught four real gaps and two solid suggestions; all addressed except one pushed back on the merits: - hooks-claude/hooks-codex: stderrSummaryMaxChars was the one new knob with NO range validation — a negative/NaN cap would silently misbehave inside slice(). Both bridges now assert a positive integer at the TOP of apply() (before the config-file parse's early return, so a bad value fails the load loudly), with rejection tests. - tool-fs: the read caps count lines/chars/bytes, so positive-FINITE was too loose (a fractional readLimit would flow into windowing arithmetic and the schema description). All four now require a positive integer, matching tool-web's cap. - Doc drift the gates cannot catch: tool-web's README tools table still named WEB_SEARCH_MAX_RESULTS as the mechanism; compact-basic's README/module doc and the compaction-capability-seam RFC still described estimation as fixed char/4 rather than the charsPerToken default. - subagent-acp: the dispose graces were tested only at the startAcpRun level, so a regression that stopped threading plugin config into AcpRunSpec would have survived. A provider-path test now drives the trap-escalation scenario through ctx.subagents.start with small config graces and bounds dispose at 4s. Pushed back on: converting compact-basic's charsPerToken to a schemastery field. The package's whole config is deliberately hand-rolled (resolveConfig, every threshold REQUIRED with no default — a documented design posture); one schemastery field beside it would be incoherent. The knob is cordis.yml-reachable, defaulted, and validated, which is what the convention requires; migrating the package to schemastery wholesale is pre-existing config-surface hygiene out of this change's scope.
2026-07-04 18:06:35 +08:00
// 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.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
Fix review findings: validate the hooks cap, integer read caps, doc drift, config plumb-through test A Codex review pass on the draft caught four real gaps and two solid suggestions; all addressed except one pushed back on the merits: - hooks-claude/hooks-codex: stderrSummaryMaxChars was the one new knob with NO range validation — a negative/NaN cap would silently misbehave inside slice(). Both bridges now assert a positive integer at the TOP of apply() (before the config-file parse's early return, so a bad value fails the load loudly), with rejection tests. - tool-fs: the read caps count lines/chars/bytes, so positive-FINITE was too loose (a fractional readLimit would flow into windowing arithmetic and the schema description). All four now require a positive integer, matching tool-web's cap. - Doc drift the gates cannot catch: tool-web's README tools table still named WEB_SEARCH_MAX_RESULTS as the mechanism; compact-basic's README/module doc and the compaction-capability-seam RFC still described estimation as fixed char/4 rather than the charsPerToken default. - subagent-acp: the dispose graces were tested only at the startAcpRun level, so a regression that stopped threading plugin config into AcpRunSpec would have survived. A provider-path test now drives the trap-escalation scenario through ctx.subagents.start with small config graces and bounds dispose at 4s. Pushed back on: converting compact-basic's charsPerToken to a schemastery field. The package's whole config is deliberately hand-rolled (resolveConfig, every threshold REQUIRED with no default — a documented design posture); one schemastery field beside it would be incoherent. The knob is cordis.yml-reachable, defaulted, and validated, which is what the convention requires; migrating the package to schemastery wholesale is pre-existing config-surface hygiene out of this change's scope.
2026-07-04 18:06:35 +08:00
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
let parsed: CodexHookConfig = {}
try {
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
const result = parseCodexConfig(raw)
parsed = result.config
for (const s of result.skipped) {
ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`)
}
} catch (error: unknown) {
ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
return
}
const model = config.model ?? ''
// SessionStart is the one emit-shaped (detached) point Codex has: track its
// run chains so disposal aborts a still-running hook process and drains the
// continuation (docs/defensive-patterns.md: dispose must reach quiescence).
const detached = createDetachedRuns()
ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs')
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
async function runPoint(
point: string,
matchQuery: string,
payload: unknown,
fix(hooks): address Codex review — Stop force-continue, Codex tool_name + plain-stdout context, defer continue:false Round-1 Codex review findings on the bridges: - Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr yielded decision 'deny' + reason undefined, and the `&& reason !== undefined` guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue on any deny; fall back to a generic steering line when there is no reason. - Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher subject, so a real Codex `matcher:"Bash"` never fired against the harness's lowercase `bash` tool. Use exec.name in both payload builders (matches the matcher subject and the sibling CC bridge). Doc/RFC updated. - Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing folded it. runPoint now folds plain stdout into context for those two events, gated on the codec's JSON gate so structured stdout is never dumped as prose. - continue:false is deferred, not honored: the seams have no hard-halt primitive yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two tests now assert the LOG records the halt request AND that the run is NOT actually halted (no longer misleading). - README concurrency wording: hooks run SERIALLY (deliberate — adjacent invoked/result log pairs, order-independent fold), not concurrently. Fixed the CC README claim + an RFC note. Regression guards proven red on the unfixed code, then reverted. The mismatched- hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
2026-07-01 10:48:23 +08:00
opts: { agent?: Agent; turn?: number; signal?: AbortSignal; plainStdoutAsContext?: boolean },
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
): Promise<MergedHookOutcome> {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
2026-07-12 03:36:43 +08:00
// 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.
fix(hooks): run hooks in the session cwd; honest process-level config + best-effort session-start; surface systemMessage drop Address review on the bridges: - Hook cwd (blocking): the bridges never passed a workdir to runHook, so hooks ran in the executor default (the ACP server launch dir), not the session cwd — a hook doing `pwd`/relative reads/marker writes operated in the wrong tree. Both bridges now thread the agent's session `header.cwd` (the session/new.cwd) as the hook workdir for agent-scoped points. Regression per bridge: server cwd ≠ session cwd, a `pwd` hook proves it ran in the session workspace (proven red without the workdir). - Example config honesty (blocking): `configPath: ./hooks.json` is read ONCE at load against the PROCESS cwd, not per-session — the comment/README now say so explicitly (a project-local per-session hooks.json is not discovered; TODO(per-session-hook-config)). The hooks-run-in-session-cwd fix above is the distinct, separately-documented half. - Session-start timing (blocking): agent/session-start is a synchronous emit and the hook runs on a detached .then, so injected context is BEST-EFFORT — not guaranteed before the first request. Downgrade the contract in code comments + README + RFC (TODO(session-start-gating)) rather than implying "first request sees it", and add a no-wait regression that asserts the safe properties without pre-waiting for the inject. - systemMessage (non-blocking): the merge collects merged.systemMessages but no bridge surfaced it. Warn per hook (like updatedInput) and document it as deferred in both READMEs + the RFC; tests assert the warn + non-surfacing.
2026-07-01 16:34:28 +08:00
const workdir = opts.agent?.session.header.cwd
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
for (const group of groups) {
// Codex matches with PURE regex (no literal fast path).
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue
for (const hook of group.hooks) {
const handlerId = nextHandlerId(point)
const session = opts.agent?.session
if (session && opts.turn !== undefined) {
appendHookInvoked(session, {
turn: opts.turn, point, dialect: 'codex', handlerId,
...group.matcher !== undefined ? { matcher: group.matcher } : {},
})
}
const { output, durationMs } = await runHook(ctx.bash, hook, {
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
payload,
defaultTimeoutMs,
fix(hooks): run hooks in the session cwd; honest process-level config + best-effort session-start; surface systemMessage drop Address review on the bridges: - Hook cwd (blocking): the bridges never passed a workdir to runHook, so hooks ran in the executor default (the ACP server launch dir), not the session cwd — a hook doing `pwd`/relative reads/marker writes operated in the wrong tree. Both bridges now thread the agent's session `header.cwd` (the session/new.cwd) as the hook workdir for agent-scoped points. Regression per bridge: server cwd ≠ session cwd, a `pwd` hook proves it ran in the session workspace (proven red without the workdir). - Example config honesty (blocking): `configPath: ./hooks.json` is read ONCE at load against the PROCESS cwd, not per-session — the comment/README now say so explicitly (a project-local per-session hooks.json is not discovered; TODO(per-session-hook-config)). The hooks-run-in-session-cwd fix above is the distinct, separately-documented half. - Session-start timing (blocking): agent/session-start is a synchronous emit and the hook runs on a detached .then, so injected context is BEST-EFFORT — not guaranteed before the first request. Downgrade the contract in code comments + README + RFC (TODO(session-start-gating)) rather than implying "first request sees it", and add a no-wait regression that asserts the safe properties without pre-waiting for the inject. - systemMessage (non-blocking): the merge collects merged.systemMessages but no bridge surfaced it. Warn per hook (like updatedInput) and document it as deferred in both READMEs + the RFC; tests assert the warn + non-surfacing.
2026-07-01 16:34:28 +08:00
...workdir !== undefined ? { cwd: workdir } : {},
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
...opts.signal ? { signal: opts.signal } : {},
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
// Discard a `hookSpecificOutput` block naming a different event.
expectedEventName: point,
}, () => performance.now())
// Clean plain stdout becomes context only when no structured context
// exists; nonzero output and raw JSON never leak as prose.
if (opts.plainStdoutAsContext === true && output.exitCode === 0
&& output.additionalContext === undefined
fix(hooks): address Codex review — Stop force-continue, Codex tool_name + plain-stdout context, defer continue:false Round-1 Codex review findings on the bridges: - Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr yielded decision 'deny' + reason undefined, and the `&& reason !== undefined` guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue on any deny; fall back to a generic steering line when there is no reason. - Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher subject, so a real Codex `matcher:"Bash"` never fired against the harness's lowercase `bash` tool. Use exec.name in both payload builders (matches the matcher subject and the sibling CC bridge). Doc/RFC updated. - Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing folded it. runPoint now folds plain stdout into context for those two events, gated on the codec's JSON gate so structured stdout is never dumped as prose. - continue:false is deferred, not honored: the seams have no hard-halt primitive yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two tests now assert the LOG records the halt request AND that the run is NOT actually halted (no longer misleading). - README concurrency wording: hooks run SERIALLY (deliberate — adjacent invoked/result log pairs, order-independent fold), not concurrently. Fixed the CC README claim + an RFC note. Regression guards proven red on the unfixed code, then reverted. The mismatched- hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
2026-07-01 10:48:23 +08:00
&& output.stdout.length > 0 && !output.stdout.startsWith('{')) {
output.additionalContext = output.stdout
}
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
outputs.push(output)
// Execution and decision mapping remain in each bridge so dialect
// differences stay explicit at their owning seam.
/* jscpd:ignore-start */
fix(hooks): run hooks in the session cwd; honest process-level config + best-effort session-start; surface systemMessage drop Address review on the bridges: - Hook cwd (blocking): the bridges never passed a workdir to runHook, so hooks ran in the executor default (the ACP server launch dir), not the session cwd — a hook doing `pwd`/relative reads/marker writes operated in the wrong tree. Both bridges now thread the agent's session `header.cwd` (the session/new.cwd) as the hook workdir for agent-scoped points. Regression per bridge: server cwd ≠ session cwd, a `pwd` hook proves it ran in the session workspace (proven red without the workdir). - Example config honesty (blocking): `configPath: ./hooks.json` is read ONCE at load against the PROCESS cwd, not per-session — the comment/README now say so explicitly (a project-local per-session hooks.json is not discovered; TODO(per-session-hook-config)). The hooks-run-in-session-cwd fix above is the distinct, separately-documented half. - Session-start timing (blocking): agent/session-start is a synchronous emit and the hook runs on a detached .then, so injected context is BEST-EFFORT — not guaranteed before the first request. Downgrade the contract in code comments + README + RFC (TODO(session-start-gating)) rather than implying "first request sees it", and add a no-wait regression that asserts the safe properties without pre-waiting for the inject. - systemMessage (non-blocking): the merge collects merged.systemMessages but no bridge surfaced it. Warn per hook (like updatedInput) and document it as deferred in both READMEs + the RFC; tests assert the warn + non-surfacing.
2026-07-01 16:34:28 +08:00
if (output.systemMessage !== undefined) {
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
if (session && opts.turn !== undefined) {
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
}
}
}
return mergeHookOutputs(outputs)
}
2026-07-12 03:36:43 +08:00
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
fix(hooks): address Codex review — Stop force-continue, Codex tool_name + plain-stdout context, defer continue:false Round-1 Codex review findings on the bridges: - Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr yielded decision 'deny' + reason undefined, and the `&& reason !== undefined` guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue on any deny; fall back to a generic steering line when there is no reason. - Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher subject, so a real Codex `matcher:"Bash"` never fired against the harness's lowercase `bash` tool. Use exec.name in both payload builders (matches the matcher subject and the sibling CC bridge). Doc/RFC updated. - Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing folded it. runPoint now folds plain stdout into context for those two events, gated on the codec's JSON gate so structured stdout is never dumped as prose. - continue:false is deferred, not honored: the seams have no hard-halt primitive yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two tests now assert the LOG records the halt request AND that the run is NOT actually halted (no longer misleading). - README concurrency wording: hooks run SERIALLY (deliberate — adjacent invoked/result log pairs, order-independent fold), not concurrently. Fixed the CC README claim + an RFC note. Regression guards proven red on the unfixed code, then reverted. The mismatched- hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
2026-07-01 10:48:23 +08:00
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
if (merged.additionalContext.length === 0) return undefined
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
return { content, source: PLUGIN_SOURCE }
}
2026-07-12 03:36:43 +08:00
/** Merge hook context while retaining this bridge's plugin-level source. */
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
if (!theirs) return ours
return { content: [...ours.content, ...theirs.content], source: ours.source }
}
// SessionStart injects plain stdout when its detached hook resolves; a slow
// hook may miss the first request.
2026-07-12 03:36:43 +08:00
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
ctx.on('agent/session-start', (agent, source) => {
detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
.then((merged) => {
const context = contextFrom(merged)
if (context) agent.inject(context.content, { source: context.source })
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
/* jscpd:ignore-end */
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
})
// UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
const turn = lastTurn(agent)
fix(hooks): address Codex review — Stop force-continue, Codex tool_name + plain-stdout context, defer continue:false Round-1 Codex review findings on the bridges: - Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr yielded decision 'deny' + reason undefined, and the `&& reason !== undefined` guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue on any deny; fall back to a generic steering line when there is no reason. - Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher subject, so a real Codex `matcher:"Bash"` never fired against the harness's lowercase `bash` tool. Use exec.name in both payload builders (matches the matcher subject and the sibling CC bridge). Doc/RFC updated. - Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing folded it. runPoint now folds plain stdout into context for those two events, gated on the codec's JSON gate so structured stdout is never dumped as prose. - continue:false is deferred, not honored: the seams have no hard-halt primitive yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two tests now assert the LOG records the halt request AND that the run is NOT actually halted (no longer misleading). - README concurrency wording: hooks run SERIALLY (deliberate — adjacent invoked/result log pairs, order-independent fold), not concurrently. Fixed the CC README claim + an RFC note. Regression guards proven red on the unfixed code, then reverted. The mismatched- hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
2026-07-01 10:48:23 +08:00
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
/* jscpd:ignore-start */
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
// Context alone is not a veto: DELEGATE so a later prompt-submit listener can
// still block/rewrite, then fold our context onto its decision.
const downstream = await next()
const ours = contextFrom(merged)
if (!ours || downstream.kind !== 'allow') return downstream
return {
kind: 'allow',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(ours, downstream.additionalContext),
}
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
})
// PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
/* jscpd:ignore-end */
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
return next()
})
// PostToolUse → PostToolDecision (block with feedback, or attach context).
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
/* jscpd:ignore-start */
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} }
}
// Context alone is not a veto: DELEGATE, then fold our context onto the
// downstream decision (a downstream block carries it too).
const downstream = await next()
if (!context) return downstream
if (downstream.kind === 'block') {
return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(context, downstream.additionalContext),
}
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
})
// 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.
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 */
fix(hooks): address Codex review — Stop force-continue, Codex tool_name + plain-stdout context, defer continue:false Round-1 Codex review findings on the bridges: - Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr yielded decision 'deny' + reason undefined, and the `&& reason !== undefined` guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue on any deny; fall back to a generic steering line when there is no reason. - Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher subject, so a real Codex `matcher:"Bash"` never fired against the harness's lowercase `bash` tool. Use exec.name in both payload builders (matches the matcher subject and the sibling CC bridge). Doc/RFC updated. - Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing folded it. runPoint now folds plain stdout into context for those two events, gated on the codec's JSON gate so structured stdout is never dumped as prose. - continue:false is deferred, not honored: the seams have no hard-halt primitive yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two tests now assert the LOG records the halt request AND that the run is NOT actually halted (no longer misleading). - README concurrency wording: hooks run SERIALLY (deliberate — adjacent invoked/result log pairs, order-independent fold), not concurrently. Fixed the CC README claim + an RFC note. Regression guards proven red on the unfixed code, then reverted. The mismatched- hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
2026-07-01 10:48:23 +08:00
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation; a block with no reason (exit 2,
// empty stderr) still forces it — fall back to a generic steering line
// rather than letting the turn stop.
const text = merged.reason ?? 'continue: blocked by Stop hook'
return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } }
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
}
return next()
})
}
// --- Codex DIALECT payloads: snake_case, model on every event, turn_id on
// turn-scoped events. ---
// These small payload helpers intentionally remain next to the dialect shape;
// sharing them would pull bridge-only agent/LLM dependencies into hook-protocol.
/* jscpd:ignore-start */
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
function lastTurn(agent: Agent | undefined): number {
if (!agent) return 0
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
/* v8 ignore next -- the `: 0` arm is a defensive fallback: when an agent is
present, lastTurn is only called from the mid-turn seams, which always run
inside an open turn, so `last` is always a turn/start here. */
return last?.type === 'turn/start' ? last.data.turn : 0
}
function blocksToText(content: ContentBlock[]): string {
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
}
/* jscpd:ignore-end */
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
/** Base fields on every Codex payload (no turn_id). */
function base(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
return {
session_id: agent?.session.header.id ?? '',
transcript_path: null,
cwd: agent?.session.header.cwd ?? process.cwd(),
hook_event_name: event,
model,
permission_mode: 'default',
}
}
/** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */
function turnBase(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) }
}
/** Extract a `command` string from a tool call's parsed arguments, else ''. */
function commandOf(args: unknown): string {
if (typeof args === 'object' && args !== null && 'command' in args) {
const command: unknown = args.command
if (typeof command === 'string') return command
}
return ''
}
function preToolPayload(exec: ToolExecution, model: string): Record<string, unknown> {
fix(hooks): address Codex review — Stop force-continue, Codex tool_name + plain-stdout context, defer continue:false Round-1 Codex review findings on the bridges: - Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr yielded decision 'deny' + reason undefined, and the `&& reason !== undefined` guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue on any deny; fall back to a generic steering line when there is no reason. - Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher subject, so a real Codex `matcher:"Bash"` never fired against the harness's lowercase `bash` tool. Use exec.name in both payload builders (matches the matcher subject and the sibling CC bridge). Doc/RFC updated. - Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing folded it. runPoint now folds plain stdout into context for those two events, gated on the codec's JSON gate so structured stdout is never dumped as prose. - continue:false is deferred, not honored: the seams have no hard-halt primitive yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two tests now assert the LOG records the halt request AND that the run is NOT actually halted (no longer misleading). - README concurrency wording: hooks run SERIALLY (deliberate — adjacent invoked/result log pairs, order-independent fold), not concurrently. Fixed the CC README claim + an RFC note. Regression guards proven red on the unfixed code, then reverted. The mismatched- hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
2026-07-01 10:48:23 +08:00
// `tool_name` is the REAL tool name (matching the `exec.name` matcher subject);
// a hardcoded constant would disagree with what the matcher tests and make a
// config's tool matcher never fire. `tool_input` keeps Codex's `{ command }`
// shape (its shell payload), derived from the call's `command` arg when present.
return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
}
function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record<string, unknown> {
fix(hooks): address Codex review — Stop force-continue, Codex tool_name + plain-stdout context, defer continue:false Round-1 Codex review findings on the bridges: - Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr yielded decision 'deny' + reason undefined, and the `&& reason !== undefined` guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue on any deny; fall back to a generic steering line when there is no reason. - Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher subject, so a real Codex `matcher:"Bash"` never fired against the harness's lowercase `bash` tool. Use exec.name in both payload builders (matches the matcher subject and the sibling CC bridge). Doc/RFC updated. - Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing folded it. runPoint now folds plain stdout into context for those two events, gated on the codec's JSON gate so structured stdout is never dumped as prose. - continue:false is deferred, not honored: the seams have no hard-halt primitive yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two tests now assert the LOG records the halt request AND that the run is NOT actually halted (no longer misleading). - README concurrency wording: hooks run SERIALLY (deliberate — adjacent invoked/result log pairs, order-independent fold), not concurrently. Fixed the CC README claim + an RFC note. Regression guards proven red on the unfixed code, then reverted. The mismatched- hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
2026-07-01 10:48:23 +08:00
return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F) The two bridge plugins that run a user's existing Claude Code / Codex hook config on the harness's typed interception seams, built on the shared dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power tool: anything it does a native cordis plugin does more powerfully — the bridge exists only to run UNMODIFIED external hooks. - dsh-hooks-claude: CC dialect. Seven hook points (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/ ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher. - dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points, always-regex matcher, snake_case payloads (turn_id/model, no trailing newline), no env/substitution, block-only decisions. Both map the neutral merged outcome onto the seam's typed Decision and stamp an explicit {kind:'plugin'} source on injected context (so it is never mislabeled as a user prompt). Config parse-failure is contained; only command hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop loop-guard is deferred (TODO). Tests: per-file 100% — config-parse unit branches + per-seam mappings end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash (verified on disk). The snapshot normalizer now scrubs hook/result.durationMs. RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:22:00 +08:00
}