diff --git a/docs/background-process.md b/docs/background-process.md new file mode 100644 index 000000000..d34f9d242 --- /dev/null +++ b/docs/background-process.md @@ -0,0 +1,65 @@ +--- +summary: "Background bash execution and process management" +read_when: + - Adding or modifying background bash behavior + - Debugging long-running bash tasks +--- + +# Background Bash + Process Tool + +Clawdis runs shell commands through the `bash` tool and keeps long‑running tasks in memory. The `process` tool manages those background sessions. + +## bash tool + +Key parameters: +- `command` (required) +- `yieldMs` (default 20000): auto‑background after this delay +- `background` (bool): background immediately +- `timeout` (seconds): kill the process after this timeout +- `workdir`, `env` + +Behavior: +- Foreground runs return output directly. +- When backgrounded (explicit or timeout), the tool returns `status: "running"` + `sessionId` and a short tail. +- Output is kept in memory until the session is polled or cleared. + +Environment overrides: +- `PI_BASH_YIELD_MS`: default yield (ms) +- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars) +- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h) + +## process tool + +Actions: +- `list`: running + finished sessions +- `poll`: drain new output for a session (also reports exit status) +- `log`: read the aggregated output (supports `offset` + `limit`) +- `write`: send stdin (`data`, optional `eof`) +- `kill`: terminate a background session +- `clear`: remove a finished session from memory +- `remove`: kill if running, otherwise clear if finished + +Notes: +- Only backgrounded sessions are listed/persisted in memory. +- Sessions are lost on process restart (no disk persistence). +- Session logs are only saved to chat history if you run `process poll/log` and the tool result is recorded. + +## Examples + +Run a long task and poll later: +```json +{"tool": "bash", "command": "sleep 5 && echo done", "yieldMs": 1000} +``` +```json +{"tool": "process", "action": "poll", "sessionId": ""} +``` + +Start immediately in background: +```json +{"tool": "bash", "command": "npm run build", "background": true} +``` + +Send stdin: +```json +{"tool": "process", "action": "write", "sessionId": "", "data": "y\n"} +``` diff --git a/docs/tools.md b/docs/tools.md index d682f4f02..22c8f4d61 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -13,6 +13,29 @@ and the agent should rely on them directly. ## Tool inventory +### `bash` +Run shell commands in the workspace. + +Core parameters: +- `command` (required) +- `yieldMs` (auto-background after timeout, default 20000) +- `background` (immediate background) +- `timeout` (seconds; kills the process if exceeded) + +Notes: +- Returns `status: "running"` with a `sessionId` when backgrounded. +- Use `process` to poll/log/write/kill/clear background sessions. + +### `process` +Manage background bash sessions. + +Core actions: +- `list`, `poll`, `log`, `write`, `kill`, `clear`, `remove` + +Notes: +- `poll` returns new output and exit status when complete. +- `log` supports `offset`/`limit` to page through output. + ### `clawdis_browser` Control the dedicated clawd browser. diff --git a/src/agents/bash-process-registry.test.ts b/src/agents/bash-process-registry.test.ts new file mode 100644 index 000000000..1c28facc4 --- /dev/null +++ b/src/agents/bash-process-registry.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + addSession, + appendOutput, + listFinishedSessions, + markBackgrounded, + markExited, + resetProcessRegistryForTests, +} from "./bash-process-registry.js"; + +type DummyChild = { + pid?: number; +}; + +describe("bash process registry", () => { + beforeEach(() => { + resetProcessRegistryForTests(); + }); + + it("captures output and truncates", () => { + const session = { + id: "sess", + command: "echo test", + child: { pid: 123 } as DummyChild, + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 10, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + aggregated: "", + tail: "", + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: false, + }; + + addSession(session as any); + appendOutput(session as any, "stdout", "0123456789"); + appendOutput(session as any, "stdout", "abcdef"); + + expect(session.aggregated).toBe("6789abcdef"); + expect(session.truncated).toBe(true); + }); + + it("only persists finished sessions when backgrounded", () => { + const session = { + id: "sess", + command: "echo test", + child: { pid: 123 } as DummyChild, + startedAt: Date.now(), + cwd: "/tmp", + maxOutputChars: 100, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + aggregated: "", + tail: "", + exited: false, + exitCode: undefined, + exitSignal: undefined, + truncated: false, + backgrounded: false, + }; + + addSession(session as any); + markExited(session as any, 0, null, "completed"); + expect(listFinishedSessions()).toHaveLength(0); + + markBackgrounded(session as any); + markExited(session as any, 0, null, "completed"); + expect(listFinishedSessions()).toHaveLength(1); + }); +}); diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts new file mode 100644 index 000000000..35ac3dd90 --- /dev/null +++ b/src/agents/bash-process-registry.ts @@ -0,0 +1,180 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; + +const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes +const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute +const MAX_JOB_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours + +function clampTtl(value: number | undefined) { + if (!value || Number.isNaN(value)) return DEFAULT_JOB_TTL_MS; + return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS); +} + +const JOB_TTL_MS = clampTtl( + Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10), +); + +export type ProcessStatus = "running" | "completed" | "failed" | "killed"; + +export interface ProcessSession { + id: string; + command: string; + child: ChildProcessWithoutNullStreams; + startedAt: number; + cwd?: string; + maxOutputChars: number; + totalOutputChars: number; + pendingStdout: string[]; + pendingStderr: string[]; + aggregated: string; + tail: string; + exitCode?: number | null; + exitSignal?: NodeJS.Signals | number | null; + exited: boolean; + truncated: boolean; + backgrounded: boolean; +} + +export interface FinishedSession { + id: string; + command: string; + startedAt: number; + endedAt: number; + cwd?: string; + status: ProcessStatus; + exitCode?: number | null; + exitSignal?: NodeJS.Signals | number | null; + aggregated: string; + tail: string; + truncated: boolean; + totalOutputChars: number; +} + +const runningSessions = new Map(); +const finishedSessions = new Map(); + +let sweeper: NodeJS.Timer | null = null; + +export function addSession(session: ProcessSession) { + runningSessions.set(session.id, session); + startSweeper(); +} + +export function getSession(id: string) { + return runningSessions.get(id); +} + +export function getFinishedSession(id: string) { + return finishedSessions.get(id); +} + +export function deleteSession(id: string) { + runningSessions.delete(id); + finishedSessions.delete(id); +} + +export function appendOutput( + session: ProcessSession, + stream: "stdout" | "stderr", + chunk: string, +) { + session.pendingStdout ??= []; + session.pendingStderr ??= []; + const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr; + buffer.push(chunk); + session.totalOutputChars += chunk.length; + const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars); + session.truncated = + session.truncated || aggregated.length < session.aggregated.length + chunk.length; + session.aggregated = aggregated; + session.tail = tail(session.aggregated, 2000); +} + +export function drainSession(session: ProcessSession) { + const stdout = session.pendingStdout.join(""); + const stderr = session.pendingStderr.join(""); + session.pendingStdout = []; + session.pendingStderr = []; + return { stdout, stderr }; +} + +export function markExited( + session: ProcessSession, + exitCode: number | null, + exitSignal: NodeJS.Signals | number | null, + status: ProcessStatus, +) { + session.exited = true; + session.exitCode = exitCode; + session.exitSignal = exitSignal; + session.tail = tail(session.aggregated, 2000); + moveToFinished(session, status); +} + +export function markBackgrounded(session: ProcessSession) { + session.backgrounded = true; +} + +function moveToFinished(session: ProcessSession, status: ProcessStatus) { + runningSessions.delete(session.id); + if (!session.backgrounded) return; + finishedSessions.set(session.id, { + id: session.id, + command: session.command, + startedAt: session.startedAt, + endedAt: Date.now(), + cwd: session.cwd, + status, + exitCode: session.exitCode, + exitSignal: session.exitSignal, + aggregated: session.aggregated, + tail: session.tail, + truncated: session.truncated, + totalOutputChars: session.totalOutputChars, + }); +} + +export function tail(text: string, max = 2000) { + if (text.length <= max) return text; + return text.slice(text.length - max); +} + +export function trimWithCap(text: string, max: number) { + if (text.length <= max) return text; + return text.slice(text.length - max); +} + +export function listRunningSessions() { + return Array.from(runningSessions.values()).filter((s) => s.backgrounded); +} + +export function listFinishedSessions() { + return Array.from(finishedSessions.values()); +} + +export function clearFinished() { + finishedSessions.clear(); +} + +export function resetProcessRegistryForTests() { + runningSessions.clear(); + finishedSessions.clear(); + if (sweeper) { + clearInterval(sweeper); + sweeper = null; + } +} + +function pruneFinishedSessions() { + const cutoff = Date.now() - JOB_TTL_MS; + for (const [id, session] of finishedSessions.entries()) { + if (session.endedAt < cutoff) { + finishedSessions.delete(id); + } + } +} + +function startSweeper() { + if (sweeper) return; + sweeper = setInterval(pruneFinishedSessions, Math.max(30_000, JOB_TTL_MS / 6)); + sweeper.unref?.(); +} diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts new file mode 100644 index 000000000..775d0ecf9 --- /dev/null +++ b/src/agents/bash-tools.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { bashTool, processTool } from "./bash-tools.js"; +import { resetProcessRegistryForTests } from "./bash-process-registry.js"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +beforeEach(() => { + resetProcessRegistryForTests(); +}); + +describe("bash tool backgrounding", () => { + it("backgrounds after yield and can be polled", async () => { + const result = await bashTool.execute("call1", { + command: "node -e \"setTimeout(() => { console.log('done') }, 50)\"", + yieldMs: 10, + }); + + expect(result.details.status).toBe("running"); + const sessionId = (result.details as { sessionId: string }).sessionId; + + let status = "running"; + let output = ""; + const deadline = Date.now() + 2000; + + while (Date.now() < deadline && status === "running") { + const poll = await processTool.execute("call2", { + action: "poll", + sessionId, + }); + status = (poll.details as { status: string }).status; + const textBlock = poll.content.find((c) => c.type === "text"); + output = textBlock?.text ?? ""; + if (status === "running") { + await sleep(20); + } + } + + expect(status).toBe("completed"); + expect(output).toContain("done"); + }); + + it("supports explicit background", async () => { + const result = await bashTool.execute("call1", { + command: "node -e \"setTimeout(() => { console.log('later') }, 50)\"", + background: true, + }); + + expect(result.details.status).toBe("running"); + const sessionId = (result.details as { sessionId: string }).sessionId; + + const list = await processTool.execute("call2", { action: "list" }); + const sessions = (list.details as { sessions: Array<{ sessionId: string }> }).sessions; + expect(sessions.some((s) => s.sessionId === sessionId)).toBe(true); + }); +}); diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts new file mode 100644 index 000000000..2679e678a --- /dev/null +++ b/src/agents/bash-tools.ts @@ -0,0 +1,707 @@ +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; + +import { + addSession, + appendOutput, + deleteSession, + drainSession, + getFinishedSession, + getSession, + listFinishedSessions, + listRunningSessions, + markBackgrounded, + markExited, +} from "./bash-process-registry.js"; +import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "./shell-utils.js"; + +const CHUNK_LIMIT = 8 * 1024; +const DEFAULT_YIELD_MS = clampNumber( + readEnvInt("PI_BASH_YIELD_MS"), + 20_000, + 10, + 120_000, +); +const DEFAULT_MAX_OUTPUT = clampNumber( + readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), + 30_000, + 1_000, + 150_000, +); + +const bashSchema = Type.Object({ + command: Type.String({ description: "Bash command to execute" }), + workdir: Type.Optional( + Type.String({ description: "Working directory (defaults to cwd)" }), + ), + env: Type.Optional(Type.Record(Type.String(), Type.String())), + yieldMs: Type.Optional( + Type.Number({ + description: "Milliseconds to wait before backgrounding (default 20000)", + }), + ), + background: Type.Optional( + Type.Boolean({ description: "Run in background immediately" }), + ), + timeout: Type.Optional( + Type.Number({ + description: "Timeout in seconds (optional, kills process on expiry)", + }), + ), + stdinMode: Type.Optional( + StringEnum(["pipe", "pty"] as const, { + description: "Only pipe is supported", + }), + ), +}); + +export type BashToolDetails = + | { + status: "running"; + sessionId: string; + pid?: number; + startedAt: number; + tail?: string; + } + | { + status: "completed" | "failed"; + exitCode: number | null; + durationMs: number; + aggregated: string; + }; + +export const bashTool: AgentTool = { + name: "bash", + label: "bash", + description: + "Execute bash with background continuation. Use yieldMs/background to continue later via process tool.", + parameters: bashSchema, + execute: async (toolCallId, args, signal, onUpdate) => { + const params = args as { + command: string; + workdir?: string; + env?: Record; + yieldMs?: number; + background?: boolean; + timeout?: number; + stdinMode?: "pipe" | "pty"; + }; + + if (!params.command) { + throw new Error("Provide a command to start."); + } + if (params.stdinMode && params.stdinMode !== "pipe") { + throw new Error('Only stdinMode "pipe" is supported right now.'); + } + + const yieldWindow = params.background + ? 0 + : clampNumber(params.yieldMs, DEFAULT_YIELD_MS, 10, 120_000); + const maxOutput = DEFAULT_MAX_OUTPUT; + const startedAt = Date.now(); + const sessionId = randomUUID(); + const workdir = params.workdir?.trim() || process.cwd(); + + const { shell, args: shellArgs } = getShellConfig(); + const child: ChildProcessWithoutNullStreams = spawn( + shell, + [...shellArgs, params.command], + { + cwd: workdir, + env: { ...process.env, ...(params.env ?? {}) }, + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + + const session = { + id: sessionId, + command: params.command, + child, + startedAt, + cwd: workdir, + maxOutputChars: maxOutput, + totalOutputChars: 0, + pendingStdout: [], + pendingStderr: [], + aggregated: "", + tail: "", + exited: false, + exitCode: undefined as number | null | undefined, + exitSignal: undefined as NodeJS.Signals | number | null | undefined, + truncated: false, + backgrounded: false, + }; + addSession(session); + + let settled = false; + let yielded = false; + let yieldTimer: NodeJS.Timeout | null = null; + let timeoutTimer: NodeJS.Timeout | null = null; + let timedOut = false; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + }; + + const onAbort = () => { + if (child.pid) { + killProcessTree(child.pid); + } + }; + + if (signal?.aborted) onAbort(); + else if (signal) signal.addEventListener("abort", onAbort, { once: true }); + + if (typeof params.timeout === "number" && params.timeout > 0) { + timeoutTimer = setTimeout(() => { + timedOut = true; + onAbort(); + }, params.timeout * 1000); + } + + const emitUpdate = () => { + if (!onUpdate) return; + const tailText = session.tail || session.aggregated; + onUpdate({ + content: [{ type: "text", text: tailText || "" }], + details: { + status: "running", + sessionId, + pid: child.pid ?? undefined, + startedAt, + tail: session.tail, + }, + }); + }; + + child.stdout.on("data", (data) => { + const str = sanitizeBinaryOutput(data.toString()); + for (const chunk of chunkString(str)) { + appendOutput(session, "stdout", chunk); + emitUpdate(); + } + }); + + child.stderr.on("data", (data) => { + const str = sanitizeBinaryOutput(data.toString()); + for (const chunk of chunkString(str)) { + appendOutput(session, "stderr", chunk); + emitUpdate(); + } + }); + + return new Promise>((resolve, reject) => { + const resolveRunning = () => { + settle(() => + resolve({ + content: [ + { + type: "text", + text: + `Command still running (session ${sessionId}, pid ${child.pid ?? "n/a"}). ` + + "Use process (list/poll/log/write/kill/clear) for follow-up.", + }, + ], + details: { + status: "running", + sessionId, + pid: child.pid ?? undefined, + startedAt, + tail: session.tail, + }, + }), + ); + }; + + const onYieldNow = () => { + if (yieldTimer) clearTimeout(yieldTimer); + if (settled) return; + yielded = true; + markBackgrounded(session); + resolveRunning(); + }; + + if (yieldWindow === 0) { + onYieldNow(); + } else { + yieldTimer = setTimeout(() => { + if (settled) return; + yielded = true; + markBackgrounded(session); + resolveRunning(); + }, yieldWindow); + } + + child.once("exit", (code, exitSignal) => { + if (yieldTimer) clearTimeout(yieldTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + const durationMs = Date.now() - startedAt; + const wasSignal = exitSignal != null; + const isSuccess = code === 0 && !wasSignal && !signal?.aborted && !timedOut; + const status: "completed" | "failed" = isSuccess ? "completed" : "failed"; + markExited(session, code, exitSignal, status); + + if (yielded || session.backgrounded) return; + + const aggregated = session.aggregated.trim(); + if (!isSuccess) { + const reason = timedOut + ? `Command timed out after ${params.timeout} seconds` + : wasSignal && exitSignal + ? `Command aborted by signal ${exitSignal}` + : code === null + ? "Command aborted before exit code was captured" + : `Command exited with code ${code}`; + const message = aggregated ? `${aggregated}\n\n${reason}` : reason; + settle(() => reject(new Error(message))); + return; + } + + settle(() => + resolve({ + content: [{ type: "text", text: aggregated || "(no output)" }], + details: { + status: "completed", + exitCode: code ?? 0, + durationMs, + aggregated, + }, + }), + ); + }); + + child.once("error", (err) => { + if (yieldTimer) clearTimeout(yieldTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + markExited(session, null, null, "failed"); + settle(() => reject(err)); + }); + }); + }, +}; + +const processSchema = Type.Object({ + action: StringEnum( + ["list", "poll", "log", "write", "kill", "clear", "remove"] as const, + { + description: "Process action", + }, + ), + sessionId: Type.Optional( + Type.String({ description: "Session id for actions other than list" }), + ), + data: Type.Optional(Type.String({ description: "Data to write for write" })), + eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })), + offset: Type.Optional(Type.Number({ description: "Log offset" })), + limit: Type.Optional(Type.Number({ description: "Log length" })), +}); + +export const processTool: AgentTool = { + name: "process", + label: "process", + description: "Manage running bash sessions: list, poll, log, write, kill.", + parameters: processSchema, + execute: async (_toolCallId, args) => { + const params = args as { + action: "list" | "poll" | "log" | "write" | "kill" | "clear" | "remove"; + sessionId?: string; + data?: string; + eof?: boolean; + offset?: number; + limit?: number; + }; + + if (params.action === "list") { + const running = listRunningSessions().map((s) => ({ + sessionId: s.id, + status: "running", + pid: s.child.pid ?? undefined, + startedAt: s.startedAt, + runtimeMs: Date.now() - s.startedAt, + cwd: s.cwd, + command: s.command, + tail: s.tail, + truncated: s.truncated, + })); + const finished = listFinishedSessions().map((s) => ({ + sessionId: s.id, + status: s.status, + startedAt: s.startedAt, + endedAt: s.endedAt, + runtimeMs: s.endedAt - s.startedAt, + cwd: s.cwd, + command: s.command, + tail: s.tail, + truncated: s.truncated, + exitCode: s.exitCode ?? undefined, + exitSignal: s.exitSignal ?? undefined, + })); + const lines = [...running, ...finished] + .sort((a, b) => b.startedAt - a.startedAt) + .map( + (s) => + `${s.sessionId.slice(0, 8)} ${pad(s.status, 9)} ${formatDuration( + s.runtimeMs, + )} :: ${truncateMiddle(s.command, 120)}`, + ); + return { + content: [ + { type: "text", text: lines.join("\n") || "No running or recent sessions." }, + ], + details: { status: "completed", sessions: [...running, ...finished] }, + }; + } + + if (!params.sessionId) { + return { + content: [{ type: "text", text: "sessionId is required for this action." }], + details: { status: "failed" }, + }; + } + + const session = getSession(params.sessionId); + const finished = getFinishedSession(params.sessionId); + + switch (params.action) { + case "poll": { + if (!session) { + if (finished) { + return { + content: [ + { + type: "text", + text: + (finished.tail || + `(no output recorded${ + finished.truncated ? " — truncated to cap" : "" + })`) + + `\n\nProcess exited with ${ + finished.exitSignal + ? `signal ${finished.exitSignal}` + : `code ${finished.exitCode ?? 0}` + }.`, + }, + ], + details: { + status: finished.status === "completed" ? "completed" : "failed", + sessionId: params.sessionId, + exitCode: finished.exitCode ?? undefined, + aggregated: finished.aggregated, + }, + }; + } + return { + content: [ + { type: "text", text: `No session found for ${params.sessionId}` }, + ], + details: { status: "failed" }, + }; + } + if (!session.backgrounded) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} is not backgrounded.`, + }, + ], + details: { status: "failed" }, + }; + } + const { stdout, stderr } = drainSession(session); + const exited = session.exited; + const exitCode = session.exitCode ?? 0; + const exitSignal = session.exitSignal ?? undefined; + if (exited) { + const status = + exitCode === 0 && exitSignal == null ? "completed" : "failed"; + markExited(session, session.exitCode ?? null, session.exitSignal ?? null, status); + } + const status = exited + ? exitCode === 0 && exitSignal == null + ? "completed" + : "failed" + : "running"; + const output = [stdout.trimEnd(), stderr.trimEnd()] + .filter(Boolean) + .join("\n") + .trim(); + return { + content: [ + { + type: "text", + text: + (output || "(no new output)") + + (exited + ? `\n\nProcess exited with ${ + exitSignal ? `signal ${exitSignal}` : `code ${exitCode}` + }.` + : "\n\nProcess still running."), + }, + ], + details: { + status, + sessionId: params.sessionId, + exitCode: exited ? exitCode : undefined, + aggregated: session.aggregated, + }, + }; + } + + case "log": { + if (session) { + if (!session.backgrounded) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} is not backgrounded.`, + }, + ], + details: { status: "failed" }, + }; + } + const total = session.aggregated.length; + const slice = session.aggregated.slice( + params.offset ?? 0, + params.limit + ? (params.offset ?? 0) + params.limit + : undefined, + ); + return { + content: [{ type: "text", text: slice || "(no output yet)" }], + details: { + status: session.exited ? "completed" : "running", + sessionId: params.sessionId, + total, + truncated: session.truncated, + }, + }; + } + if (finished) { + const total = finished.aggregated.length; + const slice = finished.aggregated.slice( + params.offset ?? 0, + params.limit + ? (params.offset ?? 0) + params.limit + : undefined, + ); + const status = finished.status === "completed" ? "completed" : "failed"; + return { + content: [ + { type: "text", text: slice || "(no output recorded)" }, + ], + details: { + status, + sessionId: params.sessionId, + total, + truncated: finished.truncated, + exitCode: finished.exitCode ?? undefined, + exitSignal: finished.exitSignal ?? undefined, + }, + }; + } + return { + content: [ + { type: "text", text: `No session found for ${params.sessionId}` }, + ], + details: { status: "failed" }, + }; + } + + case "write": { + if (!session) { + return { + content: [ + { type: "text", text: `No active session found for ${params.sessionId}` }, + ], + details: { status: "failed" }, + }; + } + if (!session.backgrounded) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} is not backgrounded.`, + }, + ], + details: { status: "failed" }, + }; + } + if (!session.child.stdin || session.child.stdin.destroyed) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} stdin is not writable.`, + }, + ], + details: { status: "failed" }, + }; + } + await new Promise((resolve, reject) => { + session.child.stdin.write(params.data ?? "", (err) => { + if (err) reject(err); + else resolve(); + }); + }); + if (params.eof) { + session.child.stdin.end(); + } + return { + content: [ + { + type: "text", + text: `Wrote ${(params.data ?? "").length} bytes to session ${ + params.sessionId + }${params.eof ? " (stdin closed)" : ""}.`, + }, + ], + details: { status: "running", sessionId: params.sessionId }, + }; + } + + case "kill": { + if (!session) { + return { + content: [ + { type: "text", text: `No active session found for ${params.sessionId}` }, + ], + details: { status: "failed" }, + }; + } + if (!session.backgrounded) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} is not backgrounded.`, + }, + ], + details: { status: "failed" }, + }; + } + if (session.child.pid) { + killProcessTree(session.child.pid); + } + markExited(session, null, "SIGKILL", "failed"); + return { + content: [ + { type: "text", text: `Killed session ${params.sessionId}.` }, + ], + details: { status: "failed" }, + }; + } + + case "clear": { + if (finished) { + deleteSession(params.sessionId); + return { + content: [ + { type: "text", text: `Cleared session ${params.sessionId}.` }, + ], + details: { status: "completed" }, + }; + } + return { + content: [ + { + type: "text", + text: `No finished session found for ${params.sessionId}`, + }, + ], + details: { status: "failed" }, + }; + } + + case "remove": { + if (session) { + if (session.child.pid) { + killProcessTree(session.child.pid); + } + markExited(session, null, "SIGKILL", "failed"); + return { + content: [ + { type: "text", text: `Removed session ${params.sessionId}.` }, + ], + details: { status: "failed" }, + }; + } + if (finished) { + deleteSession(params.sessionId); + return { + content: [ + { type: "text", text: `Removed session ${params.sessionId}.` }, + ], + details: { status: "completed" }, + }; + } + return { + content: [ + { type: "text", text: `No session found for ${params.sessionId}` }, + ], + details: { status: "failed" }, + }; + } + } + + return { + content: [ + { type: "text", text: `Unknown action ${params.action as string}` }, + ], + details: { status: "failed" }, + }; + }, +}; + +function clampNumber( + value: number | undefined, + defaultValue: number, + min: number, + max: number, +) { + if (value === undefined || Number.isNaN(value)) return defaultValue; + return Math.min(Math.max(value, min), max); +} + +function readEnvInt(key: string) { + const raw = process.env[key]; + if (!raw) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function chunkString(input: string, limit = CHUNK_LIMIT) { + const chunks: string[] = []; + for (let i = 0; i < input.length; i += limit) { + chunks.push(input.slice(i, i + limit)); + } + return chunks; +} + +function truncateMiddle(str: string, max: number) { + if (str.length <= max) return str; + const half = Math.floor((max - 3) / 2); + return `${str.slice(0, half)}...${str.slice(str.length - half)}`; +} + +function formatDuration(ms: number) { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const rem = seconds % 60; + return `${minutes}m${rem.toString().padStart(2, "0")}s`; +} + +function pad(str: string, width: number) { + if (str.length >= width) return str; + return str + " ".repeat(width - str.length); +} diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 77463f33d..0a1d2edf3 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -65,4 +65,10 @@ describe("createClawdisCodingTools", () => { } } }); + + it("includes bash and process tools", () => { + const tools = createClawdisCodingTools(); + expect(tools.some((tool) => tool.name === "bash")).toBe(true); + expect(tools.some((tool) => tool.name === "process")).toBe(true); + }); }); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 273641ffb..044f43acb 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -1,9 +1,10 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai"; -import { bashTool, codingTools, readTool } from "@mariozechner/pi-coding-agent"; +import { codingTools, readTool } from "@mariozechner/pi-coding-agent"; import { type TSchema, Type } from "@sinclair/typebox"; import { detectMime } from "../media/mime.js"; import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; +import { bashTool, processTool } from "./bash-tools.js"; import { createClawdisTools } from "./clawdis-tools.js"; import { sanitizeToolResultImages } from "./tool-images.js"; @@ -287,29 +288,17 @@ function createClawdisReadTool(base: AnyAgentTool): AnyAgentTool { }; } -function createClawdisBashTool(base: AnyAgentTool): AnyAgentTool { - return { - ...base, - execute: async (toolCallId, params, signal) => { - const result = (await base.execute( - toolCallId, - params, - signal, - )) as AgentToolResult; - return sanitizeToolResultImages(result, "bash"); - }, - }; -} - export function createClawdisCodingTools(): AnyAgentTool[] { - const base = (codingTools as unknown as AnyAgentTool[]).map((tool) => - tool.name === readTool.name - ? createClawdisReadTool(tool) - : tool.name === bashTool.name - ? createClawdisBashTool(tool) - : (tool as AnyAgentTool), - ); - return [...base, createWhatsAppLoginTool(), ...createClawdisTools()].map( - normalizeToolParameters, - ); + const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { + if (tool.name === readTool.name) return [createClawdisReadTool(tool)]; + if (tool.name === bashTool.name) return []; + return [tool as AnyAgentTool]; + }); + return [ + ...base, + bashTool, + processTool, + createWhatsAppLoginTool(), + ...createClawdisTools(), + ].map(normalizeToolParameters); } diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts new file mode 100644 index 000000000..488c65a53 --- /dev/null +++ b/src/agents/shell-utils.ts @@ -0,0 +1,41 @@ +import { spawn } from "node:child_process"; + +export function getShellConfig(): { shell: string; args: string[] } { + if (process.platform === "win32") { + const shell = process.env.COMSPEC?.trim() || "cmd.exe"; + return { shell, args: ["/d", "/s", "/c"] }; + } + + const shell = process.env.SHELL?.trim() || "sh"; + return { shell, args: ["-c"] }; +} + +export function sanitizeBinaryOutput(text: string): string { + return text + .replace(/[\p{Format}\p{Surrogate}]/gu, "") + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); +} + +export function killProcessTree(pid: number): void { + if (process.platform === "win32") { + try { + spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + stdio: "ignore", + detached: true, + }); + } catch { + // ignore errors if taskkill fails + } + return; + } + + try { + process.kill(-pid, "SIGKILL"); + } catch { + try { + process.kill(pid, "SIGKILL"); + } catch { + // process already dead + } + } +} diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 62fc75768..cf5fb487f 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -59,6 +59,8 @@ export function buildAgentSystemPromptAppend(params: { "- grep: search file contents for patterns", "- find: find files by glob pattern", "- ls: list directory contents", + "- bash: run shell commands (supports background via yieldMs/background)", + "- process: manage background bash sessions", "- whatsapp_login: generate a WhatsApp QR code and wait for linking", "- clawdis_browser: control clawd's dedicated browser", "- clawdis_canvas: present/eval/snapshot the Canvas",