Merge remote-tracking branch 'origin/master' into simpl-g-hook-contract
# Conflicts: # packages/hooks/hook-protocol/src/events.ts # packages/hooks/hook-protocol/tests/events.spec.ts # packages/hooks/hooks-claude/README.md # packages/hooks/hooks-claude/src/index.ts # packages/hooks/hooks-codex/README.md # packages/hooks/hooks-codex/src/index.ts
This commit is contained in:
@@ -19,6 +19,8 @@ import type { Config } from '@deepseek-ai/dsh-hooks-codex'
|
||||
const config: Config = {
|
||||
configPath: '/path/to/.codex/hooks.json', // required
|
||||
model: 'deepseek-v4', // optional: stamped on every payload (Codex includes `model`)
|
||||
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none
|
||||
stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
|
||||
import {
|
||||
appendHookInvoked,
|
||||
appendHookResult,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
@@ -47,11 +49,17 @@ export interface Config {
|
||||
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
|
||||
/** Character cap for the `hook/result` event's persisted stderr summary. */
|
||||
stderrSummaryMaxChars?: number
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
let handlerCounter = 0
|
||||
@@ -61,7 +69,19 @@ function nextHandlerId(point: string): string {
|
||||
|
||||
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-codex' }
|
||||
|
||||
/** 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`)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
let parsed: CodexHookConfig = {}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
||||
@@ -103,6 +123,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const output = await runHook(ctx.bash, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
|
||||
@@ -129,7 +150,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
appendHookResult(session, { turn: opts.turn, point, handlerId, output })
|
||||
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,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): Promise<Context> {
|
||||
async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise<Context> {
|
||||
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 })
|
||||
await ctx.plugin(HooksCodex, { configPath, model: 'm' })
|
||||
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
@@ -204,6 +204,29 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
|
||||
})
|
||||
|
||||
it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
|
||||
const d = dir()
|
||||
hooks(d, {})
|
||||
for (const bad of [0, -5, 1.5, Number.NaN]) {
|
||||
const adapter = new MockAdapter([])
|
||||
await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad }))
|
||||
.rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
|
||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
const res = events(agent).find(e => e.type === 'hook/result')
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
})
|
||||
|
||||
it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => {
|
||||
|
||||
Reference in New Issue
Block a user