Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
*.bak
111 changes: 111 additions & 0 deletions ISSUE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Bug: `HookCallbackMatcher.timeout` has no "wait forever" option; `timeout: 0` silently uses 60s default

## Summary

There is no way to configure a `PreToolUse` hook to wait indefinitely for a response.
The SDK always times out after at most 60 seconds, regardless of what value you pass for
`timeout`. Additionally, `timeout: 0` silently falls back to the 60-second default due to
a truthy check in the hook executor.

## Background

This matters for interactive hooks like `AskUserQuestion` and `ExitPlanMode`, where a
human must respond before the agent continues. If the user doesn't see the prompt within
60 seconds, the hook times out and the agent proceeds with empty/default answers.

The Python SDK has `timeout=None` to mean "wait forever". The TypeScript SDK offers no
equivalent.

## Root cause

In `cli.js`, each hook executor site uses a **truthy** check instead of a null-safe check:

```js
// Current code (4 occurrences in cli.js):
let u = N.timeout ? N.timeout * 1000 : z, // z = default 60_000ms
{signal, cleanup} = rk(AbortSignal.timeout(u), parentSignal);
```

This means:
- `timeout: undefined` → falsy → 60s ✓ (correct default)
- `timeout: null` → falsy → 60s ✗ (no way to express "no timeout")
- `timeout: 0` → falsy → 60s ✗ (0 is silently ignored)
- `timeout: 30` → 30s ✓

The type definition reinforces this by only allowing `number`:

```ts
// sdk.d.ts (current):
interface HookCallbackMatcher {
/** Timeout in seconds for all hooks in this matcher */
timeout?: number;
}
```

## Proposed fix

### `sdk.d.ts` — add `null` as a valid value

```ts
interface HookCallbackMatcher {
/**
* 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;
}
```

### `cli.js` source — null-safe check at each hook executor site

```js
// Proposed fix (same pattern at all 4 sites):
let u = N.timeout === null ? null
: N.timeout ? N.timeout * 1000 : z,
{signal, cleanup} = u === null
? rk(parentSignal) // no timeout signal at all
: rk(AbortSignal.timeout(u), parentSignal);
```

When `timeout === null`:
- We skip `AbortSignal.timeout()` entirely
- `rk(parentSignal)` works because `rk(A, q)` takes `q` as optional (uses `q?.aborted`)
- The hook can still be cancelled by the parent session signal

## Verification

A local patch and unit test are available at:
https://github.com/anthropics/claude-agent-sdk-typescript/... *(this PR)*

```
node apply-patch.mjs # patches local node_modules
node test-patch-unit.mjs # 14/14 pass
```

The logic test verifies:
- `timeout: null` → `rk(parentSignal)` only (no AbortSignal.timeout)
- `timeout: 0` → still 60s default (truthy check for 0, separate issue)
- `timeout: 30` → 30s as expected
- `timeout: undefined` → 60s default as expected

## Python SDK parity

The Python SDK already supports this:

```python
HookMatcher(
matcher="AskUserQuestion|ExitPlanMode",
hooks=[my_hook],
timeout=None, # wait forever
)
```

The TypeScript SDK should offer the same capability.

## Impact

Anyone building interactive applications with `AskUserQuestion` (chat interfaces, approval
flows, human-in-the-loop workflows) is silently affected: the agent will proceed without
waiting after 60 seconds, even if the developer intends for the hook to wait forever.
70 changes: 70 additions & 0 deletions apply-patch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Applies the timeout: null fix to the locally installed claude-agent-sdk.
* Run once before running test-timeout-bug.mjs.
*
* Changes:
* cli.js — All hook executor sites: truthy check → null-safe check
* sdk.d.ts — timeout?: number → timeout?: number | null
*/

import { readFileSync, writeFileSync, copyFileSync } from "fs";
import { resolve } from "path";

const sdkDir = new URL(
import.meta.resolve("@anthropic-ai/claude-agent-sdk")
).pathname.replace(/\/sdk\.mjs$/, "");

console.log("SDK dir:", sdkDir);

// ── 1. Patch cli.js ───────────────────────────────────────────────────────────
const cliPath = resolve(sdkDir, "cli.js");
copyFileSync(cliPath, cliPath + ".bak");
let cli = readFileSync(cliPath, "utf8");

