Skip to content

Comments

fix: add timeout: null support to HookCallbackMatcher for indefinite hook waiting#194

Open
samhagman wants to merge 1 commit intoanthropics:mainfrom
samhagman:fix/hook-timeout-null-support
Open

fix: add timeout: null support to HookCallbackMatcher for indefinite hook waiting#194
samhagman wants to merge 1 commit intoanthropics:mainfrom
samhagman:fix/hook-timeout-null-support

Conversation

@samhagman
Copy link

Situation, Complication, Resolution

Situation: HookCallbackMatcher.timeout lets you set how long a PreToolUse hook
waits before timing out. The Python SDK supports timeout=None for "wait forever".

Complication: The TypeScript SDK has no equivalent. The hook executor in cli.js
uses a truthy check (Z.timeout ? Z.timeout*1000 : z) which means null, undefined,
and even 0 all silently fall back to the 60-second default. Interactive hooks like
AskUserQuestion and ExitPlanMode — which require a human to respond — will always
time out after 60 seconds with no way to opt out.

Resolution: Treat timeout: null as "wait indefinitely" by skipping
AbortSignal.timeout() entirely at each of the 4 hook executor sites. The rk() signal
combiner already accepts an optional second argument, so rk(parentSignal) (no timeout
signal) works without any changes to rk itself.


Changes proposed

Since the source is not public, this PR includes:

  1. apply-patch.mjs — patches cli.js (4 sites) and sdk.d.ts in a local install
  2. restore-patch.mjs — restores from .bak backups
  3. test-patch-unit.mjs — 14 unit assertions (no API key needed); 14/14 pass
  4. test-timeout-bug.mjs — full integration repro (requires ANTHROPIC_API_KEY)
  5. ISSUE.md — detailed write-up with proposed source-level diffs

Proposed source change (cli.js, 4 sites)

// Before (truthy check — null/0/undefined all fall back to 60s):
let u = Z.timeout ? Z.timeout * 1000 : z,
    {signal, cleanup} = rk(AbortSignal.timeout(u), parentSignal);

// After (null-safe — null skips the timeout signal entirely):
let u = Z.timeout === null ? null : Z.timeout ? Z.timeout * 1000 : z,
    {signal, cleanup} = u === null
      ? rk(parentSignal)
      : rk(AbortSignal.timeout(u), parentSignal);

Proposed type change (sdk.d.ts)

// Before:
/** Timeout in seconds for all hooks in this matcher */
timeout?: number;

// After:
/**
 * Timeout in seconds for all hooks in this matcher.
 * - `undefined` — use the default (60 seconds)
 * - `null`      — wait indefinitely, never time out
 * - `number`    — explicit timeout in seconds
 */
timeout?: number | null;

Test results

node apply-patch.mjs && node test-patch-unit.mjs

=== BEFORE patch (buggy) ===
  ✅ timeout: 0  → uses default 60s (bug: treats 0 as falsy)
  ✅ timeout: null → uses default 60s (bug: null is falsy)
  ✅ timeout: undefined → uses default 60s (correct, but via wrong mechanism)
  ✅ timeout: 30 → 30s

=== AFTER patch (fixed) ===
  ✅ timeout: null → NO AbortSignal.timeout, just parent signal (never times out)
  ✅ timeout: null → parent signal is preserved
  ✅ timeout: 0  → still uses default 60s
  ✅ timeout: undefined → uses default 60s (correct)
  ✅ timeout: 30 → 30s
  ✅ timeout: 2_147_483 → ~24.8 days

=== Verify patch was applied ===
  ✅ cli.js contains null-safe check (===null?null:)
  ✅ cli.js contains >=4 null-safe guards
  ✅ cli.js contains >=4 null rk() branches
  ✅ sdk.d.ts contains 'number | null'

Results: 14 passed, 0 failed

Python SDK parity

# Python SDK — already works:
HookMatcher(matcher="AskUserQuestion", hooks=[h], timeout=None)

The TypeScript fix makes parity:

{ matcher: "AskUserQuestion", hooks: [h], timeout: null }

🤖 Generated with Claude Code

HookCallbackMatcher.timeout uses a truthy check in cli.js which means
null, undefined, and 0 all silently fall back to the 60s default. There
is no way to express "wait forever" in the TypeScript SDK, unlike the
Python SDK where timeout=None achieves this.

Changes:
- apply-patch.mjs: patches cli.js (4 sites) and sdk.d.ts locally
- restore-patch.mjs: restores .bak files
- test-patch-unit.mjs: 14 unit assertions, no API key needed (14/14 pass)
- test-timeout-bug.mjs: full integration repro (requires ANTHROPIC_API_KEY)
- ISSUE.md: detailed bug report with proposed source-level fix
- package.json / .gitignore

Proposed fix in cli.js source:
  Before: Z.timeout ? Z.timeout*1000 : z
  After:  Z.timeout === null ? null : Z.timeout ? Z.timeout*1000 : z
  + skip AbortSignal.timeout() when null, use rk(parentSignal) only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant