Kaynağa Gözat

fix(hooks): drain detached hook runs on bridge dispose

The emit-shaped hook points (SessionStart, SubagentStart, SubagentStop)
run fire-and-forget: no seam awaits the run chain, so disposing a bridge
could strand a live hook process and let a late continuation inject into
a disposed context. The floating continuation also made the coverage
gate racy: the only coverage of the SubagentStart continuation's
no-context branch arm rode on an un-awaited .then, and on a loaded CI
runner the fork's per-file coverage snapshot beat it — master run
28798191671 failed the 100% branch gate on hooks-claude/src/index.ts at
99.03% (uncovered line 336) with the identical tree passing the PR run
three minutes earlier.

New shared primitive createDetachedRuns() in dsh-hook-protocol: a bridge
tracks each detached run chain, passes the tracker's abort signal to
runHook, and registers drain() as its effect disposer — drain aborts
still-running hook processes (a kill via the bash seam, not a wait out
to the 10-minute default hook timeout), then resolves once every chain
has settled. fiber.dispose() resolving now means the bridge's detached
work is quiescent (docs/defensive-patterns.md).

The subagent marker test disposes the bridge as its sync point, so the
formerly racy branch arm is executed deterministically before the file's
coverage snapshot; new tests pin abort-on-dispose promptness for both
bridges and the tracker's settle/drain contract in hook-protocol.
Tianyi Cui 2 ay önce
ebeveyn
işleme
9ca31ab193

+ 1 - 1
packages/hooks/README.md

@@ -4,7 +4,7 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau
 
 | Package | Role | Shape |
 |---|---|---|
-| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events | library (no plugin) |
+| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events, detached-run quiescence | library (no plugin) |
 | `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin |
 | `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin |
 

+ 2 - 0
packages/hooks/hook-protocol/README.md

@@ -13,6 +13,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
 | Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
 | Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
 | Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
+| Detached-run quiescence | `createDetachedRuns()` — track fire-and-forget run chains; `drain()` aborts, then awaits them | passes `signal` to each detached `runHook`, registers `drain` as its effect disposer |
 
 ## Primitives
 
@@ -20,6 +21,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
 - **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
 - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
 - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
+- **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no seam awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence).
 
 ## `hook/*` session events
 

+ 71 - 0
packages/hooks/hook-protocol/src/detached.ts

@@ -0,0 +1,71 @@
+/**
+ * Quiescence tracking for a bridge's DETACHED hook runs. The waterfall-shaped
+ * hook points (`UserPromptSubmit`, `PreToolUse`, …) are awaited by their seams,
+ * but the emit-shaped points (`SessionStart`, `SubagentStart`, `SubagentStop`)
+ * run fire-and-forget: no seam awaits them, so without tracking a bridge's
+ * disposal could strand a live hook process and let a late continuation fire
+ * into a disposed context (docs/defensive-patterns.md: dispose must reach
+ * quiescence). A bridge creates one tracker in `apply()`, passes
+ * {@link DetachedRuns.signal} to each detached {@link runHook} call, wraps the
+ * full run chain (the hook run PLUS its `.then` continuation) in
+ * {@link DetachedRuns.track}, and registers {@link DetachedRuns.drain} as its
+ * disposer.
+ *
+ * @module @deepseek-ai/dsh-hook-protocol/detached
+ */
+
+/** In-flight registry for one bridge's detached hook runs; see the module doc for the wiring contract. */
+export interface DetachedRuns {
+  /**
+   * The abort signal every tracked run must hand to {@link runHook} (via its
+   * `signal` option). {@link drain} fires it so a still-running hook process is
+   * killed rather than awaited out to its timeout (default 10 minutes).
+   */
+  readonly signal: AbortSignal
+  /**
+   * Register one detached run until it settles. Pass the FULL chain — the hook
+   * run and its continuation/error handler — so {@link drain} waits for the
+   * side effects (an inject, a warn), not just the process exit. A rejected
+   * chain is absorbed here (settlement bookkeeping only), but rejection
+   * handling is still the caller's job: an untracked `.catch` is what turns a
+   * failure into a logged warning instead of silence.
+   * @param run - the detached run chain to hold until settled.
+   */
+  track(run: Promise<unknown>): void
+  /**
+   * Abort {@link signal}, then resolve once every tracked chain has settled —
+   * including chains tracked while the drain is in progress. The bridge
+   * registers this as its effect disposer; cordis awaits it, so
+   * `fiber.dispose()` resolving means the bridge's detached work is quiescent.
+   * A run tracked AFTER drain resolves is not awaited by anyone — by then the
+   * bridge's listeners are disposed, so nothing can start one.
+   * @returns resolves when all tracked runs have settled.
+   */
+  drain(): Promise<void>
+}
+
+/**
+ * Create a {@link DetachedRuns} tracker (one per bridge `apply()`); settled
+ * runs are pruned so a long-lived session does not accumulate them.
+ * @returns the tracker.
+ */
+export function createDetachedRuns(): DetachedRuns {
+  const inflight = new Set<Promise<unknown>>()
+  const controller = new AbortController()
+  return {
+    signal: controller.signal,
+    track(run: Promise<unknown>): void {
+      inflight.add(run)
+      const settled = (): void => { inflight.delete(run) }
+      void run.then(settled, settled)
+    },
+    async drain(): Promise<void> {
+      controller.abort(new Error('hook bridge disposed'))
+      // Re-check after each wave: a chain can be tracked while a prior wave is
+      // settling; loop until the registry is observed empty.
+      while (inflight.size > 0) {
+        await Promise.allSettled([...inflight])
+      }
+    },
+  }
+}