// Pattern: let <timeoutVar>=<hookVar>.timeout?<hookVar>.timeout*1000:z,{signal:<sig>,cleanup:<cleanup>}=rk(AbortSignal.timeout(<timeoutVar>),<parentSig>)
// Fix: let <timeoutVar>=<hookVar>.timeout===null?null:<hookVar>.timeout?<hookVar>.timeout*1000:z,{signal:<sig>,cleanup:<cleanup>}=<timeoutVar>===null?rk(<parentSig>):rk(AbortSignal.timeout(<timeoutVar>),<parentSig>)
//
// We use a generic regex so variable names don't matter across SDK versions.
const hookTimeoutRegex = /let ([A-Za-z])=([A-Za-z])\.timeout\?\2\.timeout\*1000:z,(\{signal:[A-Za-z]+,cleanup:[A-Za-z]+\})=rk\(AbortSignal\.timeout\(\1\),([A-Z])\)/g;

let count = 0;
const patched = cli.replace(hookTimeoutRegex, (match, timeoutVar, hookVar, destructure, parentSig) => {
count++;
return `let ${timeoutVar}=${hookVar}.timeout===null?null:${hookVar}.timeout?${hookVar}.timeout*1000:z,${destructure}=${timeoutVar}===null?rk(${parentSig}):rk(AbortSignal.timeout(${timeoutVar}),${parentSig})`;
});

if (count === 0) {
console.error("❌ No hook timeout patterns found in cli.js — SDK version may have changed");
process.exit(1);
}

writeFileSync(cliPath, patched, "utf8");
console.log(`✅ cli.js patched (${count} occurrences)`);

// ── 2. Patch sdk.d.ts ─────────────────────────────────────────────────────────
const dtsPath = resolve(sdkDir, "sdk.d.ts");
copyFileSync(dtsPath, dtsPath + ".bak");
let dts = readFileSync(dtsPath, "utf8");

// sdk.d.ts uses CRLF — normalise to LF, patch, then restore CRLF
const usesCRLF = dts.includes("\r\n");
const dtsNorm = dts.replace(/\r\n/g, "\n");

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

const dtsNormPatched = dtsNorm.replace(dtsBug, dtsFix);
if (dtsNormPatched === dtsNorm) {
console.error("❌ sdk.d.ts HookCallbackMatcher.timeout pattern not found");
process.exit(1);
}
const dtsPatched = usesCRLF ? dtsNormPatched.replace(/\n/g, "\r\n") : dtsNormPatched;
if (dtsPatched === dts) {
console.error("❌ sdk.d.ts HookCallbackMatcher.timeout pattern not found");
process.exit(1);
}

writeFileSync(dtsPath, dtsPatched, "utf8");
console.log("✅ sdk.d.ts patched (HookCallbackMatcher.timeout type)");

console.log("\nPatch applied. Now run:\n node test-timeout-bug.mjs");
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "claude-agent-sdk-timeout-fix",
"version": "1.0.0",
"type": "module",
"scripts": {
"test:bug": "node test-timeout-bug.mjs",
"test:bug-zero": "TIMEOUT_SECONDS=0 node test-timeout-bug.mjs",
"apply-patch": "node apply-patch.mjs",
"restore": "node restore-patch.mjs"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.50"
}
}
17 changes: 17 additions & 0 deletions restore-patch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** Restores cli.js and sdk.d.ts from .bak backups */
import { copyFileSync, existsSync } from "fs";
import { resolve } from "path";

const sdkDir = new URL(
import.meta.resolve("@anthropic-ai/claude-agent-sdk")
).pathname.replace(/\/sdk\.mjs$/, "");

for (const f of ["cli.js", "sdk.d.ts"]) {
const bak = resolve(sdkDir, f + ".bak");
if (existsSync(bak)) {
copyFileSync(bak, resolve(sdkDir, f));
console.log(`✅ Restored ${f}`);
} else {
console.log(`⚠️ No backup found for ${f}`);
}
}
134 changes: 134 additions & 0 deletions test-patch-unit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Unit test for the timeout: null patch.
* Verifies the patched logic in isolation — no API key needed.
*
* Simulates the rk() signal combiner and tests each timeout scenario.
*/

let pass = 0;
let fail = 0;

function assert(label, condition, detail = "") {
if (condition) {
console.log(` ✅ ${label}`);
pass++;
} else {
console.error(` ❌ ${label}${detail ? ": " + detail : ""}`);
fail++;
}
}

// Simulate rk(A, q) — the signal combiner from cli.js
// Returns a mock with a .timeoutSignal to let us inspect what was passed
function rk(A, q) {
return { combined: true, signals: [A, q].filter(Boolean) };
}

