fix(claude): extend Stop hook timeout and apply headless opts in SDK query - #713
Conversation
…eammateIdle phases The default 10-minute timeout covers all three sequential hook phases, so turns with in-progress tasks can get killed and strand the workflow's queue/release poll. Use the max-safe setTimeout value (~24 days) to effectively remove the timeout; waitForIdle still fires on the marker file write, not timer expiry.
Code Review — PR #713Nice, tightly scoped fix. The reasoning about the sequential Stop → TaskCompleted → TeammateIdle phases sharing a single Correctness
Scope drift (flagging, not blocking)The diff also changes line 1099: -for await (const msg of sdkQuery({ prompt, options: options ?? {} })) {
+for await (const msg of sdkQuery({ prompt, options: headlessSdkOpts })) {This is a legitimate and probably important bug fix —
Either works — the combined PR is fine, just discoverability suffers. Other notes
VerdictLGTM on the primary change. Consider the docstring nit (mentioning the internal 15-min poll cap) and clarify the scope of the headless-options fix in history before/after merge. |
* fix(claude): align stop-hook wait bound with Claude-side timeout and add liveness check The workflow's `_claude-stop-hook` had its own 15-minute wait budget for the queue/release poll loop — even after #713 extended the Claude-side hook timeout to ~24 days, a turn with no follow-up prompt inside 15 min would strand the session: the hook returned 0, Claude stopped, and the next `enqueuePrompt` wrote to a file nobody was reading. - Raise `DEFAULT_WAIT_TIMEOUT_MS` to match `STOP_HOOK_TIMEOUT_SECONDS` so both bounds are unified. - Replace the 100ms `existsSync` poll with `fs.watch` on the queue/release dirs (plus a slower existsSync fallback for dropped inotify events), so the next-turn prompt is delivered with ~0 latency. - Add an atomic-PID liveness check: `createClaudeSession` now writes `~/.atomic/claude-pid/<session_id>` containing `process.pid`, and the hook polls `process.kill(pid, 0)` every 5s. If atomic is SIGKILL'd without running teardown, the hook self-exits instead of parking Claude for the full 24-day budget. `clearClaudeSession` unlinks the pid file on graceful shutdown. Missing pid files are tolerated (liveness is skipped), so non-runtime hook invocations still work. - Add `abortableSleep` so the cooperating wait tasks cancel immediately when any one of them detects a hit. * chore(release): bump version to v0.5.28-1
* fix(claude): align stop-hook wait bound with Claude-side timeout and add liveness check The workflow's `_claude-stop-hook` had its own 15-minute wait budget for the queue/release poll loop — even after #713 extended the Claude-side hook timeout to ~24 days, a turn with no follow-up prompt inside 15 min would strand the session: the hook returned 0, Claude stopped, and the next `enqueuePrompt` wrote to a file nobody was reading. - Raise `DEFAULT_WAIT_TIMEOUT_MS` to match `STOP_HOOK_TIMEOUT_SECONDS` so both bounds are unified. - Replace the 100ms `existsSync` poll with `fs.watch` on the queue/release dirs (plus a slower existsSync fallback for dropped inotify events), so the next-turn prompt is delivered with ~0 latency. - Add an atomic-PID liveness check: `createClaudeSession` now writes `~/.atomic/claude-pid/<session_id>` containing `process.pid`, and the hook polls `process.kill(pid, 0)` every 5s. If atomic is SIGKILL'd without running teardown, the hook self-exits instead of parking Claude for the full 24-day budget. `clearClaudeSession` unlinks the pid file on graceful shutdown. Missing pid files are tolerated (liveness is skipped), so non-runtime hook invocations still work. - Add `abortableSleep` so the cooperating wait tasks cancel immediately when any one of them detects a hit. * chore(release): bump version to v0.5.28-1
* fix(claude): align stop-hook wait bound with Claude-side timeout and add liveness check The workflow's `_claude-stop-hook` had its own 15-minute wait budget for the queue/release poll loop — even after #713 extended the Claude-side hook timeout to ~24 days, a turn with no follow-up prompt inside 15 min would strand the session: the hook returned 0, Claude stopped, and the next `enqueuePrompt` wrote to a file nobody was reading. - Raise `DEFAULT_WAIT_TIMEOUT_MS` to match `STOP_HOOK_TIMEOUT_SECONDS` so both bounds are unified. - Replace the 100ms `existsSync` poll with `fs.watch` on the queue/release dirs (plus a slower existsSync fallback for dropped inotify events), so the next-turn prompt is delivered with ~0 latency. - Add an atomic-PID liveness check: `createClaudeSession` now writes `~/.atomic/claude-pid/<session_id>` containing `process.pid`, and the hook polls `process.kill(pid, 0)` every 5s. If atomic is SIGKILL'd without running teardown, the hook self-exits instead of parking Claude for the full 24-day budget. `clearClaudeSession` unlinks the pid file on graceful shutdown. Missing pid files are tolerated (liveness is skipped), so non-runtime hook invocations still work. - Add `abortableSleep` so the cooperating wait tasks cancel immediately when any one of them detects a hit. * chore(release): bump version to v0.5.28-1
…add liveness check The workflow's `_claude-stop-hook` had its own 15-minute wait budget for the queue/release poll loop — even after #713 extended the Claude-side hook timeout to ~24 days, a turn with no follow-up prompt inside 15 min would strand the session: the hook returned 0, Claude stopped, and the next `enqueuePrompt` wrote to a file nobody was reading. - Raise `DEFAULT_WAIT_TIMEOUT_MS` to match `STOP_HOOK_TIMEOUT_SECONDS` so both bounds are unified. - Replace the 100ms `existsSync` poll with `fs.watch` on the queue/release dirs (plus a slower existsSync fallback for dropped inotify events), so the next-turn prompt is delivered with ~0 latency. - Add an atomic-PID liveness check: `createClaudeSession` now writes `~/.atomic/claude-pid/<session_id>` containing `process.pid`, and the hook polls `process.kill(pid, 0)` every 5s. If atomic is SIGKILL'd without running teardown, the hook self-exits instead of parking Claude for the full 24-day budget. `clearClaudeSession` unlinks the pid file on graceful shutdown. Missing pid files are tolerated (liveness is skipped), so non-runtime hook invocations still work. - Add `abortableSleep` so the cooperating wait tasks cancel immediately when any one of them detects a hit.
Summary
Fixes two related issues in the Claude SDK provider: the Stop hook being killed during long
TaskCompleted/TeammateIdlephases, and headless SDK queries incorrectly ignoring the auto-deny options forAskUserQuestion.Key Changes
Stop hook timeout (
WORKFLOW_HOOK_SETTINGS): IntroducesSTOP_HOOK_TIMEOUT_SECONDS = 2_147_483(~24 days, the max-safesetTimeoutvalue in seconds) and passes it astimeouton the Stop hook entry. Claude Code's Stop hook process runs three phases sequentially — Stop hooks, thenTaskCompletedhooks per in-progress task, thenTeammateIdlehooks — and the per-hooktimeoutapplies to the whole lifecycle. The prior default (10 min) could kill the hook mid-run, severing the_claude-stop-hookqueue/release poll and stranding the workflow.waitForIdlestill resolves on the marker-file write, not on timer expiry, so real hook completion is unaffected.Headless SDK query options (
HeadlessClaudeSessionWrapper.query): ThesdkQuerycall was passingoptions ?? {}instead of the constructedheadlessSdkOpts, so theAskUserQuestionauto-deny (disallowedToolsmerge) was silently dropped on every headless run. Now passesheadlessSdkOptsso the tool is correctly blocked and headless queries cannot stall waiting for a human response.