Merge branch 'master' into worktree-fast-gates

This commit is contained in:
Tianyi Cui
2026-07-17 20:22:34 +08:00
294 changed files with 15965 additions and 960 deletions

View File

@@ -41,13 +41,15 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
| Codex hook | Harness seam | Mapping |
|---|---|---|
| `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` |
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` |
| `PreToolUse` | `tools/pre-execute` (waterfall) | `block``PreToolDecision.deny` (no `allow`/`ask`) |
| `PostToolUse` | `tools/post-execute` (waterfall) | `block``block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a Code Mode sub-calls context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) |
| `PostToolUse` | `tools/post-execute` (waterfall) | `block``block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result |
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering |
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.
Every agent-scoped stdin payload carries `session_id` and `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `null`, preserving the Codex `string | null` shape. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn.
`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

View File

@@ -29,6 +29,7 @@
"@deepseek-ai/dsh-hook-protocol": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -40,6 +41,8 @@
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -17,6 +17,7 @@ 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 {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
appendHookInvoked,
@@ -163,17 +164,16 @@ export function apply(ctx: Context, config: Config): void {
return { content, source: PLUGIN_SOURCE }
}
/** 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 }
/** Prepend one context without flattening downstream provenance or metadata. */
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] {
return [ours, ...theirs ?? []]
}
// 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 })
detached.track(runPoint('SessionStart', source, { ...base(ctx, 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 })
@@ -185,7 +185,7 @@ export function apply(ctx: Context, config: Config): void {
// 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 })
const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(ctx, agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true })
/* jscpd:ignore-start */
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
@@ -196,14 +196,14 @@ export function apply(ctx: Context, config: Config): void {
return {
kind: 'allow',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(ours, downstream.additionalContext),
additionalContexts: prependContext(ours, downstream.additionalContexts),
}
})
// 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 } : {} })
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
/* jscpd:ignore-end */
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
return next()
@@ -213,22 +213,22 @@ export function apply(ctx: Context, config: Config): void {
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
/* jscpd:ignore-start */
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, 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 } : {} }
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [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 { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(context, downstream.additionalContext),
additionalContexts: prependContext(context, downstream.additionalContexts),
}
})
@@ -237,7 +237,7 @@ export function apply(ctx: Context, config: Config): void {
// avoid continuing the same turn indefinitely. It is always false here, so an
// unconditionally blocking hook force-continues every step until it self-limits.
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn })
/* jscpd:ignore-end */
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation; a block with no reason (exit 2,
@@ -271,10 +271,12 @@ function blocksToText(content: ContentBlock[]): string {
/* jscpd:ignore-end */
/** Base fields on every Codex payload (no turn_id). */
function base(agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
function base(ctx: Context, agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
return {
session_id: agent?.session.header.id ?? '',
transcript_path: null,
transcript_path: agent === undefined
? null
: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? null,
cwd: agent?.session.header.cwd ?? process.cwd(),
hook_event_name: event,
model,
@@ -283,8 +285,8 @@ function base(agent: Agent | undefined, event: string, model: string): Record<st
}
/** 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)) }
function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record<string, unknown> {
return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(agent)) }
}
/** Extract a `command` string from a tool call's parsed arguments, else ''. */
@@ -296,14 +298,14 @@ function commandOf(args: unknown): string {
return ''
}
function preToolPayload(exec: ToolExecution, model: string): Record<string, unknown> {
function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record<string, unknown> {
// `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 }
return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }
}
function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record<string, unknown> {
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) }
function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record<string, unknown> {
return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
}

View File

@@ -5,6 +5,7 @@ import { join } from 'node:path'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
@@ -23,9 +24,12 @@ function hooks(d: string, h: unknown): string {
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
}
async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise<Context> {
type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string }
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
await ctx.plugin(LlmService); await ctx.plugin(SessionStore)
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
@@ -52,6 +56,28 @@ export type CoverageGroup = 'prompt' | 'post-tool' | 'result-shape' | 'edge-path
export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGroup[]): void {
const selected = new Set(typeof groups === 'string' ? [groups] : groups)
if (selected.has('prompt')) describe('hooks-codex coverage — prompt decision mapping', () => {
it('uses the persistence locator for transcript_path and null without one', async () => {
async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string | null }; expected: string | undefined }> {
const d = dir()
const cap = join(d, 'payload')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('transcript'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
return {
payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null },
expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path,
}
}
const located = await capture(dir())
expect(located.payload.transcript_path).toBe(located.expected)
expect((await capture()).payload.transcript_path).toBeNull()
}, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom.
it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
const d = dir()
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
@@ -90,7 +116,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
})
it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => {
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {
const d = dir()
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')])
@@ -98,7 +124,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.on('agent/prompt-submit', async () => ({
kind: 'allow' as const,
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
additionalContexts: [{
content: [{ type: 'text' as const, text: 'from-downstream' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
envelope: 'raw' as const,
meta: { owner: 'policy' },
}],
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
@@ -106,6 +137,13 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
expect(req).toContain('from-bridge')
expect(req).toContain('from-downstream')
expect(req).toContain('rewritten-prompt')
const contexts = events(agent).filter(event => event.type === 'context/message')
expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'hooks-codex' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
})
@@ -124,6 +162,33 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
const d = dir()
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream-note' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
envelope: 'raw' as const,
meta: { owner: 'policy' },
}],
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const contexts = events(agent).filter(event => event.type === 'context/message')
expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'hooks-codex' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
const d = dir()
hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] })
@@ -405,7 +470,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
expect(result.isError).toBeFalsy()
expect(result.additionalContext?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true)
expect(result.additionalContexts?.[0]?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true)
})
it('when the bash executor REJECTS, the hook/result omits exitCode (non-blocking)', async () => {

View File

@@ -29,6 +29,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../llm/llm"
},