docs: trim generated prose
This commit is contained in:
@@ -1,13 +1,6 @@
|
||||
/**
|
||||
* Parse a Claude Code hook config file into the shared {@link MatcherGroup}
|
||||
* shape, faithfully to CC's `hooks.json` / settings `hooks` key format.
|
||||
*
|
||||
* A CC config maps each event name to an array of matcher groups, each holding
|
||||
* an array of typed hooks. Only `type: 'command'` hooks run here; other types
|
||||
* (`prompt`/`agent`/`http`) are PARSED but skipped with a warning (faithful-but-
|
||||
* degraded — the same stance Codex takes). The `command` string undergoes
|
||||
* `${CLAUDE_PLUGIN_ROOT}` substitution at parse time so the runner sees a literal.
|
||||
*
|
||||
* Parse a Claude Code hook config file into the shared {@link MatcherGroup} shape, faithfully
|
||||
* to CC's `hooks.json` / settings `hooks` key format.
|
||||
* @module @deepseek-ai/dsh-hooks-claude/config
|
||||
*/
|
||||
|
||||
@@ -57,13 +50,13 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw Claude Code config object (the value under the `hooks` key, or a
|
||||
* `hooks.json` whose top level IS that map) into runnable {@link MatcherGroup}s.
|
||||
* Non-command hooks and malformed entries are dropped (recorded in `skipped` /
|
||||
* silently ignored) rather than throwing — a bad hook config must not crash boot.
|
||||
* `vars` are substituted into every surviving `command`.
|
||||
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare event map.
|
||||
* @param vars - substitution values applied to every surviving `command` (defaults to none).
|
||||
* Parse a raw Claude Code config object (the value under the `hooks` key, or a `hooks.json`
|
||||
* whose top level IS that map) into runnable {@link MatcherGroup}s.
|
||||
*
|
||||
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
|
||||
* event map.
|
||||
* @param vars - substitution values applied to every surviving `command` (defaults to
|
||||
* none).
|
||||
* @returns the runnable per-event groups plus the skipped non-command hooks.
|
||||
*/
|
||||
export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
/**
|
||||
* `dsh-hooks-claude` — a bridge plugin that runs a user's existing Claude Code
|
||||
* hook config (`hooks.json` / a settings file's `hooks` key) on the harness's
|
||||
* canonical interception seams. It is the CC DIALECT half of the hooks
|
||||
* subsystem: it owns CC's per-event stdin payloads, CC's env +
|
||||
* `${CLAUDE_PLUGIN_ROOT}` substitution, and the mapping from a hook's neutral
|
||||
* outcome onto the harness's typed Decisions. The dialect-agnostic primitives
|
||||
* (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive
|
||||
* merge, the `hook/*` events) come from `@deepseek-ai/dsh-hook-protocol`.
|
||||
*
|
||||
* A native cordis plugin could do everything this bridge does — more powerfully,
|
||||
* with typed returns and no serialization boundary. The bridge exists only to
|
||||
* run UNMODIFIED external CC hooks faithfully; anything bespoke should be a
|
||||
* native plugin on the same seams.
|
||||
*
|
||||
* Scope: the seven in-scope hook points (`SessionStart`, `UserPromptSubmit`,
|
||||
* `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`). Only
|
||||
* `type: 'command'` hooks run; the matcher group config + exit-code/stdout
|
||||
* protocol are byte-faithful to CC. `updatedInput` (tool-input rewrite) is
|
||||
* logged + warned, not honored (deferred — see the interception-seams RFC).
|
||||
*
|
||||
* `dsh-hooks-claude` — a bridge plugin that runs a user's existing Claude Code hook config
|
||||
* (`hooks.json` / a settings file's `hooks` key) on the harness's canonical interception
|
||||
* seams.
|
||||
* @module @deepseek-ai/dsh-hooks-claude
|
||||
*/
|
||||
|
||||
@@ -129,11 +112,10 @@ 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. ---
|
||||
// --- 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).
|
||||
const detached = createDetachedRuns()
|
||||
ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')
|
||||
|
||||
@@ -154,19 +136,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
): Promise<MergedHookOutcome> {
|
||||
const groups: MatcherGroup[] = parsed[point] ?? []
|
||||
const outputs: HookOutput[] = []
|
||||
// Run the hook in the AGENT'S session workspace (the `session/new` cwd on the
|
||||
// session header), not the executor default (the ACP server's launch dir).
|
||||
// A hook that does `pwd`, reads a relative file, or writes a marker must
|
||||
// operate in the user's project tree. Absent for a no-agent run (falls back
|
||||
// to the executor default).
|
||||
// Run the hook in the AGENT'S session workspace (the `session/new` cwd on the session
|
||||
// header), not the executor default (the ACP server's launch dir).
|
||||
const workdir = opts.agent?.session.header.cwd
|
||||
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to
|
||||
// the session workspace (the same dir the hook RUNS in). Claude Code always
|
||||
// exports this var, and common unmodified hooks reference `$CLAUDE_PROJECT_DIR`
|
||||
// (shell expansion at run time) for project-relative paths — leaving it empty
|
||||
// in the default ACP wiring (no `projectDir` configured) would break them even
|
||||
// though the bridge already knows the workspace. Absent only for a no-agent run
|
||||
// with no configured projectDir (nothing to point at).
|
||||
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session
|
||||
// workspace (the same dir the hook RUNS in).
|
||||
const projectDir = config.projectDir ?? workdir
|
||||
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
|
||||
for (const group of groups) {
|
||||
@@ -206,13 +180,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return mergeHookOutputs(outputs)
|
||||
}
|
||||
|
||||
// TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from
|
||||
// a hook's `continue:false`, but no seam below honors it — there is no
|
||||
// "hard-halt the whole agent" primitive on the interception seams yet (a
|
||||
// Decision can block/deny/steer a single point, not stop the run). Honoring it
|
||||
// needs that primitive; deferred with the loop-guard work. Until then a
|
||||
// `continue:false` hook still has its per-point effect (its decision/context),
|
||||
// and the halt request is recorded in the `hook/result` log but not acted on.
|
||||
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
|
||||
|
||||
/** Build a HookContext from accumulated additionalContext strings, or undefined when none. */
|
||||
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
|
||||
@@ -221,30 +189,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return { content, source: PLUGIN_SOURCE }
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
|
||||
* call sites) with a downstream listener's optional one, so folding our
|
||||
* additionalContext onto a delegated decision drops neither. The merged block
|
||||
* carries a single `source` — this bridge's — because a `HookContext` holds one
|
||||
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
|
||||
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
|
||||
* downstream plugin's text is still correctly framed as plugin context, not a
|
||||
* user prompt.
|
||||
*/
|
||||
/** 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 }
|
||||
}
|
||||
|
||||
// --- SessionStart: emit (cannot block). Inject any additionalContext into the
|
||||
// agent. The matcher subject is the source.
|
||||
// TODO(session-start-gating): `agent/session-start` is a SYNCHRONOUS emit and
|
||||
// this hook runs on a detached `.then`, so the injected context is BEST-EFFORT
|
||||
// — it is not guaranteed to land before the first turn reaches the model. A
|
||||
// slow hook can miss the first request (the context then arrives as a later
|
||||
// injection turn). Gating startup on the hook is a loop-level change deferred
|
||||
// to the interception seams; today the contract is "injected as soon as the
|
||||
// hook resolves", not "before the first request". ---
|
||||
// SessionStart injects context when its detached hook resolves.
|
||||
// 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, sessionStartPayload(agent, source), { agent, signal: detached.signal })
|
||||
.then((merged) => {
|
||||
@@ -264,10 +216,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (merged.decision === 'deny') {
|
||||
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
|
||||
}
|
||||
// Our hooks did not block. DELEGATE (attaching context alone is not a veto):
|
||||
// a later `agent/prompt-submit` listener must still get to block or rewrite.
|
||||
// Then fold our additionalContext onto its decision — a downstream block wins
|
||||
// (a dropped prompt makes the context moot; `block` carries no context field).
|
||||
// Our hooks did not block.
|
||||
const downstream = await next()
|
||||
const ours = contextFrom(merged)
|
||||
if (!ours || downstream.kind !== 'allow') return downstream
|
||||
@@ -309,34 +258,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
})
|
||||
|
||||
// --- Stop → ContinuationDecision. CC's Stop hook can force the conversation to
|
||||
// CONTINUE (block the stop) with stderr/reason as the continuation. No matcher.
|
||||
// TODO(stop-loop-guard): CC breaks an infinite force-continue with
|
||||
// `stop_hook_active` (set true once a Stop hook has already fired this run) plus
|
||||
// a max-consecutive cap; both are deferred. Today `stop_hook_active` is always
|
||||
// false, so a Stop hook that unconditionally blocks would force-continue every
|
||||
// step — a hook author must self-limit until the guard lands. ---
|
||||
// A blocking Stop hook forces continuation with its reason.
|
||||
// TODO(stop-loop-guard): cap consecutive forced continuations.
|
||||
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
|
||||
const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn })
|
||||
if (merged.decision === 'deny') {
|
||||
// A blocking Stop hook forces continuation. It carries its reason as
|
||||
// next-step steering; a blocking hook that emitted no reason (exit 2, empty
|
||||
// stderr) still forces the turn to continue — the block is what matters, so
|
||||
// fall back to a generic steering line rather than letting the turn stop.
|
||||
// A blocking Stop hook forces continuation.
|
||||
const text = merged.reason ?? 'continue: blocked by Stop hook'
|
||||
return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
// --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is
|
||||
// observe-only this cut). A SubagentStart hook's additionalContext is injected
|
||||
// into the live child; SubagentStop only observes. Both look the live child up
|
||||
// so the hook runs in the child's session workspace and the payload carries
|
||||
// the child's session_id/cwd (see subagentPayload). The matcher subject is the
|
||||
// CC-default `agent_type` (SUBAGENT_TYPE) — the harness seam carries no
|
||||
// per-kind label, so a config's default/`*`/empty agent_type matcher fires and
|
||||
// a specific-kind matcher does not (documented in the RFC). ---
|
||||
// --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is observe-only
|
||||
// this cut).
|
||||
ctx.on('subagent/start', (info) => {
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
|
||||
@@ -347,13 +282,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
.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
|
||||
// service's detached `.then` BEFORE the tool caller's `await run.result`
|
||||
// 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 (the tracker's settlement bookkeeping
|
||||
// would absorb one anyway).
|
||||
// Look up the child (still recoverable: `subagent/end` fires from the service's detached
|
||||
// `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the
|
||||
// child's cwd, not the server default.
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
|
||||
})
|
||||
|
||||
@@ -302,12 +302,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
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).
|
||||
// The markers prove the hook PROCESSES ran, not that the detached `.then` continuations did
|
||||
// (`touch` lands before the process exits).
|
||||
await hooks.dispose()
|
||||
})
|
||||
|
||||
@@ -317,10 +313,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
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.
|
||||
// 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.
|
||||
writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
|
||||
chmodSync(slowHook, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
|
||||
@@ -334,11 +328,9 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
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.
|
||||
// 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).
|
||||
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.
|
||||
@@ -367,11 +359,8 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
})
|
||||
|
||||
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
||||
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
|
||||
// would veto the prompt (0 model requests) and log a hook/invoked. Build the
|
||||
// ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then
|
||||
// dispose it — a leaked listener fails the test (a no-op `true` hook would
|
||||
// pass even leaked, so it proved nothing).
|
||||
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it would veto the
|
||||
// prompt (0 model requests) and log a hook/invoked.
|
||||
const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
@@ -393,10 +382,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
|
||||
// Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray
|
||||
// `export default apply` would collapse the module via `unwrapExports`
|
||||
// (`exports.default ?? exports`), DROP `inject`, and crash at load with
|
||||
// "cannot get property … without inject". Guard the shape directly.
|
||||
// Loader must retain this namespace's injection metadata.
|
||||
expect('default' in HooksClaude).toBe(false)
|
||||
expect(HooksClaude.name).toBe('hooks-claude')
|
||||
expect(HooksClaude.inject).toEqual(['bash'])
|
||||
|
||||
@@ -183,9 +183,9 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
|
||||
})
|
||||
|
||||
it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => {
|
||||
// Regression: a blocking Stop hook (exit 2) with no stderr yields decision
|
||||
// 'deny' + reason undefined; the turn must STILL force-continue (the block is
|
||||
// what matters), not silently stop. Self-limit to one block so it can't loop.
|
||||
// Regression: a blocking Stop hook (exit 2) with no stderr yields decision 'deny' + reason
|
||||
// undefined; the turn must STILL force-continue (the block is what matters), not silently
|
||||
// stop.
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`)
|
||||
@@ -382,10 +382,8 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', ()
|
||||
|
||||
describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => {
|
||||
it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
|
||||
// Honoring `continue:false` (hard-halt the whole run) is deferred — there is
|
||||
// no such primitive on the interception seams yet. So this asserts the LOG
|
||||
// faithfully records the halt request (decision "stop"), AND that the run is
|
||||
// NOT actually halted: the tool still runs and the turn completes normally.
|
||||
// Honoring `continue:false` (hard-halt the whole run) is deferred — there is no such
|
||||
// primitive on the interception seams yet.
|
||||
const d = dir()
|
||||
const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
@@ -457,9 +455,8 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// A hook that only adds context must NOT short-circuit the waterfall: a
|
||||
// downstream agent/prompt-submit listener (a policy plugin) must still get to
|
||||
// block the prompt. The bridge delegates via next() and folds its context.
|
||||
// A hook that only adds context must not short-circuit the waterfall: a downstream
|
||||
// agent/prompt-submit listener (a policy plugin) must still get to block the prompt.
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
@@ -589,10 +586,8 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => {
|
||||
|
||||
describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => {
|
||||
it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => {
|
||||
// The bug: the bridge passed no workdir, so hooks ran in the executor default
|
||||
// (the server launch dir), not session/new.cwd. Here the executor default and
|
||||
// the session cwd are DIFFERENT temp dirs; a PreToolUse hook writes `pwd` to a
|
||||
// marker and we assert it ran in the SESSION cwd.
|
||||
// The bug: the bridge passed no workdir, so hooks ran in the executor default (the server
|
||||
// launch dir), not session/new.cwd.
|
||||
const serverDir = dir()
|
||||
const sessionDir = dir()
|
||||
const marker = join(sessionDir, 'where')
|
||||
@@ -626,11 +621,8 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
|
||||
})
|
||||
|
||||
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
|
||||
// SubagentStop looks the child up (recoverable at subagent/end) and runs the
|
||||
// hook in the CHILD's session cwd, not the executor default. Here the executor
|
||||
// default and the child session cwd are DIFFERENT dirs; a SubagentStop hook
|
||||
// writes `pwd` to a relative marker and we assert it landed in the CHILD dir —
|
||||
// which only holds if the listener threaded the child agent into runPoint.
|
||||
// SubagentStop looks the child up (recoverable at subagent/end) and runs the hook in the
|
||||
// CHILD's session cwd, not the executor default.
|
||||
const serverDir = dir()
|
||||
const childDir = dir()
|
||||
const marker = join(childDir, 'stopwhere')
|
||||
@@ -681,11 +673,8 @@ describe('hooks-claude coverage — systemMessage is warned, not surfaced', () =
|
||||
|
||||
describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => {
|
||||
it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => {
|
||||
// Regression for the documented downgrade: session-start injection is
|
||||
// detached, so a prompt sent immediately need not observe it. This asserts
|
||||
// the SAFE properties (no crash, the turn still runs) WITHOUT waiting for the
|
||||
// inject first — it documents the best-effort timing rather than masking it
|
||||
// by pre-waiting for context/message (which the guaranteed-timing tests do).
|
||||
// Regression for the documented downgrade: session-start injection is detached, so a prompt
|
||||
// sent immediately need not observe it.
|
||||
const d = dir()
|
||||
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n')
|
||||
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
|
||||
Reference in New Issue
Block a user