+ 4 - 0
packages/hooks/hook-protocol/src/index.ts

@@ -15,6 +15,8 @@
  *   session-event helpers (declaration-merged into `SessionEventMap`);
  *   `appendHookResult` derives the durable `decision`/`stderrSummary` from the
  *   {@link HookOutput} so the shared event's semantics live in one place.
+ * - {@link createDetachedRuns} — quiescence tracking for the fire-and-forget
+ *   hook points: disposal aborts and drains a bridge's detached runs.
  *
  * Each bridge owns what genuinely DIFFERS: building the per-event stdin payload
  * (CC vs Codex field sets), the dialect's env/substitution, and mapping the
@@ -38,3 +40,5 @@ export { mergeHookOutputs } from './merge.ts'
 export type { MergedDecision, MergedHookOutcome } from './merge.ts'
 export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts'
 export type { HookInvocation, HookResultRecord } from './events.ts'
+export { createDetachedRuns } from './detached.ts'
+export type { DetachedRuns } from './detached.ts'

+ 68 - 0
packages/hooks/hook-protocol/tests/detached.spec.ts

@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import { createDetachedRuns } from '@deepseek-ai/dsh-hook-protocol'
+
+/** A promise settled from outside, so a test controls exactly when a tracked run finishes. */
+function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
+  let resolve!: () => void
+  let reject!: (error: Error) => void
+  const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
+  return { promise, resolve, reject }
+}
+
+describe('createDetachedRuns', () => {
+  it('starts with an unfired signal; drain fires it (so still-running hook processes get killed)', async () => {
+    const detached = createDetachedRuns()
+    expect(detached.signal.aborted).toBe(false)
+    await detached.drain()
+    expect(detached.signal.aborted).toBe(true)
+    expect(String(detached.signal.reason)).toContain('hook bridge disposed')
+  })
+
+  it('drain with nothing tracked resolves immediately', async () => {
+    await expect(createDetachedRuns().drain()).resolves.toBeUndefined()
+  })
+
+  it('drain waits for a tracked run to settle', async () => {
+    const detached = createDetachedRuns()
+    const run = deferred()
+    detached.track(run.promise)
+    let drained = false
+    const draining = detached.drain().then(() => { drained = true })
+    // Give the drain every chance to (wrongly) resolve before the run settles.
+    await new Promise(resolve => setTimeout(resolve, 10))
+    expect(drained).toBe(false)
+    run.resolve()
+    await draining
+    expect(drained).toBe(true)
+  })
+
+  it('drain waits for a run tracked WHILE a prior wave was settling', async () => {
+    const detached = createDetachedRuns()
+    const first = deferred()
+    const second = deferred()
+    detached.track(first.promise)
+    // The late run enters the registry from the first run's own continuation —
+    // after drain() snapshotted its first wave.
+    void first.promise.then(() => { detached.track(second.promise) })
+    let drained = false
+    const draining = detached.drain().then(() => { drained = true })
+    first.resolve()
+    await new Promise(resolve => setTimeout(resolve, 10))
+    expect(drained).toBe(false)
+    second.resolve()
+    await draining
+    expect(drained).toBe(true)
+  })
+
+  it('a rejected tracked run is absorbed by the settlement bookkeeping (drain still resolves)', async () => {
+    const detached = createDetachedRuns()
+    const run = deferred()
+    detached.track(run.promise)
+    // The caller-side handler every bridge attaches; the tracker's own
+    // bookkeeping must not depend on it, but an UNHANDLED rejection would fail
+    // the test run, which is exactly the guarantee under test.
+    run.promise.catch(() => {})
+    run.reject(new Error('hook run boom'))
+    await expect(detached.drain()).resolves.toBeUndefined()
+  })
+})

+ 2 - 0
packages/hooks/hooks-claude/README.md

