diff --git a/CHANGELOG.md b/CHANGELOG.md index 1302dc00187..42f4d644203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,8 @@ Docs: https://docs.openclaw.ai - Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone. - Gateway/status self version reporting: make Gateway self version in `openclaw status` prefer runtime `VERSION` (while preserving explicit `OPENCLAW_VERSION` override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai. - Memory/QMD index isolation: set `QMD_CONFIG_DIR` alongside `XDG_CONFIG_HOME` so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind. +- CLI/Coding-agent reliability: switch default `claude-cli` non-interactive args to `--permission-mode bypassPermissions`, auto-normalize legacy `--dangerously-skip-permissions` backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc. +- ACP/ACPX session bootstrap: retry with `sessions new` when `sessions ensure` returns no session identifiers so ACP spawns avoid `NO_SESSION`/`ACP_TURN_FAILED` failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc. - LINE/auth boundary hardening synthesis: enforce strict LINE webhook authn/z boundary semantics across pairing-store account scoping, DM/group allowlist separation, fail-closed webhook auth/runtime behavior, and replay/duplication controls (including in-flight replay reservation and post-success dedupe marking). (from #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777) Thanks @bmendonca3, @davidahmann, @harshang03, @haosenwang1018, @liuxiaopai-ai, @coygeek, and @Takhoffman. - LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3. - LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman. diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 186a5355d33..1c96302462a 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -185,8 +185,8 @@ Input modes: OpenClaw ships a default for `claude-cli`: - `command: "claude"` -- `args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"]` -- `resumeArgs: ["-p", "--output-format", "json", "--dangerously-skip-permissions", "--resume", "{sessionId}"]` +- `args: ["-p", "--output-format", "json", "--permission-mode", "bypassPermissions"]` +- `resumeArgs: ["-p", "--output-format", "json", "--permission-mode", "bypassPermissions", "--resume", "{sessionId}"]` - `modelArg: "--model"` - `systemPromptArg: "--append-system-prompt"` - `sessionArg: "--session-id"` diff --git a/docs/help/testing.md b/docs/help/testing.md index 7c647f11eb2..efb889f1950 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -219,7 +219,7 @@ OPENCLAW_LIVE_SETUP_TOKEN=1 OPENCLAW_LIVE_SETUP_TOKEN_PROFILE=anthropic:setup-to - Defaults: - Model: `claude-cli/claude-sonnet-4-6` - Command: `claude` - - Args: `["-p","--output-format","json","--dangerously-skip-permissions"]` + - Args: `["-p","--output-format","json","--permission-mode","bypassPermissions"]` - Overrides (optional): - `OPENCLAW_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-opus-4-6"` - `OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.3-codex"` diff --git a/extensions/acpx/src/runtime-internals/test-fixtures.ts b/extensions/acpx/src/runtime-internals/test-fixtures.ts index 928867418b8..f5d79122546 100644 --- a/extensions/acpx/src/runtime-internals/test-fixtures.ts +++ b/extensions/acpx/src/runtime-internals/test-fixtures.ts @@ -75,14 +75,35 @@ const setValue = command === "set" ? String(args[commandIndex + 2] || "") : ""; if (command === "sessions" && args[commandIndex + 1] === "ensure") { writeLog({ kind: "ensure", agent, args, sessionName: ensureName }); - emitJson({ - action: "session_ensured", - acpxRecordId: "rec-" + ensureName, - acpxSessionId: "sid-" + ensureName, - agentSessionId: "inner-" + ensureName, - name: ensureName, - created: true, - }); + if (process.env.MOCK_ACPX_ENSURE_EMPTY === "1") { + emitJson({ action: "session_ensured", name: ensureName }); + } else { + emitJson({ + action: "session_ensured", + acpxRecordId: "rec-" + ensureName, + acpxSessionId: "sid-" + ensureName, + agentSessionId: "inner-" + ensureName, + name: ensureName, + created: true, + }); + } + process.exit(0); +} + +if (command === "sessions" && args[commandIndex + 1] === "new") { + writeLog({ kind: "new", agent, args, sessionName: ensureName }); + if (process.env.MOCK_ACPX_NEW_EMPTY === "1") { + emitJson({ action: "session_created", name: ensureName }); + } else { + emitJson({ + action: "session_created", + acpxRecordId: "rec-" + ensureName, + acpxSessionId: "sid-" + ensureName, + agentSessionId: "inner-" + ensureName, + name: ensureName, + created: true, + }); + } process.exit(0); } diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 44f02cabd5a..5e4baf7f3cb 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -377,4 +377,51 @@ describe("AcpxRuntime", () => { expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE"); expect(report.installCommand).toContain("acpx"); }); + + it("falls back to 'sessions new' when 'sessions ensure' returns no session IDs", async () => { + process.env.MOCK_ACPX_ENSURE_EMPTY = "1"; + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + const handle = await runtime.ensureSession({ + sessionKey: "agent:claude:acp:fallback-test", + agent: "claude", + mode: "persistent", + }); + expect(handle.backend).toBe("acpx"); + expect(handle.acpxRecordId).toBe("rec-agent:claude:acp:fallback-test"); + expect(handle.agentSessionId).toBe("inner-agent:claude:acp:fallback-test"); + + const logs = await readMockRuntimeLogEntries(logPath); + expect(logs.some((entry) => entry.kind === "ensure")).toBe(true); + expect(logs.some((entry) => entry.kind === "new")).toBe(true); + } finally { + delete process.env.MOCK_ACPX_ENSURE_EMPTY; + } + }); + + it("fails with ACP_SESSION_INIT_FAILED when both ensure and new omit session IDs", async () => { + process.env.MOCK_ACPX_ENSURE_EMPTY = "1"; + process.env.MOCK_ACPX_NEW_EMPTY = "1"; + try { + const { runtime, logPath } = await createMockRuntimeFixture(); + + await expect( + runtime.ensureSession({ + sessionKey: "agent:claude:acp:fallback-fail", + agent: "claude", + mode: "persistent", + }), + ).rejects.toMatchObject({ + code: "ACP_SESSION_INIT_FAILED", + message: expect.stringContaining("neither 'sessions ensure' nor 'sessions new'"), + }); + + const logs = await readMockRuntimeLogEntries(logPath); + expect(logs.some((entry) => entry.kind === "ensure")).toBe(true); + expect(logs.some((entry) => entry.kind === "new")).toBe(true); + } finally { + delete process.env.MOCK_ACPX_ENSURE_EMPTY; + delete process.env.MOCK_ACPX_NEW_EMPTY; + } + }); }); diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 0d9973afe70..c4a00f008a8 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -179,7 +179,7 @@ export class AcpxRuntime implements AcpRuntime { const cwd = asTrimmedString(input.cwd) || this.config.cwd; const mode = input.mode; - const events = await this.runControlCommand({ + let events = await this.runControlCommand({ args: this.buildControlArgs({ cwd, command: [agent, "sessions", "ensure", "--name", sessionName], @@ -187,12 +187,36 @@ export class AcpxRuntime implements AcpRuntime { cwd, fallbackCode: "ACP_SESSION_INIT_FAILED", }); - const ensuredEvent = events.find( + let ensuredEvent = events.find( (event) => asOptionalString(event.agentSessionId) || asOptionalString(event.acpxSessionId) || asOptionalString(event.acpxRecordId), ); + + if (!ensuredEvent) { + events = await this.runControlCommand({ + args: this.buildControlArgs({ + cwd, + command: [agent, "sessions", "new", "--name", sessionName], + }), + cwd, + fallbackCode: "ACP_SESSION_INIT_FAILED", + }); + ensuredEvent = events.find( + (event) => + asOptionalString(event.agentSessionId) || + asOptionalString(event.acpxSessionId) || + asOptionalString(event.acpxRecordId), + ); + if (!ensuredEvent) { + throw new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`, + ); + } + } + const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined; const agentSessionId = ensuredEvent ? asOptionalString(ensuredEvent.agentSessionId) : undefined; const backendSessionId = ensuredEvent diff --git a/skills/coding-agent/SKILL.md b/skills/coding-agent/SKILL.md index cca6ef83ad5..50db2c14570 100644 --- a/skills/coding-agent/SKILL.md +++ b/skills/coding-agent/SKILL.md @@ -1,6 +1,6 @@ --- name: coding-agent -description: 'Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), thread-bound ACP harness requests in chat (for example spawn/run Codex or Claude Code in a Discord thread; use sessions_spawn with runtime:"acp"), or any work in ~/clawd workspace (never spawn agents here). Requires a bash tool that supports pty:true.' +description: 'Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), thread-bound ACP harness requests in chat (for example spawn/run Codex or Claude Code in a Discord thread; use sessions_spawn with runtime:"acp"), or any work in ~/clawd workspace (never spawn agents here). Claude Code: use --print --permission-mode bypassPermissions (no PTY). Codex/Pi/OpenCode: pty:true required.' metadata: { "openclaw": { "emoji": "🧩", "requires": { "anyBins": ["claude", "codex", "opencode", "pi"] } }, @@ -11,18 +11,27 @@ metadata: Use **bash** (with optional background mode) for all coding agent work. Simple and effective. -## ⚠️ PTY Mode Required! +## ⚠️ PTY Mode: Codex/Pi/OpenCode yes, Claude Code no -Coding agents (Codex, Claude Code, Pi) are **interactive terminal applications** that need a pseudo-terminal (PTY) to work correctly. Without PTY, you'll get broken output, missing colors, or the agent may hang. - -**Always use `pty:true`** when running coding agents: +For **Codex, Pi, and OpenCode**, PTY is still required (interactive terminal apps): ```bash -# ✅ Correct - with PTY +# ✅ Correct for Codex/Pi/OpenCode bash pty:true command:"codex exec 'Your prompt'" +``` -# ❌ Wrong - no PTY, agent may break -bash command:"codex exec 'Your prompt'" +For **Claude Code** (`claude` CLI), use `--print --permission-mode bypassPermissions` instead. +`--dangerously-skip-permissions` with PTY can exit after the confirmation dialog. +`--print` mode keeps full tool access and avoids interactive confirmation: + +```bash +# ✅ Correct for Claude Code (no PTY needed) +cd /path/to/project && claude --permission-mode bypassPermissions --print 'Your task' + +# For background execution: use background:true on the exec tool + +# ❌ Wrong for Claude Code +bash pty:true command:"claude --dangerously-skip-permissions 'task'" ``` ### Bash Tool Parameters @@ -158,11 +167,11 @@ gh pr comment --body "" ## Claude Code ```bash -# With PTY for proper terminal output -bash pty:true workdir:~/project command:"claude 'Your task'" +# Foreground +bash workdir:~/project command:"claude --permission-mode bypassPermissions --print 'Your task'" # Background -bash pty:true workdir:~/project background:true command:"claude 'Your task'" +bash workdir:~/project background:true command:"claude --permission-mode bypassPermissions --print 'Your task'" ``` --- @@ -222,7 +231,9 @@ git worktree remove /tmp/issue-99 ## ⚠️ Rules -1. **Always use pty:true** - coding agents need a terminal! +1. **Use the right execution mode per agent**: + - Codex/Pi/OpenCode: `pty:true` + - Claude Code: `--print --permission-mode bypassPermissions` (no PTY required) 2. **Respect tool choice** - if user asks for Codex, use Codex. - Orchestrator mode: do NOT hand-code patches yourself. - If an agent fails/hangs, respawn it or ask the user for direction, but don't silently take over. diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index c78dfdb87fc..3075462b12e 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -34,3 +34,110 @@ describe("resolveCliBackendConfig reliability merge", () => { expect(resolved?.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8); }); }); + +describe("resolveCliBackendConfig claude-cli defaults", () => { + it("uses non-interactive permission-mode defaults for fresh and resume args", () => { + const resolved = resolveCliBackendConfig("claude-cli"); + + expect(resolved).not.toBeNull(); + expect(resolved?.config.args).toContain("--permission-mode"); + expect(resolved?.config.args).toContain("bypassPermissions"); + expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); + expect(resolved?.config.resumeArgs).toContain("--permission-mode"); + expect(resolved?.config.resumeArgs).toContain("bypassPermissions"); + expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions"); + }); + + it("retains default claude safety args when only command is overridden", () => { + const cfg = { + agents: { + defaults: { + cliBackends: { + "claude-cli": { + command: "/usr/local/bin/claude", + }, + }, + }, + }, + } satisfies OpenClawConfig; + + const resolved = resolveCliBackendConfig("claude-cli", cfg); + + expect(resolved).not.toBeNull(); + expect(resolved?.config.command).toBe("/usr/local/bin/claude"); + expect(resolved?.config.args).toContain("--permission-mode"); + expect(resolved?.config.args).toContain("bypassPermissions"); + expect(resolved?.config.resumeArgs).toContain("--permission-mode"); + expect(resolved?.config.resumeArgs).toContain("bypassPermissions"); + }); + + it("normalizes legacy skip-permissions overrides to permission-mode bypassPermissions", () => { + const cfg = { + agents: { + defaults: { + cliBackends: { + "claude-cli": { + command: "claude", + args: ["-p", "--dangerously-skip-permissions", "--output-format", "json"], + resumeArgs: [ + "-p", + "--dangerously-skip-permissions", + "--output-format", + "json", + "--resume", + "{sessionId}", + ], + }, + }, + }, + }, + } satisfies OpenClawConfig; + + const resolved = resolveCliBackendConfig("claude-cli", cfg); + + expect(resolved).not.toBeNull(); + expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); + expect(resolved?.config.args).toContain("--permission-mode"); + expect(resolved?.config.args).toContain("bypassPermissions"); + expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions"); + expect(resolved?.config.resumeArgs).toContain("--permission-mode"); + expect(resolved?.config.resumeArgs).toContain("bypassPermissions"); + }); + + it("keeps explicit permission-mode overrides while removing legacy skip flag", () => { + const cfg = { + agents: { + defaults: { + cliBackends: { + "claude-cli": { + command: "claude", + args: ["-p", "--dangerously-skip-permissions", "--permission-mode", "acceptEdits"], + resumeArgs: [ + "-p", + "--dangerously-skip-permissions", + "--permission-mode=acceptEdits", + "--resume", + "{sessionId}", + ], + }, + }, + }, + }, + } satisfies OpenClawConfig; + + const resolved = resolveCliBackendConfig("claude-cli", cfg); + + expect(resolved).not.toBeNull(); + expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions"); + expect(resolved?.config.args).toEqual(["-p", "--permission-mode", "acceptEdits"]); + expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions"); + expect(resolved?.config.resumeArgs).toEqual([ + "-p", + "--permission-mode=acceptEdits", + "--resume", + "{sessionId}", + ]); + expect(resolved?.config.args).not.toContain("bypassPermissions"); + expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions"); + }); +}); diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index cf3cdb4bb18..92992effa0a 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -33,14 +33,19 @@ const CLAUDE_MODEL_ALIASES: Record = { "claude-haiku-3-5": "haiku", }; +const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions"; +const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode"; +const CLAUDE_BYPASS_PERMISSIONS_MODE = "bypassPermissions"; + const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = { command: "claude", - args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"], + args: ["-p", "--output-format", "json", "--permission-mode", "bypassPermissions"], resumeArgs: [ "-p", "--output-format", "json", - "--dangerously-skip-permissions", + "--permission-mode", + "bypassPermissions", "--resume", "{sessionId}", ], @@ -147,6 +152,48 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig) }; } +function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined { + if (!args) { + return args; + } + const normalized: string[] = []; + let sawLegacySkip = false; + let hasPermissionMode = false; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) { + sawLegacySkip = true; + continue; + } + if (arg === CLAUDE_PERMISSION_MODE_ARG) { + hasPermissionMode = true; + normalized.push(arg); + const maybeValue = args[i + 1]; + if (typeof maybeValue === "string") { + normalized.push(maybeValue); + i += 1; + } + continue; + } + if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) { + hasPermissionMode = true; + } + normalized.push(arg); + } + if (sawLegacySkip && !hasPermissionMode) { + normalized.push(CLAUDE_PERMISSION_MODE_ARG, CLAUDE_BYPASS_PERMISSIONS_MODE); + } + return normalized; +} + +function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig { + return { + ...config, + args: normalizeClaudePermissionArgs(config.args), + resumeArgs: normalizeClaudePermissionArgs(config.resumeArgs), + }; +} + export function resolveCliBackendIds(cfg?: OpenClawConfig): Set { const ids = new Set([ normalizeBackendKey("claude-cli"), @@ -169,11 +216,12 @@ export function resolveCliBackendConfig( if (normalized === "claude-cli") { const merged = mergeBackendConfig(DEFAULT_CLAUDE_BACKEND, override); - const command = merged.command?.trim(); + const config = normalizeClaudeBackendConfig(merged); + const command = config.command?.trim(); if (!command) { return null; } - return { id: normalized, config: { ...merged, command } }; + return { id: normalized, config: { ...config, command } }; } if (normalized === "codex-cli") { const merged = mergeBackendConfig(DEFAULT_CODEX_BACKEND, override); diff --git a/src/agents/cli-runner.test.ts b/src/agents/cli-runner.test.ts index ec2ea4768c5..ec1b0b09ac8 100644 --- a/src/agents/cli-runner.test.ts +++ b/src/agents/cli-runner.test.ts @@ -7,6 +7,8 @@ import { runCliAgent } from "./cli-runner.js"; import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js"; const supervisorSpawnMock = vi.fn(); +const enqueueSystemEventMock = vi.fn(); +const requestHeartbeatNowMock = vi.fn(); vi.mock("../process/supervisor/index.js", () => ({ getProcessSupervisor: () => ({ @@ -18,6 +20,14 @@ vi.mock("../process/supervisor/index.js", () => ({ }), })); +vi.mock("../infra/system-events.js", () => ({ + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), +})); + +vi.mock("../infra/heartbeat-wake.js", () => ({ + requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args), +})); + type MockRunExit = { reason: | "manual-cancel" @@ -49,6 +59,8 @@ function createManagedRun(exit: MockRunExit, pid = 1234) { describe("runCliAgent with process supervisor", () => { beforeEach(() => { supervisorSpawnMock.mockClear(); + enqueueSystemEventMock.mockClear(); + requestHeartbeatNowMock.mockClear(); }); it("runs CLI through supervisor and returns payload", async () => { @@ -124,6 +136,46 @@ describe("runCliAgent with process supervisor", () => { ).rejects.toThrow("produced no output"); }); + it("enqueues a system event and heartbeat wake on no-output watchdog timeout for session runs", async () => { + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "no-output-timeout", + exitCode: null, + exitSignal: "SIGKILL", + durationMs: 200, + stdout: "", + stderr: "", + timedOut: true, + noOutputTimedOut: true, + }), + ); + + await expect( + runCliAgent({ + sessionId: "s1", + sessionKey: "agent:main:main", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.2-codex", + timeoutMs: 1_000, + runId: "run-2b", + cliSessionId: "thread-123", + }), + ).rejects.toThrow("produced no output"); + + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + const [notice, opts] = enqueueSystemEventMock.mock.calls[0] ?? []; + expect(String(notice)).toContain("produced no output"); + expect(String(notice)).toContain("interactive input or an approval prompt"); + expect(opts).toMatchObject({ sessionKey: "agent:main:main" }); + expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ + reason: "cli:watchdog:stall", + sessionKey: "agent:main:main", + }); + }); + it("fails with timeout when overall timeout trips", async () => { supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 0ceca9979d0..3dfe728ce31 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -4,8 +4,11 @@ import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/config.js"; import { shouldLogVerbose } from "../globals.js"; import { isTruthyEnvValue } from "../infra/env.js"; +import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; +import { enqueueSystemEvent } from "../infra/system-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getProcessSupervisor } from "../process/supervisor/index.js"; +import { scopedHeartbeatWakeOptions } from "../routing/session-key.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; import { analyzeBootstrapBudget, @@ -341,6 +344,17 @@ export async function runCliAgent(params: { log.warn( `cli watchdog timeout: provider=${params.provider} model=${modelId} session=${resolvedSessionId ?? params.sessionId} noOutputTimeoutMs=${noOutputTimeoutMs} pid=${managedRun.pid ?? "unknown"}`, ); + if (params.sessionKey) { + const stallNotice = [ + `CLI agent (${params.provider}) produced no output for ${Math.round(noOutputTimeoutMs / 1000)}s and was terminated.`, + "It may have been waiting for interactive input or an approval prompt.", + "For Claude Code, prefer --permission-mode bypassPermissions --print.", + ].join(" "); + enqueueSystemEvent(stallNotice, { sessionKey: params.sessionKey }); + requestHeartbeatNow( + scopedHeartbeatWakeOptions(params.sessionKey, { reason: "cli:watchdog:stall" }), + ); + } throw new FailoverError(timeoutReason, { reason: "timeout", provider: params.provider, diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index c25463d796d..b0426c59175 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -20,7 +20,13 @@ const CLI_RESUME = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-6"; -const DEFAULT_CLAUDE_ARGS = ["-p", "--output-format", "json", "--dangerously-skip-permissions"]; +const DEFAULT_CLAUDE_ARGS = [ + "-p", + "--output-format", + "json", + "--permission-mode", + "bypassPermissions", +]; const DEFAULT_CODEX_ARGS = [ "exec", "--json",