Merge remote-tracking branch 'origin/master' into worktree-export-jsdoc-gate

# Conflicts:
#	docs/cordis-catalog/services.md
#	docs/persistence-catalog.md
#	docs/rfc/INDEX.md
#	package.json
#	scripts/gen-cordis-catalog.ts
This commit is contained in:
Tianyi Cui
2026-07-07 09:34:12 +08:00
80 changed files with 2956 additions and 160 deletions

View File

@@ -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

View File

@@ -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 }))
})
}

View File

@@ -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, readFileSync, 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,59 @@ 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 pidFile = join(dir, 'pid')
const marker = join(dir, 'started')
const slowHook = join(dir, 'slow.sh')
// 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), not
// await its exit or its 10-minute default hook timeout.
writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\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))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
await hooks.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.
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.
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
})
})