diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f15edcf..1cb1685e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Tools: add Firecrawl fallback for `web_fetch` when configured. - Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites. - Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails. +- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`. - Status: trim `/status` to current-provider usage only and drop the OAuth/token block. - Directory: unify `clawdbot directory` across channels and plugin channels. - UI: allow deleting sessions from the Control UI. diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md index 6a8a71bf3..a48ab092f 100644 --- a/docs/gateway/background-process.md +++ b/docs/gateway/background-process.md @@ -39,6 +39,7 @@ Config (preferred): - `tools.exec.backgroundMs` (default 10000) - `tools.exec.timeoutSec` (default 1800) - `tools.exec.cleanupMs` (default 1800000) + - `tools.exec.notifyOnExit` (default true): enqueue a system event + request heartbeat when a backgrounded exec exits. ## process tool diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 076c30704..e8e37bd21 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1745,10 +1745,10 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`. - `backgroundMs`: time before auto-background (ms, default 10000) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800) - `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000) +- `notifyOnExit`: enqueue a system event + request heartbeat when backgrounded exec exits (default true) - `applyPatch.enabled`: enable experimental `apply_patch` (OpenAI/OpenAI Codex only; default false) - `applyPatch.allowModels`: optional allowlist of model ids (e.g. `gpt-5.2` or `openai/gpt-5.2`) -Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias). -Legacy: `tools.bash` is still accepted as an alias. +Note: `applyPatch` is only under `tools.exec`. `tools.web` configures web search + fetch tools: - `tools.web.search.enabled` (default: true when key is present) diff --git a/docs/tools/apply-patch.md b/docs/tools/apply-patch.md index 45e53d070..1ab9d806c 100644 --- a/docs/tools/apply-patch.md +++ b/docs/tools/apply-patch.md @@ -37,7 +37,7 @@ The tool accepts a single `input` string that wraps one or more file operations: - Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`. - OpenAI-only (including OpenAI Codex). Optionally gate by model via `tools.exec.applyPatch.allowModels`. -- Config is only under `tools.exec` (no `tools.bash` alias). +- Config is only under `tools.exec`. ## Example diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 532d9ba93..afe047e9f 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -22,6 +22,10 @@ Background sessions are scoped per agent; `process` only sees sessions from the - Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output. Note: `elevated` is ignored when sandboxing is off (exec already runs on the host). +## Config + +- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit. + ## Examples Foreground: @@ -53,4 +57,4 @@ Enable it explicitly: Notes: - Only available for OpenAI/OpenAI Codex models. - Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`. -- Config lives under `tools.exec.applyPatch` (no `tools.bash` alias). +- Config lives under `tools.exec.applyPatch`. diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index 070685300..6f7c5750e 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -23,6 +23,9 @@ export interface ProcessSession { id: string; command: string; scopeKey?: string; + sessionKey?: string; + notifyOnExit?: boolean; + exitNotified?: boolean; child?: ChildProcessWithoutNullStreams; stdin?: SessionStdin; pid?: number; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 3e621684a..e5a9a7af6 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -3,13 +3,17 @@ import { randomUUID } from "node:crypto"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; +import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; +import { enqueueSystemEvent } from "../infra/system-events.js"; import { logInfo } from "../logger.js"; import { + type ProcessSession, type SessionStdin, addSession, appendOutput, markBackgrounded, markExited, + tail, } from "./bash-process-registry.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; import { @@ -34,6 +38,7 @@ const DEFAULT_MAX_OUTPUT = clampNumber( ); const DEFAULT_PATH = process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const DEFAULT_NOTIFY_TAIL_CHARS = 400; type PtyExitEvent = { exitCode: number; signal?: number }; type PtyListener = (event: T) => void; @@ -62,6 +67,8 @@ export type ExecToolDefaults = { elevated?: ExecElevatedDefaults; allowBackground?: boolean; scopeKey?: string; + sessionKey?: string; + notifyOnExit?: boolean; cwd?: string; }; @@ -117,6 +124,28 @@ export type ExecToolDetails = cwd?: string; }; +function normalizeNotifyOutput(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") { + if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return; + const sessionKey = session.sessionKey?.trim(); + if (!sessionKey) return; + session.exitNotified = true; + const exitLabel = session.exitSignal + ? `signal ${session.exitSignal}` + : `code ${session.exitCode ?? 0}`; + const output = normalizeNotifyOutput( + tail(session.tail || session.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), + ); + const summary = output + ? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}` + : `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`; + enqueueSystemEvent(summary, { sessionKey }); + requestHeartbeatNow({ reason: `exec:${session.id}:exit` }); +} + export function createExecTool( defaults?: ExecToolDefaults, // biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance. @@ -132,6 +161,8 @@ export function createExecTool( typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0 ? defaults.timeoutSec : 1800; + const notifyOnExit = defaults?.notifyOnExit !== false; + const notifySessionKey = defaults?.sessionKey?.trim() || undefined; return { name: "exec", @@ -308,6 +339,9 @@ export function createExecTool( id: sessionId, command: params.command, scopeKey: defaults?.scopeKey, + sessionKey: notifySessionKey, + notifyOnExit, + exitNotified: false, child: child ?? undefined, stdin, pid: child?.pid ?? pty?.pid, @@ -347,6 +381,7 @@ export function createExecTool( const finalizeTimeout = () => { if (session.exited) return; markExited(session, null, "SIGKILL", "failed"); + maybeNotifyOnExit(session, "failed"); if (settled || !rejectFn) return; const aggregated = session.aggregated.trim(); const reason = `Command timed out after ${effectiveTimeout} seconds`; @@ -477,6 +512,7 @@ export function createExecTool( const isSuccess = code === 0 && !wasSignal && !signal?.aborted && !timedOut; const status: "completed" | "failed" = isSuccess ? "completed" : "failed"; markExited(session, code, exitSignal, status); + maybeNotifyOnExit(session, status); if (!session.child && session.stdin) { session.stdin.destroyed = true; } @@ -536,6 +572,7 @@ export function createExecTool( if (timeoutTimer) clearTimeout(timeoutTimer); if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer); markExited(session, null, null, "failed"); + maybeNotifyOnExit(session, "failed"); settle(() => reject(err)); }); } diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 8b76fab8c..198a062ca 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resetProcessRegistryForTests } from "./bash-process-registry.js"; +import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js"; +import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js"; import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js"; import { buildDockerExecArgs } from "./bash-tools.shared.js"; import { sanitizeBinaryOutput } from "./shell-utils.js"; @@ -42,6 +43,7 @@ async function waitForCompletion(sessionId: string) { beforeEach(() => { resetProcessRegistryForTests(); + resetSystemEventsForTest(); }); describe("exec tool backgrounding", () => { @@ -241,6 +243,36 @@ describe("exec tool backgrounding", () => { }); }); +describe("exec notifyOnExit", () => { + it("enqueues a system event when a backgrounded exec exits", async () => { + const tool = createExecTool({ + allowBackground: true, + backgroundMs: 0, + notifyOnExit: true, + sessionKey: "agent:main:main", + }); + + const result = await tool.execute("call1", { + command: echoAfterDelay("notify"), + background: true, + }); + + expect(result.details.status).toBe("running"); + const sessionId = (result.details as { sessionId: string }).sessionId; + + let finished = getFinishedSession(sessionId); + const deadline = Date.now() + (isWin ? 8000 : 2000); + while (!finished && Date.now() < deadline) { + await sleep(20); + finished = getFinishedSession(sessionId); + } + + expect(finished).toBeTruthy(); + const events = peekSystemEvents("agent:main:main"); + expect(events.some((event) => event.includes(sessionId.slice(0, 8)))).toBe(true); + }); +}); + describe("buildDockerExecArgs", () => { it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => { const args = buildDockerExecArgs({ diff --git a/src/agents/pi-embedded-runner/utils.ts b/src/agents/pi-embedded-runner/utils.ts index 4da0b9a2b..ff8020168 100644 --- a/src/agents/pi-embedded-runner/utils.ts +++ b/src/agents/pi-embedded-runner/utils.ts @@ -11,10 +11,8 @@ export function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel { export function resolveExecToolDefaults(config?: ClawdbotConfig): ExecToolDefaults | undefined { const tools = config?.tools; - if (!tools) return undefined; - if (!tools.exec) return tools.bash; - if (!tools.bash) return tools.exec; - return { ...tools.bash, ...tools.exec }; + if (!tools?.exec) return undefined; + return tools.exec; } export function describeUnknownError(error: unknown): string { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 9ddfdf159..09945897a 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -181,6 +181,7 @@ export function createClawdbotCodingTools(options?: { cwd: options?.workspaceDir, allowBackground, scopeKey, + sessionKey: options?.sessionKey, sandbox: sandbox ? { containerName: sandbox.containerName, diff --git a/src/auto-reply/reply/bash-command.ts b/src/auto-reply/reply/bash-command.ts index dc8797664..0ae45ec03 100644 --- a/src/auto-reply/reply/bash-command.ts +++ b/src/auto-reply/reply/bash-command.ts @@ -328,11 +328,14 @@ export async function handleBashChatCommand(params: { try { const foregroundMs = resolveForegroundMs(params.cfg); const shouldBackgroundImmediately = foregroundMs <= 0; - const timeoutSec = params.cfg.tools?.exec?.timeoutSec ?? params.cfg.tools?.bash?.timeoutSec; + const timeoutSec = params.cfg.tools?.exec?.timeoutSec; + const notifyOnExit = params.cfg.tools?.exec?.notifyOnExit; const execTool = createExecTool({ scopeKey: CHAT_BASH_SCOPE_KEY, allowBackground: true, timeoutSec, + sessionKey: params.sessionKey, + notifyOnExit, elevated: { enabled: params.elevated.enabled, allowed: params.elevated.allowed, diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index 641a1e85b..2af325033 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -141,6 +141,18 @@ describe("legacy config detection", () => { }); expect((res.config as { agent?: unknown }).agent).toBeUndefined(); }); + it("migrates tools.bash to tools.exec", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + tools: { + bash: { timeoutSec: 12 }, + }, + }); + expect(res.changes).toContain("Moved tools.bash → tools.exec."); + expect(res.config?.tools?.exec).toEqual({ timeoutSec: 12 }); + expect((res.config?.tools as { bash?: unknown } | undefined)?.bash).toBeUndefined(); + }); it("accepts per-agent tools.elevated overrides", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index 1f38fe42e..aafe97c73 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -24,6 +24,22 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".'); }, }, + { + id: "tools.bash->tools.exec", + describe: "Move tools.bash to tools.exec", + apply: (raw, changes) => { + const tools = ensureRecord(raw, "tools"); + const bash = getRecord(tools.bash); + if (!bash) return; + if (tools.exec === undefined) { + tools.exec = bash; + changes.push("Moved tools.bash → tools.exec."); + } else { + changes.push("Removed tools.bash (tools.exec already set)."); + } + delete tools.bash; + }, + }, { id: "agent.defaults-v2", describe: "Move agent config to agents.defaults and tools", @@ -59,13 +75,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ const bash = getRecord(agent.bash); if (bash) { - if (tools.exec === undefined && tools.bash === undefined) { + if (tools.exec === undefined) { tools.exec = bash; changes.push("Moved agent.bash → tools.exec."); - } else if (tools.exec !== undefined) { - changes.push("Removed agent.bash (tools.exec already set)."); } else { - changes.push("Removed agent.bash (tools.bash already set)."); + changes.push("Removed agent.bash (tools.exec already set)."); } } diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts index 31bc48037..1ec76bc79 100644 --- a/src/config/legacy.rules.ts +++ b/src/config/legacy.rules.ts @@ -85,6 +85,10 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ message: "agent.* was moved; use agents.defaults (and tools.* for tool/elevated/exec settings) instead (auto-migrated on load).", }, + { + path: ["tools", "bash"], + message: "tools.bash was removed; use tools.exec instead (auto-migrated on load).", + }, { path: ["agent", "model"], message: diff --git a/src/config/schema.ts b/src/config/schema.ts index 60bb21961..88ee6c29f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -135,6 +135,7 @@ const FIELD_LABELS: Record = { "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", "tools.exec.applyPatch.enabled": "Enable apply_patch", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", + "tools.exec.notifyOnExit": "Exec Notify On Exit", "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", @@ -288,6 +289,8 @@ const FIELD_HELP: Record = { "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "tools.exec.applyPatch.allowModels": 'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").', + "tools.exec.notifyOnExit": + "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", "tools.message.allowCrossContextSend": "Legacy override: allow cross-context sends across all providers.", "tools.message.crossContext.allowWithinProvider": diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index f3e9736e7..1b4c32418 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -263,6 +263,8 @@ export type ToolsConfig = { timeoutSec?: number; /** How long to keep finished sessions in memory (ms). */ cleanupMs?: number; + /** Emit a system event and heartbeat when a backgrounded exec exits. */ + notifyOnExit?: boolean; /** apply_patch subtool configuration (experimental). */ applyPatch?: { /** Enable apply_patch for OpenAI models (default: false). */ @@ -274,15 +276,6 @@ export type ToolsConfig = { allowModels?: string[]; }; }; - /** @deprecated Use tools.exec. */ - bash?: { - /** Default time (ms) before a bash command auto-backgrounds. */ - backgroundMs?: number; - /** Default timeout (seconds) before auto-killing bash commands. */ - timeoutSec?: number; - /** How long to keep finished sessions in memory (ms). */ - cleanupMs?: number; - }; /** Sub-agent tool policy defaults (deny wins). */ subagents?: { /** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 6032530aa..b9d721035 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -324,6 +324,7 @@ export const ToolsSchema = z backgroundMs: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(), + notifyOnExit: z.boolean().optional(), applyPatch: z .object({ enabled: z.boolean().optional(), @@ -332,13 +333,6 @@ export const ToolsSchema = z .optional(), }) .optional(), - bash: z - .object({ - backgroundMs: z.number().int().positive().optional(), - timeoutSec: z.number().int().positive().optional(), - cleanupMs: z.number().int().positive().optional(), - }) - .optional(), subagents: z .object({ tools: ToolPolicySchema, diff --git a/src/media-understanding/apply.ts b/src/media-understanding/apply.ts index e5a756e70..0ae1c1b2b 100644 --- a/src/media-understanding/apply.ts +++ b/src/media-understanding/apply.ts @@ -3,8 +3,6 @@ import type { MsgContext } from "../auto-reply/templating.js"; import { applyTemplate } from "../auto-reply/templating.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { resolveApiKeyForProvider } from "../agents/model-auth.js"; -import { ensureClawdbotModelsJson } from "../agents/models-config.js"; -import { minimaxUnderstandImage } from "../agents/minimax-vlm.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { runExec } from "../process/exec.js"; import type {