// Simulate the BEFORE (buggy) logic
function computeTimeoutBefore(hookTimeout, defaultZ) {
const u = hookTimeout ? hookTimeout * 1000 : defaultZ;
return rk(AbortSignal.timeout(u), "parentSignal");
}

// Simulate the AFTER (fixed) logic
function computeTimeoutAfter(hookTimeout, defaultZ) {
const u = hookTimeout === null ? null : hookTimeout ? hookTimeout * 1000 : defaultZ;
return u === null ? rk("parentSignal") : rk(AbortSignal.timeout(u), "parentSignal");
}

const DEFAULT_Z = 60_000;

console.log("\n=== BEFORE patch (buggy) ===\n");

{
const r = computeTimeoutBefore(0, DEFAULT_Z);
// 0 is falsy → falls back to 60s → AbortSignal.timeout(60000) is created
assert("timeout: 0 → uses default 60s (bug: treats 0 as falsy)", r.signals.length === 2);
}
{
const r = computeTimeoutBefore(null, DEFAULT_Z);
// null is falsy → falls back to 60s → AbortSignal.timeout(60000) is created
assert("timeout: null → uses default 60s (bug: null is falsy)", r.signals.length === 2);
}
{
const r = computeTimeoutBefore(undefined, DEFAULT_Z);
// undefined is falsy → falls back to 60s
assert("timeout: undefined → uses default 60s (correct, but via wrong mechanism)", r.signals.length === 2);
}
{
const r = computeTimeoutBefore(30, DEFAULT_Z);
// 30 → 30*1000 = 30000ms
assert("timeout: 30 → 30s", r.signals.length === 2);
}

console.log("\n=== AFTER patch (fixed) ===\n");

{
const r = computeTimeoutAfter(null, DEFAULT_Z);
// null → skip AbortSignal.timeout → only parent signal
assert("timeout: null → NO AbortSignal.timeout, just parent signal (never times out)", r.signals.length === 1);
assert("timeout: null → parent signal is preserved", r.signals[0] === "parentSignal");
}
{
const r = computeTimeoutAfter(0, DEFAULT_Z);
// 0 is still falsy (but not null) → falls back to default 60s (same as before)
// Could be improved with ?? but that's a separate issue
assert("timeout: 0 → still uses default 60s (truthy bug for 0 preserved, separate issue)", r.signals.length === 2);
}
{
const r = computeTimeoutAfter(undefined, DEFAULT_Z);
// undefined → not null → undefined is falsy → default 60s
assert("timeout: undefined → uses default 60s (correct)", r.signals.length === 2);
}
{
const r = computeTimeoutAfter(30, DEFAULT_Z);
// 30 → 30*1000 = 30000ms
assert("timeout: 30 → 30s", r.signals.length === 2);
}
{
const r = computeTimeoutAfter(2_147_483, DEFAULT_Z);
// max safe value
assert("timeout: 2_147_483 → ~24.8 days", r.signals.length === 2);
}

console.log("\n=== Verify patch was applied to cli.js ===\n");

import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath, pathToFileURL } from "url";

const sdkDir = fileURLToPath(
new URL("./node_modules/@anthropic-ai/claude-agent-sdk", import.meta.url)
);
const cli = readFileSync(resolve(sdkDir, "cli.js"), "utf8");
const dts = readFileSync(resolve(sdkDir, "sdk.d.ts"), "utf8");

assert(
"cli.js contains null-safe check (===null?null:)",
cli.includes("===null?null:") && cli.includes("===null?rk("),
"patch was not applied"
);
// After patching, the null-safe guard appears before each truthy check,
// so "===null?null:" precedes every "timeout?timeout*1000:z" in hook executors.
// Check that we have at least 4 patched sites (one per hook type).
const nullGuardCount = (cli.match(/===null\?null:/g) || []).length;
assert(
"cli.js contains >=4 null-safe guards (one per hook executor site)",
nullGuardCount >= 4,
`found ${nullGuardCount}`
);
const nullRkCount = (cli.match(/===null\?rk\(/g) || []).length;
assert(
"cli.js contains >=4 null rk() branches (one per hook executor site)",
nullRkCount >= 4,
`found ${nullRkCount}`
);
assert(
"sdk.d.ts contains 'number | null' for HookCallbackMatcher.timeout",
dts.includes("timeout?: number | null;"),
"type not updated"
);

console.log(`\n${"─".repeat(40)}`);
console.log(`Results: ${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);
Loading