Merge remote-tracking branch 'origin/master' into simpl-e-acp-trim
# Conflicts: # docs/rfc/README.md
This commit is contained in:
@@ -82,8 +82,9 @@ function versionOf(info: Stats): FsVersion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: lets specs pin the temp-file name (to prove exclusive-open
|
||||
* behavior) without a name race.
|
||||
* Test seam: lets specs pin the atomic-write temp names (to prove
|
||||
* exclusive-open behavior without a name race) and observe the staged temp
|
||||
* file before it is renamed over the target.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override the generated private staging-dir name (relative to the target dir). */
|
||||
|
||||
@@ -104,7 +104,7 @@ export class LocalFileSystem extends FileSystem {
|
||||
|
||||
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
|
||||
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
|
||||
return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
return { targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
@@ -127,7 +127,7 @@ export class LocalFileSystem extends FileSystem {
|
||||
return entries.map(entry => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
target: { inputPath: entry.name, targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
|
||||
target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
|
||||
...(entry.version !== undefined ? { version: entry.version } : {}),
|
||||
...(entry.size !== undefined ? { size: entry.size } : {}),
|
||||
}))
|
||||
@@ -210,8 +210,6 @@ export class LocalFileSystem extends FileSystem {
|
||||
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
replacements: edited.replacements,
|
||||
replaceAll: edit.replaceAll,
|
||||
version: this.versionAfterWrite(after, target),
|
||||
// The LF-normalized before/after text (the applied-hunk diff basis);
|
||||
// line-ending restoration is a storage detail the diff ignores.
|
||||
|
||||
@@ -138,12 +138,6 @@ describe('listDir', () => {
|
||||
join(dir, 'skills', 'dir-skill'),
|
||||
join(dir, 'skills', 'zeta.md'),
|
||||
])
|
||||
expect(entries.map(entry => entry.target.inputPath)).toEqual([
|
||||
'alpha.md',
|
||||
'broken-link',
|
||||
'dir-skill',
|
||||
'zeta.md',
|
||||
])
|
||||
const materializedEntries = entries.filter(entry => entry.version !== undefined)
|
||||
expect(materializedEntries.map(entry => entry.target.targetKey))
|
||||
.toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath))))
|
||||
@@ -335,7 +329,7 @@ describe('editText', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) })
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(outcome.after).toBe('hello there')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
@@ -365,7 +359,7 @@ describe('editText', () => {
|
||||
const target = await fs.resolve('a.txt')
|
||||
// No version guard: any current content is edited, regardless of version.
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(outcome.after).toBe('hello there')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
@@ -411,7 +405,7 @@ describe('editText', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) })
|
||||
expect(outcome.replacements).toBe(3)
|
||||
expect(outcome.after).toBe('b b b')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
||||
})
|
||||
|
||||
@@ -457,7 +451,7 @@ describe('editText', () => {
|
||||
// The version the first edit returned is a valid guard for a second edit —
|
||||
// no intervening re-stat needed.
|
||||
const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version })
|
||||
expect(second.replacements).toBe(1)
|
||||
expect(second.after).toBe('ONE TWO')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO')
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
function target(path: string): FsTarget {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
return { targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ A backend subclasses `FileSystem` and implements seven primitives.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
|
||||
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
|
||||
|
||||
@@ -52,8 +52,6 @@ export function FsVersion(v: string): FsVersion {
|
||||
* this; every other operation takes it.
|
||||
*/
|
||||
export interface FsTarget {
|
||||
/** The original model/plugin-supplied path, for diagnostics only. */
|
||||
inputPath: string
|
||||
/** Opaque key for stale guards and target lookup. */
|
||||
targetKey: FsTargetKey
|
||||
/**
|
||||
@@ -142,10 +140,6 @@ export interface FsEditRequest {
|
||||
|
||||
/** Outcome of a literal edit. */
|
||||
export interface FsEditOutcome {
|
||||
/** Number of literal replacements applied. */
|
||||
replacements: number
|
||||
/** Whether every match was replaced. */
|
||||
replaceAll: boolean
|
||||
/** Opaque version of the file after the edit. */
|
||||
version: FsVersion
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,7 @@ class FakeFileSystem extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
return { targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
@@ -45,7 +45,7 @@ class FakeFileSystem extends FileSystem {
|
||||
{
|
||||
name: 'alpha.md',
|
||||
type: 'file',
|
||||
target: { inputPath: 'skills/alpha.md', targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' },
|
||||
target: { targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' },
|
||||
size: 2,
|
||||
version: FsVersion('v1'),
|
||||
},
|
||||
@@ -60,7 +60,7 @@ class FakeFileSystem extends FileSystem {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
const after = content.split(edit.oldString).join(edit.newString)
|
||||
this.files.set(target.targetKey, after)
|
||||
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after }
|
||||
return { version: FsVersion('v3'), before: content, after }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('FileSystem provider seam', () => {
|
||||
expect(entries).toEqual([{
|
||||
name: 'alpha.md',
|
||||
type: 'file',
|
||||
target: { inputPath: 'skills/alpha.md', targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' },
|
||||
target: { targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' },
|
||||
size: 2,
|
||||
version: 'v1',
|
||||
}])
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsEditOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
@@ -43,9 +42,9 @@ export function parseEditArgs(args: { file_path: string; old_string: string; new
|
||||
}
|
||||
}
|
||||
|
||||
/** Format an edit outcome as a Claude-style model-facing success message. */
|
||||
export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string {
|
||||
return outcome.replaceAll
|
||||
/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */
|
||||
export function formatEditOutput(displayPath: string, replaceAll: boolean): string {
|
||||
return replaceAll
|
||||
? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
|
||||
: `The file ${displayPath} has been updated successfully.`
|
||||
}
|
||||
@@ -91,7 +90,7 @@ export function applyEditTool(ctx: Context): void {
|
||||
// relativizes it).
|
||||
const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after)
|
||||
return {
|
||||
content: [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }],
|
||||
content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }],
|
||||
meta: { diffs },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
*/
|
||||
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */
|
||||
export const READ_MAX_LINE_LENGTH = 2000
|
||||
@@ -59,16 +58,12 @@ export interface WindowResult {
|
||||
export interface FileReadOutcome {
|
||||
/** 1-based first line requested. */
|
||||
offset: number
|
||||
/** Maximum number of lines requested. */
|
||||
limit: number
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
truncatedByBytes?: true
|
||||
/** Opaque version of the file at read time. */
|
||||
version: FsVersion
|
||||
}
|
||||
|
||||
interface WindowAccumulator {
|
||||
|
||||
@@ -109,10 +109,8 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
|
||||
const outcome: FileReadOutcome = {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
lines: window.lines,
|
||||
totalLines: window.totalLines,
|
||||
version: info.version,
|
||||
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens). The
|
||||
|
||||
@@ -40,7 +40,7 @@ class FakeFs extends FileSystem {
|
||||
}
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
|
||||
return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
this.throwIfArmed()
|
||||
@@ -71,7 +71,7 @@ class FakeFs extends FileSystem {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
const after = content.split(edit.oldString).join(edit.newString)
|
||||
this.files.set(target.targetKey, after)
|
||||
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after }
|
||||
return { version: FsVersion('v3'), before: content, after }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ describe('read tool', () => {
|
||||
})
|
||||
|
||||
describe('formatReadOutput footer variants', () => {
|
||||
const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') }
|
||||
const base: FileReadOutcome = { offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1 }
|
||||
|
||||
it('reports a byte-capped read', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
|
||||
|
||||
@@ -12,13 +12,13 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
|
||||
| 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) | calls them around each invocation |
|
||||
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
|
||||
|
||||
## Primitives
|
||||
|
||||
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
|
||||
- **`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 `defaultTimeoutMs`), 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`/`suppressOutput` 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.
|
||||
- **`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.
|
||||
|
||||
## `hook/*` session events
|
||||
@@ -26,7 +26,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`):
|
||||
|
||||
- `hook/invoked` — `{ turn, point, dialect, matcher?, handlerId }`: a hook command ran.
|
||||
- `hook/result` — `{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`.
|
||||
- `hook/result` — `{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`. `appendHookResult` owns the semantics: `decision` is the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`; `stderrSummary` is the trimmed stderr truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
|
||||
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import type { HookOutput } from './types.ts'
|
||||
|
||||
/** The exit code a hook uses to signal a blocking error (stderr → model). */
|
||||
export const BLOCKING_EXIT_CODE = 2
|
||||
const BLOCKING_EXIT_CODE = 2
|
||||
|
||||
/** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
|
||||
function str(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
@@ -72,7 +72,7 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
|
||||
* `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a
|
||||
* `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still
|
||||
* surfaced (for the log/diagnostics), and the event-agnostic top-level fields
|
||||
* (`decision`/`reason`/`continue`/`stopReason`/`suppressOutput`/`systemMessage`)
|
||||
* (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`)
|
||||
* are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
|
||||
* block as-is — a caller that doesn't key by event opts out of the check.
|
||||
*/
|
||||
@@ -125,8 +125,6 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>, ex
|
||||
if (cont !== undefined) output.continue = cont
|
||||
const stopReason = str(parsed, 'stopReason')
|
||||
if (stopReason !== undefined) output.stopReason = stopReason
|
||||
const suppress = bool(parsed, 'suppressOutput')
|
||||
if (suppress !== undefined) output.suppressOutput = suppress
|
||||
const sysMsg = str(parsed, 'systemMessage')
|
||||
if (sysMsg !== undefined) output.systemMessage = sysMsg
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { HookDialect } from './types.ts'
|
||||
import type { HookDialect, HookOutput } from './types.ts'
|
||||
|
||||
/** What identifies a hook invocation across its invoked/result pair. */
|
||||
export interface HookInvocation {
|
||||
@@ -37,16 +37,42 @@ export interface HookResultRecord {
|
||||
turn: number
|
||||
point: string
|
||||
handlerId: string
|
||||
/** The dialect-neutral decision the bridge resolved (`deny`/`allow`/`block`/…). */
|
||||
decision: string
|
||||
/** The process exit code (absent when the hook could not run). */
|
||||
exitCode?: number
|
||||
/** A truncated stderr summary (the block-reason source on exit 2). */
|
||||
stderrSummary?: string
|
||||
/** Wall-clock duration of the run. */
|
||||
/**
|
||||
* The decoded outcome the run produced. {@link appendHookResult} derives the
|
||||
* durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared
|
||||
* event's semantics live here, in the lib that declares it, not per-bridge.
|
||||
*/
|
||||
output: HookOutput
|
||||
/**
|
||||
* Character cap for the derived `stderrSummary`. The bound is the bridge's
|
||||
* to own (its `stderrSummaryMaxChars` config) and is passed in explicitly —
|
||||
* {@link DEFAULT_STDERR_SUMMARY_MAX_CHARS} is the reference default.
|
||||
*/
|
||||
stderrSummaryMaxChars: number
|
||||
/** Wall-clock duration of the run (from `runHook`) — durable audit timing. */
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The reference default for {@link HookResultRecord.stderrSummaryMaxChars}
|
||||
* (both bridges' config default). It lives here, once, next to the truncation
|
||||
* rule it bounds, so the bridges cannot drift apart on the shared event's
|
||||
* default cap.
|
||||
*/
|
||||
export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500
|
||||
|
||||
/**
|
||||
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
|
||||
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
||||
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
||||
* the config default and passes it in.
|
||||
*/
|
||||
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
|
||||
}
|
||||
|
||||
/** Append a `hook/invoked` provenance event to `session`. */
|
||||
export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
|
||||
session.append('hook/invoked', {
|
||||
@@ -59,26 +85,23 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
|
||||
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
||||
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
||||
* the config default and passes it in.
|
||||
* Append a `hook/result` outcome event to `session` (pairs with a prior
|
||||
* `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's
|
||||
* parsed decision, else `'stop'` when it asked to halt (`continue: false`),
|
||||
* else `'pass'`; `stderrSummary` is the trimmed stderr truncated to
|
||||
* `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode`
|
||||
* is omitted when the hook never ran.
|
||||
*/
|
||||
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
|
||||
}
|
||||
|
||||
/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */
|
||||
export function appendHookResult(session: Session, record: HookResultRecord): void {
|
||||
const { output } = record
|
||||
const stderrSummary = summarizeStderr(output.stderr, record.stderrSummaryMaxChars)
|
||||
session.append('hook/result', {
|
||||
turn: record.turn,
|
||||
point: record.point,
|
||||
handlerId: record.handlerId,
|
||||
decision: record.decision,
|
||||
...record.exitCode !== undefined ? { exitCode: record.exitCode } : {},
|
||||
...record.stderrSummary !== undefined ? { stderrSummary: record.stderrSummary } : {},
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
durationMs: record.durationMs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* - {@link mergeHookOutputs} — fold multiple matched hooks into one
|
||||
* most-restrictive {@link MergedHookOutcome} (deny > ask > allow).
|
||||
* - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*`
|
||||
* session-event helpers (declaration-merged into `SessionEventMap`).
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
@@ -29,10 +31,10 @@ export type {
|
||||
MatcherMode,
|
||||
} from './types.ts'
|
||||
export { matchesMatcher } from './matcher.ts'
|
||||
export { BLOCKING_EXIT_CODE, parseHookOutput } from './codec.ts'
|
||||
export { runHook } from './runner.ts'
|
||||
export { parseHookOutput } from './codec.ts'
|
||||
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
|
||||
export type { RunHookOptions, RunHookResult } from './runner.ts'
|
||||
export { mergeHookOutputs } from './merge.ts'
|
||||
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
|
||||
export { appendHookInvoked, appendHookResult, summarizeStderr } from './events.ts'
|
||||
export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts'
|
||||
export type { HookInvocation, HookResultRecord } from './events.ts'
|
||||
|
||||
@@ -17,6 +17,15 @@ import type { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { parseHookOutput } from './codec.ts'
|
||||
import type { CommandHook, HookOutput } from './types.ts'
|
||||
|
||||
/**
|
||||
* The reference default per-hook timeout, in ms (10 minutes) — the value both
|
||||
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It
|
||||
* lives here, once, as the protocol's default; the bridges' `defaultTimeoutMs`
|
||||
* config defaults to it, and a per-hook {@link CommandHook.timeoutSec} is the
|
||||
* override surface.
|
||||
*/
|
||||
export const DEFAULT_HOOK_TIMEOUT_MS = 600_000
|
||||
|
||||
/** Everything a single hook invocation needs beyond its command line. */
|
||||
export interface RunHookOptions {
|
||||
/** The JSON payload object written to the hook's stdin (the bridge builds it). */
|
||||
@@ -27,10 +36,14 @@ export interface RunHookOptions {
|
||||
cwd?: string
|
||||
/** Abort signal — cancels the hook run when fired (the parent step aborts). */
|
||||
signal?: AbortSignal
|
||||
/** Default timeout (ms) when the hook config sets none. */
|
||||
defaultTimeoutMs: number
|
||||
/** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */
|
||||
trailingNewline: boolean
|
||||
/**
|
||||
* Timeout applied when the hook's config sets no `timeout` of its own. The
|
||||
* bridge owns the default (its `defaultTimeoutMs` config, reference default
|
||||
* {@link DEFAULT_HOOK_TIMEOUT_MS}) and passes it in explicitly.
|
||||
*/
|
||||
defaultTimeoutMs: number
|
||||
/**
|
||||
* The event this hook is firing for (e.g. `'PreToolUse'`). When set, a
|
||||
* structured `hookSpecificOutput` block whose `hookEventName` names a DIFFERENT
|
||||
@@ -43,18 +56,20 @@ export interface RunHookOptions {
|
||||
/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
|
||||
export interface RunHookResult {
|
||||
output: HookOutput
|
||||
/** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `hook` via `bash` with `options.payload` serialized to its stdin, then
|
||||
* decode the result. `now` is injected (a monotonic-ms source) so the duration
|
||||
* is testable without a real clock. The hook's configured `timeoutSec` (wire
|
||||
* unit: seconds) overrides `defaultTimeoutMs`. The command runs with the
|
||||
* dialect's `env` merged after the executor's credential scrub (the trusted-
|
||||
* plugin path). NEVER throws: an infrastructure failure (the executor rejecting)
|
||||
* is surfaced as a {@link HookOutput} with `exitCode: undefined`, so the caller's
|
||||
* merge logic treats it as a non-blocking error rather than crashing the turn.
|
||||
* decode the result into a {@link HookOutput}. The hook's configured
|
||||
* `timeoutSec` (wire unit: seconds) overrides `options.defaultTimeoutMs`.
|
||||
* The command runs with the dialect's `env` merged after the executor's
|
||||
* credential scrub (the trusted-plugin path). NEVER throws: an infrastructure
|
||||
* failure (the executor rejecting) is surfaced as a {@link HookOutput} with
|
||||
* `exitCode: undefined`, so the caller's merge logic treats it as a
|
||||
* non-blocking error rather than crashing the turn. `now` is injected for
|
||||
* testable durations.
|
||||
*/
|
||||
export async function runHook(
|
||||
bash: BashExecutor,
|
||||
|
||||
@@ -18,7 +18,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
/**
|
||||
* A hook command was invoked at a hook point — log-only provenance (like
|
||||
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
|
||||
* `dialect` is the bridge that ran it (`claude`/`codex`/`native`), `point`
|
||||
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
|
||||
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
|
||||
* pattern that selected it (absent for match-all), `handlerId` a stable id
|
||||
* for the command (so an invoked/result pair correlates). `turn` is the open
|
||||
@@ -34,11 +34,14 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
/**
|
||||
* A hook command's outcome — log-only, paired with a prior `hook/invoked`
|
||||
* (same `handlerId`). `decision` is the resolved dialect-neutral outcome the
|
||||
* bridge mapped it to (`allow`/`deny`/`ask`/`block`/`continue`/`stop`/`pass`),
|
||||
* `exitCode` the process exit (absent if it never ran), `stderrSummary` a
|
||||
* truncated stderr (the block reason source on exit 2), `durationMs` the wall
|
||||
* time. `turn` matches the `hook/invoked`.
|
||||
* (same `handlerId`). `decision` is the dialect-neutral outcome derived by
|
||||
* `appendHookResult` (which owns the rule): the hook's parsed decision
|
||||
* (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to
|
||||
* halt via `continue:false`, else `'pass'`. `exitCode` is the process exit
|
||||
* (absent if it never ran), `stderrSummary` the trimmed stderr truncated to
|
||||
* the bridge's configured cap (the block reason source on exit 2),
|
||||
* `durationMs` the wall-clock runtime (audit timing; snapshot replay
|
||||
* normalizes it). `turn` matches the `hook/invoked`.
|
||||
* @mode emit
|
||||
*/
|
||||
'hook/result': {
|
||||
@@ -53,8 +56,12 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Which protocol dialect a hook config / invocation belongs to. */
|
||||
export type HookDialect = 'claude' | 'codex' | 'native'
|
||||
/**
|
||||
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
|
||||
* bridge `'codex'`. A native plugin on the interception seams is not a bridge
|
||||
* and writes no `hook/*` provenance (see the interception-seams RFC).
|
||||
*/
|
||||
export type HookDialect = 'claude' | 'codex'
|
||||
|
||||
/**
|
||||
* One configured command hook (the `{ type: 'command', command, timeout? }`
|
||||
@@ -115,8 +122,6 @@ export interface HookOutput {
|
||||
continue?: boolean
|
||||
/** Human-readable reason shown when {@link continue} is `false`. */
|
||||
stopReason?: string
|
||||
/** Hide the hook's stdout from the transcript (CC `suppressOutput`). */
|
||||
suppressOutput?: boolean
|
||||
/**
|
||||
* The neutral blocking decision a hook expressed, folded from the two channels
|
||||
* the reference protocols keep DISTINCT: the legacy top-level `decision`
|
||||
|
||||
@@ -38,13 +38,12 @@ describe('parseHookOutput — exit code semantics', () => {
|
||||
})
|
||||
|
||||
describe('parseHookOutput — structured stdout (exit 0 only)', () => {
|
||||
it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => {
|
||||
it('parses top-level continue/stopReason/systemMessage', () => {
|
||||
const out = parseHookOutput(0, JSON.stringify({
|
||||
continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up',
|
||||
continue: false, stopReason: 'budget exceeded', systemMessage: 'heads up',
|
||||
}), '')
|
||||
expect(out.continue).toBe(false)
|
||||
expect(out.stopReason).toBe('budget exceeded')
|
||||
expect(out.suppressOutput).toBe(true)
|
||||
expect(out.systemMessage).toBe('heads up')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { appendHookInvoked, appendHookResult, summarizeStderr } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { appendHookInvoked, appendHookResult, summarizeStderr, type HookOutput } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/** A {@link HookOutput} with the required stream fields defaulted. */
|
||||
function output(over: Partial<HookOutput> = {}): HookOutput {
|
||||
return { exitCode: 0, stderr: '', stdout: '', ...over }
|
||||
}
|
||||
|
||||
describe('hook/* session events', () => {
|
||||
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
|
||||
@@ -18,7 +23,7 @@ describe('hook/* session events', () => {
|
||||
|
||||
it('omits matcher when absent (match-all hook)', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'native', handlerId: 'h2' })
|
||||
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' })
|
||||
|
||||
const ev = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
if (ev?.type === 'hook/invoked') {
|
||||
@@ -26,32 +31,72 @@ describe('hook/* session events', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('appendHookResult records the decided outcome, omitting absent optionals', () => {
|
||||
it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny',
|
||||
exitCode: 2, stderrSummary: 'blocked', durationMs: 12,
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'h1',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }),
|
||||
})
|
||||
const full = [...session.events].find(e => e.type === 'hook/result')
|
||||
if (full?.type === 'hook/result') {
|
||||
expect(full.data).toMatchObject({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 12 })
|
||||
expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 })
|
||||
}
|
||||
|
||||
// A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys.
|
||||
const session2 = new Session(SessionId('s2'))
|
||||
appendHookResult(session2, { turn: 1, point: 'Stop', handlerId: 'h3', decision: 'allow', durationMs: 3 })
|
||||
appendHookResult(session2, {
|
||||
turn: 1, point: 'Stop', handlerId: 'h3',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }),
|
||||
})
|
||||
const sparse = [...session2.events].find(e => e.type === 'hook/result')
|
||||
if (sparse?.type === 'hook/result') {
|
||||
expect('exitCode' in sparse.data).toBe(false)
|
||||
expect('stderrSummary' in sparse.data).toBe(false)
|
||||
expect(sparse.data.durationMs).toBe(3)
|
||||
expect(sparse.data.decision).toBe('allow')
|
||||
}
|
||||
})
|
||||
|
||||
it('the decision falls back to stop on continue:false, else pass', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) })
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() })
|
||||
// An explicit decision wins over the continue:false fallback.
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) })
|
||||
|
||||
const decisions = [...session.events]
|
||||
.filter(e => e.type === 'hook/result')
|
||||
.map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : [])
|
||||
expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']])
|
||||
})
|
||||
|
||||
it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'long',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }),
|
||||
})
|
||||
const ev = [...session.events].find(e => e.type === 'hook/result')
|
||||
if (ev?.type === 'hook/result') {
|
||||
expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…')
|
||||
}
|
||||
})
|
||||
|
||||
it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'edge',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }),
|
||||
})
|
||||
const ev = [...session.events].find(e => e.type === 'hook/result')
|
||||
if (ev?.type === 'hook/result') {
|
||||
expect(ev.data.stderrSummary).toBe('y'.repeat(500))
|
||||
}
|
||||
})
|
||||
|
||||
it('an invoked/result pair correlates by handlerId', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
|
||||
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', decision: 'allow', exitCode: 0, durationMs: 7 })
|
||||
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) })
|
||||
|
||||
const invoked = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
const result = [...session.events].find(e => e.type === 'hook/result')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { runHook } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/**
|
||||
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
|
||||
@@ -89,6 +89,7 @@ describe('runHook — payload + env + stdin plumbing', () => {
|
||||
const { bash, specs } = recordingBash(async () => result())
|
||||
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
|
||||
expect(specs[0]!.timeoutMs).toBe(60000)
|
||||
expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes)
|
||||
})
|
||||
|
||||
it('passes the abort signal through', async () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ In a `cordis.yml`:
|
||||
projectDir: .
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning.
|
||||
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
|
||||
|
||||
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
|
||||
|
||||
|
||||
@@ -31,10 +31,11 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
|
||||
import {
|
||||
appendHookInvoked,
|
||||
appendHookResult,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
summarizeStderr,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
@@ -82,8 +83,8 @@ export const Config: z<Config> = z.object({
|
||||
configPath: z.string().required(),
|
||||
pluginRoot: z.string(),
|
||||
projectDir: z.string(),
|
||||
defaultTimeoutMs: z.number().default(600_000),
|
||||
stderrSummaryMaxChars: z.number().default(500),
|
||||
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
|
||||
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
|
||||
})
|
||||
|
||||
/** A stable per-handler id so an invoked/result pair correlates in the log. */
|
||||
@@ -105,8 +106,9 @@ 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.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
// --- Parse the config ONCE at load. A read/parse failure is contained: the
|
||||
// bridge logs and registers nothing rather than crashing boot (a typo'd path
|
||||
// must not take the agent down). ---
|
||||
@@ -126,8 +128,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return
|
||||
}
|
||||
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
|
||||
|
||||
/**
|
||||
* Run every command hook configured for `point` whose matcher selects
|
||||
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
|
||||
@@ -173,10 +173,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...hookEnv ? { env: hookEnv } : {},
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
defaultTimeoutMs,
|
||||
trailingNewline: true,
|
||||
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
|
||||
// different event than the one firing (the schemas key it by event).
|
||||
@@ -190,14 +190,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
|
||||
appendHookResult(session, {
|
||||
turn: opts.turn, point, handlerId,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
durationMs,
|
||||
})
|
||||
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,8 +322,8 @@ describe('hooks-claude coverage — more default/sparse arms', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — schema-bypass default + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) defaults the timeout and runs', async () => {
|
||||
describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) with only configPath runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
@@ -337,8 +337,9 @@ describe('hooks-claude coverage — schema-bypass default + unspawnable hook', (
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so the
|
||||
// runtime `defaultTimeoutMs ?? 600_000` fallback is exercised.
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so
|
||||
// the bridge must run on the raw minimal config (the per-hook timeout is
|
||||
// the protocol lib's reference default, not a config knob).
|
||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
@@ -32,7 +32,7 @@ In a `cordis.yml`:
|
||||
model: deepseek-v4
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias. Events outside the five Codex points are dropped at parse.
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse.
|
||||
|
||||
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
|
||||
import {
|
||||
appendHookInvoked,
|
||||
appendHookResult,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
summarizeStderr,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
@@ -57,8 +58,8 @@ export interface Config {
|
||||
export const Config: z<Config> = z.object({
|
||||
configPath: z.string().required(),
|
||||
model: z.string().default(''),
|
||||
defaultTimeoutMs: z.number().default(600_000),
|
||||
stderrSummaryMaxChars: z.number().default(500),
|
||||
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
|
||||
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
|
||||
})
|
||||
|
||||
let handlerCounter = 0
|
||||
@@ -78,8 +79,9 @@ 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.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
|
||||
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'))
|
||||
@@ -93,7 +95,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return
|
||||
}
|
||||
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
|
||||
const model = config.model ?? ''
|
||||
|
||||
async function runPoint(
|
||||
@@ -122,9 +123,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
defaultTimeoutMs,
|
||||
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
|
||||
// Discard a `hookSpecificOutput` block naming a different event.
|
||||
expectedEventName: point,
|
||||
@@ -149,14 +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) {
|
||||
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
|
||||
appendHookResult(session, {
|
||||
turn: opts.turn, point, handlerId,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
durationMs,
|
||||
})
|
||||
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
})
|
||||
|
||||
it('warns on a skipped async hook and a direct apply() defaults the timeout', async () => {
|
||||
it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [
|
||||
@@ -243,7 +243,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
ctx.logger.warn = warn as never
|
||||
// Direct apply (schema bypass) → defaultTimeoutMs ?? 600_000 + model ?? '' fallbacks.
|
||||
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
|
||||
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
Reference in New Issue
Block a user