Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts
This commit is contained in:
@@ -1,16 +1,13 @@
|
||||
/**
|
||||
* Parse the bridge-supported subset of a Codex `hooks.json` into the shared
|
||||
* {@link MatcherGroup} shape. The bridge accepts five events and the
|
||||
* `{ type: 'command', command, timeout?/timeoutSec? }` hook shape, performs no
|
||||
* config-time placeholder substitution or plugin-env injection, and skips
|
||||
* non-command and `async: true` handlers with a warning.
|
||||
*
|
||||
* Parse Codex's five-event hook subset into shared {@link MatcherGroup}s. Only synchronous command
|
||||
* hooks run; other types and `async: true` commands are recorded as skipped. Codex performs no
|
||||
* command substitution.
|
||||
* @module @deepseek-ai/dsh-hooks-codex/config
|
||||
*/
|
||||
|
||||
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/** The five current Codex hook points this bridge supports. */
|
||||
/** The five Codex hook points this bridge supports. */
|
||||
export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
|
||||
|
||||
/** A parsed Codex config: event name → its matcher groups (command hooks only). */
|
||||
@@ -35,11 +32,8 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw Codex `hooks.json` object into runnable {@link MatcherGroup}s.
|
||||
* Only the five bridge-supported {@link CODEX_EVENTS} are honored; another event is dropped.
|
||||
* `type !== 'command'` and `async: true` command hooks are skipped (recorded in
|
||||
* `skipped`). Malformed entries are ignored rather than thrown — a bad config
|
||||
* must not crash boot. No config-time placeholder substitution is performed.
|
||||
* Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
|
||||
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`.
|
||||
* @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
|
||||
* @returns the runnable per-event groups plus the skipped hooks with their reasons.
|
||||
*/
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
/**
|
||||
* `dsh-hooks-codex` — a bridge plugin that runs a user's existing Codex
|
||||
* `hooks.json` on the harness's canonical interception seams. The CODEX DIALECT
|
||||
* half of the hooks subsystem.
|
||||
*
|
||||
* This bridge supports five of Codex's ten current hook points (`PreToolUse`,
|
||||
* `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`), regex-only
|
||||
* matchers, snake_case stdin payloads with `turn_id`/`model` extras and no
|
||||
* trailing newline, no config-time placeholder substitution or plugin-env
|
||||
* injection, and no pre-tool approval or rewrite path. The dialect-agnostic
|
||||
* primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the
|
||||
* Codex-shaped payloads, matcher mode, and decision mapping.
|
||||
*
|
||||
* 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 no pre-tool approval or rewrite path; only
|
||||
* blocking decisions are honored. Shared execution and parsing live in
|
||||
* `dsh-hook-protocol`; see the
|
||||
* [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* @module @deepseek-ai/dsh-hooks-codex
|
||||
*/
|
||||
|
||||
@@ -45,7 +40,7 @@ export const inject = ['bash']
|
||||
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative
|
||||
* Path to a Codex `hooks.json`. Process-level: read once at load, a relative
|
||||
* path resolves against the process launch cwd.
|
||||
* TODO(per-session-hook-config): per-session project-local discovery from each
|
||||
* `session/new.cwd` is not yet implemented.
|
||||
@@ -81,8 +76,7 @@ function assertPositiveInteger(name: string, value: number): void {
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Validate the cap BEFORE the config-file parse: a bad value must fail the
|
||||
// load loudly, not be skipped by the parse-failure early return.
|
||||
// Validate before config parsing so a bad value cannot be hidden by its early return.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
@@ -115,12 +109,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
): Promise<MergedHookOutcome> {
|
||||
const groups: MatcherGroup[] = parsed[point] ?? []
|
||||
const outputs: HookOutput[] = []
|
||||
// Run the hook in the agent's session workspace (the `session/new` cwd), not
|
||||
// the executor default (the server launch dir) — a hook reading a relative
|
||||
// file or `pwd` must see the user's project tree. Absent for a no-agent run.
|
||||
// Run hooks in the agent's session workspace so relative paths address the
|
||||
// user's project rather than the server launch directory.
|
||||
const workdir = opts.agent?.session.header.cwd
|
||||
for (const group of groups) {
|
||||
// Codex matches with PURE regex (no literal fast path).
|
||||
// Codex always interprets matchers as regexes; it has no literal fast path.
|
||||
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue
|
||||
for (const hook of group.hooks) {
|
||||
const handlerId = nextHandlerId(point)
|
||||
@@ -136,20 +129,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
defaultTimeoutMs,
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
|
||||
trailingNewline: false, // Codex writes stdin without a trailing newline.
|
||||
// Discard a `hookSpecificOutput` block naming a different event.
|
||||
expectedEventName: point,
|
||||
}, () => performance.now())
|
||||
// Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN
|
||||
// (non-JSON) stdout as additionalContext. The codec keeps that raw text on
|
||||
// `output.stdout` but only sets `additionalContext` from a JSON
|
||||
// `hookSpecificOutput`, so fold plain stdout in here and let the shared
|
||||
// merge + contextFrom path carry it. Gated exactly like the codec's own
|
||||
// structured-stdout parse: only on a clean `exitCode === 0` (a non-zero
|
||||
// exit is an error, not context — an `echo x; exit 2` must not inject
|
||||
// `x`), only when stdout is non-JSON (`!startsWith('{')` — a structured
|
||||
// hook's raw JSON is never dumped as prose), and never clobbering an
|
||||
// explicit additionalContext from a JSON block.
|
||||
// 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
|
||||
&& output.stdout.length > 0 && !output.stdout.startsWith('{')) {
|
||||
@@ -170,11 +155,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return mergeHookOutputs(outputs)
|
||||
}
|
||||
|
||||
// TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from
|
||||
// a hook's `continue:false`, but no seam below honors it — there is no
|
||||
// "hard-halt the whole agent" primitive on the interception seams yet. Deferred
|
||||
// with the loop-guard work; until then a `continue:false` hook keeps its
|
||||
// per-point effect and the halt request is recorded in `hook/result`, not acted on.
|
||||
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
|
||||
|
||||
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
|
||||
if (merged.additionalContext.length === 0) return undefined
|
||||
@@ -182,25 +163,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return { content, source: PLUGIN_SOURCE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
|
||||
* call sites) with a downstream listener's optional one, so folding our
|
||||
* additionalContext onto a delegated decision drops neither. The merged block
|
||||
* carries a single `source` — this bridge's — because a `HookContext` holds one
|
||||
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
|
||||
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
|
||||
* downstream plugin's text is still correctly framed as plugin context.
|
||||
*/
|
||||
/** 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: emit. Codex passes a plain-stdout hook's output as additionalContext.
|
||||
// TODO(session-start-gating): a synchronous emit + detached `.then`, so the
|
||||
// injected context is BEST-EFFORT — not guaranteed before the first turn reaches
|
||||
// the model (a slow hook can miss the first request). Gating is a deferred
|
||||
// loop-level change; the contract is "injected as soon as the hook resolves".
|
||||
// SessionStart injects plain stdout when its detached hook resolves; a slow
|
||||
// hook may miss the first request.
|
||||
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
@@ -211,7 +182,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
/* jscpd:ignore-end */
|
||||
})
|
||||
|
||||
// UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).
|
||||
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
|
||||
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
|
||||
const turn = lastTurn(agent)
|
||||
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
|
||||
|
||||
@@ -16,10 +16,9 @@ import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Full-loop Codex-bridge tests: scripted mock MODEL + REAL loop + REAL bash +
|
||||
* REAL `dsh-hooks-codex` running REAL shell scripts from a temp `hooks.json`.
|
||||
* Codex dialect specifics exercised here: regex matcher (substring), block-only
|
||||
* decisions, the five-event subset.
|
||||
* Full-loop Codex bridge tests with a mock model, the real loop and bash
|
||||
* executor, and shell hooks from a temporary config. Covers regex matching,
|
||||
* block-only decisions, and the five-event subset.
|
||||
*/
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -91,28 +90,23 @@ describe('hooks-codex bridge', () => {
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('codex blocked it'))).toBe(true)
|
||||
// recorded under the codex dialect
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true)
|
||||
})
|
||||
|
||||
it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
|
||||
const dir = configDir()
|
||||
// Block exactly ONCE (a marker file), then allow — without a one-shot guard a
|
||||
// hook that always exits 2 would force-continue forever (the deferred
|
||||
// stop_hook_active loop-guard is the real fix; here we self-limit so the test
|
||||
// exercises the continue path without looping).
|
||||
// Block once with a marker; until the loop guard lands, an always-blocking
|
||||
// hook would never let this test finish.
|
||||
const marker = join(dir, 'fired')
|
||||
const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`)
|
||||
writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] })
|
||||
|
||||
// Step 1 has no tool calls → would stop; the Stop hook forces step 2.
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The Stop hook's reason became next-step steering → a second model request ran.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal')
|
||||
})
|
||||
@@ -120,7 +114,6 @@ describe('hooks-codex bridge', () => {
|
||||
it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => {
|
||||
const dir = configDir()
|
||||
const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
// SubagentStop is a current Codex event that this bridge drops (no crash, no effect).
|
||||
writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
|
||||
const adapter = new MockAdapter([textResponse('fine')])
|
||||
@@ -128,7 +121,6 @@ describe('hooks-codex bridge', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// Ran normally; the unknown event was dropped at parse.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -144,10 +136,8 @@ describe('hooks-codex bridge', () => {
|
||||
|
||||
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
||||
const dir = configDir()
|
||||
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it
|
||||
// would veto the prompt (0 model requests) and log a hook/invoked. After a
|
||||
// clean dispose the turn must proceed untouched — this fails loudly on a leak
|
||||
// (a no-op `true` hook would pass even with a leaked listener).
|
||||
// A leaked listener would let this blocking hook veto the prompt and log an invocation; a
|
||||
// no-op hook would pass even when leaked.
|
||||
const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n')
|
||||
writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
@@ -173,10 +163,8 @@ describe('hooks-codex bridge', () => {
|
||||
const dir = configDir()
|
||||
const pidFile = join(dir, 'pid')
|
||||
const marker = join(dir, 'started')
|
||||
// Record the hook shell's PID and touch the marker FIRST so the test can
|
||||
// tell "the hook is genuinely mid-run", then sleep far past the suite
|
||||
// timeout. Dispose must KILL the process (the tracker's abort signal wired
|
||||
// through this bridge's runPoint), not await its exit.
|
||||
// Record the PID and marker before sleeping past the suite timeout. Disposal must abort the
|
||||
// tracked process through `runPoint`, not await its natural exit.
|
||||
const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
|
||||
writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
|
||||
const ctx = new Context()
|
||||
@@ -195,14 +183,11 @@ describe('hooks-codex bridge', () => {
|
||||
await waitFor(() => existsSync(marker))
|
||||
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
||||
await fiber.dispose()
|
||||
// Quiescence, not just promptness: the drain resolves only after the run
|
||||
// settled, and the run settles only after the killed process was reaped —
|
||||
// so by the time dispose returns, the PID must be GONE (kill(pid, 0)
|
||||
// throws ESRCH). An untracked fire-and-forget regression would leave the
|
||||
// process alive (or unreaped) and fail this deterministically.
|
||||
// Disposal reaches quiescence only after the aborted run settles and the process is reaped, so
|
||||
// `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain.
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
// The aborted run resolves as a non-blocking error (runHook never rejects),
|
||||
// so the drained continuation must NOT have logged a failure.
|
||||
// runHook resolves an aborted run as a non-blocking error, so draining must
|
||||
// not log a rejected continuation.
|
||||
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
|
||||
})
|
||||
|
||||
|
||||
@@ -71,9 +71,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// Context alone is not a veto: a downstream agent/prompt-submit listener (a
|
||||
// policy plugin registered after the bridge) must still get to block. The
|
||||
// bridge delegates via next() and folds its context onto the decision.
|
||||
// Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a
|
||||
// downstream policy listener can still block.
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
@@ -439,11 +438,9 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
})
|
||||
|
||||
it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => {
|
||||
// The plain-stdout→context fold is gated on exitCode === 0, matching the
|
||||
// codec's structured-stdout rule. SessionStart is an EMIT (cannot block), so
|
||||
// an `echo stale; exit 2` here is the exact case the gate guards: without it,
|
||||
// the non-clean hook's stdout would wrongly inject "stale". A marker lets us
|
||||
// wait for the detached hook to finish before asserting absence.
|
||||
// SessionStart cannot block, but non-clean stdout still must not become context. The marker
|
||||
// waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches
|
||||
// the codec's structured-stdout rule.
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] })
|
||||
|
||||
Reference in New Issue
Block a user