@@ -42,6 +42,8 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco
 | `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child |
 | `SubagentStop` | `subagent/end` (emit) | observe-only |
 
+The three emit points run detached — no seam awaits a `SessionStart`/`SubagentStart`/`SubagentStop` hook. Each run chain is tracked, and disposing the bridge aborts still-running hook processes, then drains the continuations before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`).
+
 The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note).
 
 ## Context source

+ 16 - 6
packages/hooks/hooks-claude/src/index.ts

@@ -31,6 +31,7 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
 import {
   appendHookInvoked,
   appendHookResult,
+  createDetachedRuns,
   DEFAULT_HOOK_TIMEOUT_MS,
   DEFAULT_STDERR_SUMMARY_MAX_CHARS,
   matchesMatcher,
@@ -128,6 +129,14 @@ export function apply(ctx: Context, config: Config): void {
     return
   }
 
+  // --- The emit-shaped points (SessionStart, SubagentStart, SubagentStop) run
+  // detached — no seam awaits them — so every run chain is tracked and disposal
+  // aborts still-running hook processes, then drains the continuations
+  // (docs/defensive-patterns.md: dispose must reach quiescence). After the parse
+  // gate: a bridge that registered nothing has nothing to drain. ---
+  const detached = createDetachedRuns()
+  ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')
+
   /**
    * Run every command hook configured for `point` whose matcher selects
    * `matchQuery`, with the per-event `payload` on stdin, and fold the results.
@@ -237,14 +246,14 @@ export function apply(ctx: Context, config: Config): void {
   // to the interception seams; today the contract is "injected as soon as the
   // hook resolves", not "before the first request". ---
   ctx.on('agent/session-start', (agent, source) => {
-    void runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent })
+    detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal })
       .then((merged) => {
         const context = contextFrom(merged)
         if (context) agent.inject(context.content, { source: context.source })
       })
       .catch((error: unknown) => {
         ctx.logger.warn(`hooks-claude: SessionStart hook failed: ${String(error)}`)
-      })
+      }))
   })
 
   // --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no
@@ -330,12 +339,12 @@ export function apply(ctx: Context, config: Config): void {
   // a specific-kind matcher does not (documented in the RFC). ---
   ctx.on('subagent/start', (info) => {
     const child = ctx.get('agents')?.get(info.id)
-    void runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {} })
+    detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
       .then((merged) => {
         const context = contextFrom(merged)
         if (context && child) child.inject(context.content, { source: context.source })
       })
-      .catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) })
+      .catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) }))
   })
   ctx.on('subagent/end', (info) => {
     // Look up the child (still recoverable: `subagent/end` fires from the
@@ -343,9 +352,10 @@ export function apply(ctx: Context, config: Config): void {
     // disposes it) so the hook runs in the child's cwd, not the server default.
     // No `.then`/inject follows (SubagentStop only observes), and no `turn` is
     // passed (so no `hook/*` log records), so runPoint has nothing that can
-    // reject — no `.catch` is needed. Fire-and-forget.
+    // reject — no `.catch` is needed (the tracker's settlement bookkeeping
+    // would absorb one anyway).
     const child = ctx.get('agents')?.get(info.id)
-    void runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {} })
+    detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
   })
 }
 

+ 45 - 8
packages/hooks/hooks-claude/tests/bridge.spec.ts

@@ -1,8 +1,8 @@
-import { afterEach, describe, expect, it } from 'vitest'
-import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { Context } from 'cordis'
+import { Context, type Fiber } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
 import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
@@ -39,6 +39,11 @@ function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): stri
 }
 
 async function harness(configDir: string, adapter: MockAdapter): Promise<Context> {
+  return (await harnessWithFiber(configDir, adapter)).ctx
+}
+
+/** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
+async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promise<{ ctx: Context; hooks: Fiber }> {
   const ctx = new Context()
   await ctx.plugin(LlmService)
   await ctx.plugin(SessionStore)
@@ -47,9 +52,9 @@ async function harness(configDir: string, adapter: MockAdapter): Promise<Context
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
-  await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
+  const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
   ctx.llm.registerAdapter(['mock'], adapter)
-  return ctx
+  return { ctx, hooks }
 }
 
 function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
@@ -285,19 +290,51 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
     } }))
 
     const adapter = new MockAdapter([])
-    const ctx = await harness(dir, adapter)
+    const { ctx, hooks } = await harnessWithFiber(dir, adapter)
     // Drive the observe-only lifecycle events directly (no real child needed — the
-    // bridge just listens). The agents registry is absent here, so SubagentStart's
+    // bridge just listens). No child agent is registered, so SubagentStart's
     // child lookup yields undefined and it simply runs the hook.
     ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
     ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
 
     // Both hooks run async (detached .then); poll for their marker files rather
     // than a fixed sleep that flakes under load.
-    const { existsSync } = await import('node:fs')
     await waitFor(() => existsSync(startMarker) && existsSync(stopMarker))
     expect(existsSync(startMarker)).toBe(true)
     expect(existsSync(stopMarker)).toBe(true)
+    // The markers prove the hook PROCESSES ran, not that the detached `.then`
+    // continuations did (`touch` lands before the process exits). Dispose drains
+    // them, so the no-context arm of the SubagentStart continuation — covered
+    // only here — executes before this file's coverage snapshot instead of
+    // racing it (the arm went uncovered on a loaded CI runner and failed the
+    // per-file 100% branch gate).
+    await hooks.dispose()
+  })
+
+  it('disposing the bridge aborts a still-running hook and drains to quiescence', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
+    dirs.push(dir)
+    const marker = join(dir, 'started')
+    const slowHook = join(dir, 'slow.sh')
+    // Touch the marker FIRST so the test can tell "the hook is genuinely
+    // mid-run", then sleep far past the suite timeout: dispose resolving at all
+    // proves the drain KILLED the process (the tracker's abort signal) instead
+    // of awaiting its exit or its 10-minute default hook timeout.
+    writeFileSync(slowHook, `#!/usr/bin/env bash\ntouch "${marker}"\nsleep 30\n`)
+    chmodSync(slowHook, 0o755)
+    writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
+      SubagentStart: [{ hooks: [{ type: 'command', command: slowHook }] }],
+    } }))
+
+    const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([]))
+    const warn = vi.fn()
+    ctx.logger.warn = warn as never
+    ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
+    await waitFor(() => existsSync(marker))
+    await hooks.dispose()
+    // The aborted run resolves as a non-blocking error (runHook never rejects),
+    // so the drained continuation must NOT have logged a failure.
+    expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
   })
 })
 

+ 2 - 0
packages/hooks/hooks-codex/README.md

@@ -48,6 +48,8 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
 
 A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers.
 
+`SessionStart` — the one emit point — runs detached; each run chain is tracked, and disposing the bridge aborts a still-running hook process, then drains the continuation before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`).
+
 ## Context source
 
 Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`).

+ 9 - 2
packages/hooks/hooks-codex/src/index.ts

@@ -24,6 +24,7 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
 import {
   appendHookInvoked,
   appendHookResult,
+  createDetachedRuns,
   DEFAULT_HOOK_TIMEOUT_MS,
   DEFAULT_STDERR_SUMMARY_MAX_CHARS,
   matchesMatcher,
@@ -97,6 +98,12 @@ export function apply(ctx: Context, config: Config): void {
 
   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')
+
   async function runPoint(
     point: string,
     matchQuery: string,
@@ -189,12 +196,12 @@ export function apply(ctx: Context, config: Config): void {
   // 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".
   ctx.on('agent/session-start', (agent, source) => {
-    void runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true })
+    detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
       .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)}`) })
+      .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
   })
 
   // UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask).

+ 40 - 2
packages/hooks/hooks-codex/tests/bridge.spec.ts

@@ -1,5 +1,5 @@
-import { afterEach, describe, expect, it } from 'vitest'
-import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from 'cordis'
@@ -62,6 +62,15 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
 }
 function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
 
+/** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */
+async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
+  const deadline = Date.now() + timeout
+  while (!predicate()) {
+    if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
+    await new Promise(r => setTimeout(r, interval))
+  }
+}
+
 describe('hooks-codex bridge', () => {
   it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => {
     const dir = configDir()
@@ -159,6 +168,35 @@ describe('hooks-codex bridge', () => {
     expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran
   })
 
+  it('disposing the bridge aborts a still-running SessionStart hook and drains to quiescence', async () => {
+    const dir = configDir()
+    const marker = join(dir, 'started')
+    // Touch the marker FIRST so the test can tell "the hook is genuinely
+    // mid-run", then sleep far past the suite timeout: dispose resolving at all
+    // proves the drain KILLED the process (the tracker's abort signal wired
+    // through this bridge's runPoint) instead of awaiting its exit.
+    const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nsleep 30\n`)
+    writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(AgentLoop, { agents: [] })
+    await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
+    const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
+    ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
+    const warn = vi.fn()
+    ctx.logger.warn = warn as never
+    ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // fires agent/session-start
+    await waitFor(() => existsSync(marker))
+    await fiber.dispose()
+    // The aborted run resolves as a non-blocking error (runHook never rejects),
+    // so the drained continuation must NOT have logged a failure.
+    expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
+  })
+
   it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
     expect('default' in HooksCodex).toBe(false)
     expect(HooksCodex.name).toBe('hooks-codex')