diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index 43d1b0e49bc..e1d09f0ca3f 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -37,6 +37,10 @@ subscription quota exhaustion merely because ACP labels them `limit`. | `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | | `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible | +On the CLI engine, version and `--effort` capability probes have a bounded +20-second budget when `timeoutSec` is zero or unset. A positive run timeout below +20 seconds also limits these probes, with a minimum probe budget of one second. + ## Default model An omitted, empty, or whitespace-only `model` uses Claude Opus 5 diff --git a/packages/adapters/claude-local/src/server/cli-capabilities.test.ts b/packages/adapters/claude-local/src/server/cli-capabilities.test.ts new file mode 100644 index 00000000000..092cc5d8a89 --- /dev/null +++ b/packages/adapters/claude-local/src/server/cli-capabilities.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + claudeCommandSupportsEffortFlag, + readClaudeCommandVersion, + resetClaudeCliCapabilitiesCacheForTests, +} from "./cli-capabilities.js"; + +const directories: string[] = []; + +afterEach(() => { + resetClaudeCliCapabilitiesCacheForTests(); + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function slowClaude() { + const cwd = mkdtempSync(path.join(os.tmpdir(), "paperclip-slow-claude-")); + directories.push(cwd); + const command = path.join(cwd, "claude"); + writeFileSync(command, `#!${process.execPath}\nsetTimeout(() => { + console.log(process.argv.includes("--version") ? "2.1.284 (test CLI)" : "Usage: claude --effort "); +}, 1500);\n`); + chmodSync(command, 0o755); + return { runId: "capability-probe", command, target: null, cwd, env: {}, graceSec: 1 }; +} + +// This fixture is a real executable. Windows does not use POSIX shebang files. +describe.skipIf(process.platform === "win32")("Claude CLI capability probe budgets", () => { + it("allows a slow version and effort probe with an unlimited run timeout", async () => { + const input = { ...slowClaude(), timeoutSec: 0 }; + await expect(readClaudeCommandVersion(input)).resolves.toBe("2.1.284"); + await expect(claudeCommandSupportsEffortFlag(input)).resolves.toBe(true); + }, 30_000); + + it("retains an explicit shorter positive timeout", async () => { + const input = { ...slowClaude(), timeoutSec: 1 }; + await expect(readClaudeCommandVersion(input)).resolves.toBeNull(); + await expect(claudeCommandSupportsEffortFlag(input)).resolves.toBeNull(); + }, 30_000); +}); diff --git a/packages/adapters/claude-local/src/server/cli-capabilities.ts b/packages/adapters/claude-local/src/server/cli-capabilities.ts index dec74c2f384..f20c46d7c91 100644 --- a/packages/adapters/claude-local/src/server/cli-capabilities.ts +++ b/packages/adapters/claude-local/src/server/cli-capabilities.ts @@ -85,7 +85,7 @@ async function probeClaudeCommandVersion(input: { { cwd: input.cwd, env: input.env, - timeoutSec: Math.max(1, Math.min(input.timeoutSec, 20)), + timeoutSec: input.timeoutSec > 0 ? Math.max(1, Math.min(input.timeoutSec, 20)) : 20, graceSec: Math.max(1, Math.min(input.graceSec, 5)), onLog: async () => {}, }, @@ -127,7 +127,7 @@ async function probeClaudeCommandSupportsEffortFlag(input: { { cwd: input.cwd, env: input.env, - timeoutSec: Math.max(1, Math.min(input.timeoutSec, 20)), + timeoutSec: input.timeoutSec > 0 ? Math.max(1, Math.min(input.timeoutSec, 20)) : 20, graceSec: Math.max(1, Math.min(input.graceSec, 5)), onLog: async () => {}, },