From aafcd569b10fd8e90da1a41f5adbeaad4a88ca47 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 25 Dec 2025 17:58:19 +0000 Subject: [PATCH] feat: line-based process logs --- CHANGELOG.md | 1 + docs/background-process.md | 9 +- docs/configuration.md | 10 + docs/tools.md | 13 +- src/agents/bash-process-registry.ts | 27 +- src/agents/bash-tools.test.ts | 106 ++- src/agents/bash-tools.ts | 975 ++++++++++++++++------------ src/agents/pi-embedded-runner.ts | 4 +- src/agents/pi-tools.test.ts | 6 + src/agents/pi-tools.ts | 27 +- src/config/config.ts | 16 + 11 files changed, 738 insertions(+), 456 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9976d23e4..39942ba6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Streamed `` segments are stripped before partial replies are emitted. - System prompt now tags allowlisted owner numbers as the user identity to avoid mistaken “friend” assumptions. - LM Studio/Ollama replies now require tags; streaming ignores content until begins. +- `process log` pagination is now line-based (omit `offset` to grab the last N lines). - UI perf: pause repeat animations when scenes are inactive (typing dots, onboarding glow, iOS status pulse), throttle voice overlay level updates, and reduce overlay focus churn. - Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`. - Gateway launchd loop fixed by removing redundant `kickstart -k`. diff --git a/docs/background-process.md b/docs/background-process.md index d34f9d242..8e952f91a 100644 --- a/docs/background-process.md +++ b/docs/background-process.md @@ -15,7 +15,7 @@ Key parameters: - `command` (required) - `yieldMs` (default 20000): auto‑background after this delay - `background` (bool): background immediately -- `timeout` (seconds): kill the process after this timeout +- `timeout` (seconds, default 1800): kill the process after this timeout - `workdir`, `env` Behavior: @@ -28,6 +28,11 @@ Environment overrides: - `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars) - `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h) +Config (preferred): +- `agent.bash.backgroundMs` (default 20000) +- `agent.bash.timeoutSec` (default 1800) +- `agent.bash.cleanupMs` (default 1800000) + ## process tool Actions: @@ -43,6 +48,8 @@ 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. +- `process list` includes a derived `name` (command verb + target) for quick scans. +- `process log` uses line-based `offset`/`limit` (omit `offset` to grab the last N lines). ## Examples diff --git a/docs/configuration.md b/docs/configuration.md index bba03e23f..ae2075f4e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -131,11 +131,21 @@ Controls the embedded agent runtime (provider/model/thinking/verbose/timeouts). timeoutSeconds: 600, mediaMaxMb: 5, heartbeatMinutes: 30, + bash: { + backgroundMs: 20000, + timeoutSec: 1800, + cleanupMs: 1800000 + }, contextTokens: 200000 } } ``` +`agent.bash` configures background bash defaults: +- `backgroundMs`: time before auto-background (ms, default 20000) +- `timeoutSec`: auto-kill after this runtime (seconds, default 1800) +- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000) + ### `models` (custom providers + base URLs) Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers diff --git a/docs/tools.md b/docs/tools.md index 22c8f4d61..ca470fbe1 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -20,7 +20,7 @@ Core parameters: - `command` (required) - `yieldMs` (auto-background after timeout, default 20000) - `background` (immediate background) -- `timeout` (seconds; kills the process if exceeded) +- `timeout` (seconds; kills the process if exceeded, default 1800) Notes: - Returns `status: "running"` with a `sessionId` when backgrounded. @@ -34,7 +34,7 @@ Core actions: Notes: - `poll` returns new output and exit status when complete. -- `log` supports `offset`/`limit` to page through output. +- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines). ### `clawdis_browser` Control the dedicated clawd browser. @@ -90,6 +90,15 @@ Notes: - `add` expects a full cron job object (same schema as `cron.add` RPC). - `update` uses `{ jobId, patch }`. +### `clawdis_gateway` +Restart the running Gateway process (in-place). + +Core actions: +- `restart` (sends `SIGUSR1` to the current process; `clawdis gateway`/`gateway-daemon` restart in-place) + +Notes: +- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. + ## Parameters (common) Gateway-backed tools (`clawdis_canvas`, `clawdis_nodes`, `clawdis_cron`): diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index 997379120..cec7767a9 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -9,7 +9,7 @@ function clampTtl(value: number | undefined) { return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS); } -const JOB_TTL_MS = clampTtl( +let jobTtlMs = clampTtl( Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10), ); @@ -163,14 +163,18 @@ export function clearFinished() { export function resetProcessRegistryForTests() { runningSessions.clear(); finishedSessions.clear(); - if (sweeper) { - clearInterval(sweeper); - sweeper = null; - } + stopSweeper(); +} + +export function setJobTtlMs(value?: number) { + if (value === undefined || Number.isNaN(value)) return; + jobTtlMs = clampTtl(value); + stopSweeper(); + startSweeper(); } function pruneFinishedSessions() { - const cutoff = Date.now() - JOB_TTL_MS; + const cutoff = Date.now() - jobTtlMs; for (const [id, session] of finishedSessions.entries()) { if (session.endedAt < cutoff) { finishedSessions.delete(id); @@ -180,9 +184,12 @@ function pruneFinishedSessions() { function startSweeper() { if (sweeper) return; - sweeper = setInterval( - pruneFinishedSessions, - Math.max(30_000, JOB_TTL_MS / 6), - ); + sweeper = setInterval(pruneFinishedSessions, Math.max(30_000, jobTtlMs / 6)); sweeper.unref?.(); } + +function stopSweeper() { + if (!sweeper) return; + clearInterval(sweeper); + sweeper = null; +} diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index ef4f61bbe..48deebdf4 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -1,9 +1,30 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { + bashTool, + createBashTool, + createProcessTool, + processTool, +} from "./bash-tools.js"; import { resetProcessRegistryForTests } from "./bash-process-registry.js"; -import { bashTool, processTool } from "./bash-tools.js"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +async function waitForCompletion(sessionId: string) { + let status = "running"; + const deadline = Date.now() + 2000; + while (Date.now() < deadline && status === "running") { + const poll = await processTool.execute("call-wait", { + action: "poll", + sessionId, + }); + status = (poll.details as { status: string }).status; + if (status === "running") { + await sleep(20); + } + } + return status; +} + beforeEach(() => { resetProcessRegistryForTests(); }); @@ -54,4 +75,87 @@ describe("bash tool backgrounding", () => { ).sessions; expect(sessions.some((s) => s.sessionId === sessionId)).toBe(true); }); + + it("derives a session name from the command", async () => { + const result = await bashTool.execute("call1", { + command: "echo hello", + background: true, + }); + const sessionId = (result.details as { sessionId: string }).sessionId; + await sleep(25); + + const list = await processTool.execute("call2", { action: "list" }); + const sessions = ( + list.details as { sessions: Array<{ sessionId: string; name?: string }> } + ).sessions; + const entry = sessions.find((s) => s.sessionId === sessionId); + expect(entry?.name).toBe("echo hello"); + }); + + it("uses default timeout when timeout is omitted", async () => { + const customBash = createBashTool({ timeoutSec: 1, backgroundMs: 10 }); + const customProcess = createProcessTool(); + + const result = await customBash.execute("call1", { + command: "node -e \"setInterval(() => {}, 1000)\"", + background: true, + }); + + const sessionId = (result.details as { sessionId: string }).sessionId; + let status = "running"; + const deadline = Date.now() + 5000; + + while (Date.now() < deadline && status === "running") { + const poll = await customProcess.execute("call2", { + action: "poll", + sessionId, + }); + status = (poll.details as { status: string }).status; + if (status === "running") { + await sleep(50); + } + } + + expect(status).toBe("failed"); + }); + + it("logs line-based slices and defaults to last lines", async () => { + const result = await bashTool.execute("call1", { + command: + "node -e \"console.log('one'); console.log('two'); console.log('three');\"", + background: true, + }); + const sessionId = (result.details as { sessionId: string }).sessionId; + + const status = await waitForCompletion(sessionId); + + const log = await processTool.execute("call3", { + action: "log", + sessionId, + limit: 2, + }); + const textBlock = log.content.find((c) => c.type === "text"); + expect(textBlock?.text).toBe("two\nthree"); + expect((log.details as { totalLines?: number }).totalLines).toBe(3); + expect(status).toBe("completed"); + }); + + it("supports line offsets for log slices", async () => { + const result = await bashTool.execute("call1", { + command: + "node -e \"console.log('alpha'); console.log('beta'); console.log('gamma');\"", + background: true, + }); + const sessionId = (result.details as { sessionId: string }).sessionId; + await waitForCompletion(sessionId); + + const log = await processTool.execute("call2", { + action: "log", + sessionId, + offset: 1, + limit: 1, + }); + const textBlock = log.content.find((c) => c.type === "text"); + expect(textBlock?.text).toBe("beta"); + }); }); diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index fa8dd7688..d23243f7e 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -1,8 +1,8 @@ -import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; 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, @@ -15,20 +15,11 @@ import { listRunningSessions, markBackgrounded, markExited, + setJobTtlMs, } from "./bash-process-registry.js"; -import { - getShellConfig, - killProcessTree, - sanitizeBinaryOutput, -} from "./shell-utils.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, @@ -36,6 +27,15 @@ const DEFAULT_MAX_OUTPUT = clampNumber( 150_000, ); +export type BashToolDefaults = { + backgroundMs?: number; + timeoutSec?: number; +}; + +export type ProcessToolDefaults = { + cleanupMs?: number; +}; + const bashSchema = Type.Object({ command: Type.String({ description: "Bash command to execute" }), workdir: Type.Optional( @@ -77,222 +77,247 @@ export type BashToolDetails = 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"; - }; +export function createBashTool( + defaults?: BashToolDefaults, +): AgentTool { + const defaultBackgroundMs = clampNumber( + defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"), + 20_000, + 10, + 120_000, + ); + const defaultTimeoutSec = + typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0 + ? defaults.timeoutSec + : 1800; - 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.'); - } + return { + 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"; + }; - 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 env = params.env ?? {}; - const child: ChildProcessWithoutNullStreams = spawn( - shell, - [...shellArgs, params.command], - { - cwd: workdir, - env: { ...process.env, ...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 (!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.'); } - }; - if (signal?.aborted) onAbort(); - else if (signal) signal.addEventListener("abort", onAbort, { once: true }); + const yieldWindow = params.background + ? 0 + : clampNumber( + params.yieldMs ?? defaultBackgroundMs, + defaultBackgroundMs, + 10, + 120_000, + ); + const maxOutput = DEFAULT_MAX_OUTPUT; + const startedAt = Date.now(); + const sessionId = randomUUID(); + const workdir = params.workdir?.trim() || process.cwd(); - 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, + 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"], }, - }); - }; + ); - 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 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); - const onYieldNow = () => { - if (yieldTimer) clearTimeout(yieldTimer); + 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; - yielded = true; - markBackgrounded(session); - resolveRunning(); + settled = true; + fn(); }; - if (yieldWindow === 0) { - onYieldNow(); - } else { - yieldTimer = setTimeout(() => { - if (settled) return; - yielded = true; - markBackgrounded(session); - resolveRunning(); - }, yieldWindow); + const onAbort = () => { + if (child.pid) { + killProcessTree(child.pid); + } + }; + + if (signal?.aborted) onAbort(); + else if (signal) signal.addEventListener("abort", onAbort, { once: true }); + + const effectiveTimeout = + typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec; + if (effectiveTimeout > 0) { + timeoutTimer = setTimeout(() => { + timedOut = true; + onAbort(); + }, effectiveTimeout * 1000); } - 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); + 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, + }, + }); + }; - 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; + child.stdout.on("data", (data) => { + const str = sanitizeBinaryOutput(data.toString()); + for (const chunk of chunkString(str)) { + appendOutput(session, "stdout", chunk); + emitUpdate(); } - - 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)); + 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/remove) 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 ${effectiveTimeout} 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)); + }); + }, + ); + }, + }; +} + +export const bashTool = createBashTool(); const processSchema = Type.Object({ action: StringEnum( @@ -310,172 +335,186 @@ const processSchema = Type.Object({ 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; - }; +export function createProcessTool( + defaults?: ProcessToolDefaults, +): AgentTool { + if (defaults?.cleanupMs !== undefined) { + setJobTtlMs(defaults.cleanupMs); + } - 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] }, + return { + 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.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(); + 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, + name: deriveSessionName(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, + name: deriveSessionName(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) => { + const label = s.name + ? truncateMiddle(s.name, 80) + : truncateMiddle(s.command, 120); + return `${s.sessionId.slice(0, 8)} ${pad( + s.status, + 9, + )} ${formatDuration(s.runtimeMs)} :: ${label}`; + }); return { content: [ { type: "text", - text: - (output || "(no new output)") + - (exited - ? `\n\nProcess exited with ${ - exitSignal ? `signal ${exitSignal}` : `code ${exitCode}` - }.` - : "\n\nProcess still running."), + text: lines.join("\n") || "No running or recent sessions.", }, ], - details: { - status, - sessionId: params.sessionId, - exitCode: exited ? exitCode : undefined, - aggregated: session.aggregated, - }, + 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, + name: deriveSessionName(finished.command), + }, + }; + } + 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, + name: deriveSessionName(session.command), + }, + }; + } + case "log": { if (session) { if (!session.backgrounded) { @@ -489,38 +528,45 @@ export const processTool: AgentTool = { details: { status: "failed" }, }; } - const total = session.aggregated.length; - const slice = session.aggregated.slice( - params.offset ?? 0, - params.limit ? (params.offset ?? 0) + params.limit : undefined, + const { slice, totalLines, totalChars } = sliceLogLines( + session.aggregated, + params.offset, + params.limit, ); return { content: [{ type: "text", text: slice || "(no output yet)" }], details: { status: session.exited ? "completed" : "running", sessionId: params.sessionId, - total, + total: totalLines, + totalLines, + totalChars, truncated: session.truncated, + name: deriveSessionName(session.command), }, }; } if (finished) { - const total = finished.aggregated.length; - const slice = finished.aggregated.slice( - params.offset ?? 0, - params.limit ? (params.offset ?? 0) + params.limit : undefined, + const { slice, totalLines, totalChars } = sliceLogLines( + finished.aggregated, + params.offset, + params.limit, ); - const status = - finished.status === "completed" ? "completed" : "failed"; + const status = finished.status === "completed" ? "completed" : "failed"; return { - content: [{ type: "text", text: slice || "(no output recorded)" }], + content: [ + { type: "text", text: slice || "(no output recorded)" }, + ], details: { status, sessionId: params.sessionId, - total, + total: totalLines, + totalLines, + totalChars, truncated: finished.truncated, exitCode: finished.exitCode ?? undefined, exitSignal: finished.exitSignal ?? undefined, + name: deriveSessionName(finished.command), }, }; } @@ -536,10 +582,7 @@ export const processTool: AgentTool = { if (!session) { return { content: [ - { - type: "text", - text: `No active session found for ${params.sessionId}`, - }, + { type: "text", text: `No active session found for ${params.sessionId}` }, ], details: { status: "failed" }, }; @@ -584,7 +627,11 @@ export const processTool: AgentTool = { }${params.eof ? " (stdin closed)" : ""}.`, }, ], - details: { status: "running", sessionId: params.sessionId }, + details: { + status: "running", + sessionId: params.sessionId, + name: session ? deriveSessionName(session.command) : undefined, + }, }; } @@ -592,10 +639,7 @@ export const processTool: AgentTool = { if (!session) { return { content: [ - { - type: "text", - text: `No active session found for ${params.sessionId}`, - }, + { type: "text", text: `No active session found for ${params.sessionId}` }, ], details: { status: "failed" }, }; @@ -619,70 +663,79 @@ export const processTool: AgentTool = { content: [ { type: "text", text: `Killed session ${params.sessionId}.` }, ], - details: { status: "failed" }, + details: { + status: "failed", + name: session ? deriveSessionName(session.command) : undefined, + }, }; } - 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); + case "clear": { + if (finished) { + deleteSession(params.sessionId); + return { + content: [ + { type: "text", text: `Cleared session ${params.sessionId}.` }, + ], + details: { status: "completed" }, + }; } - markExited(session, null, "SIGKILL", "failed"); return { content: [ - { type: "text", text: `Removed session ${params.sessionId}.` }, + { + type: "text", + text: `No finished session found for ${params.sessionId}`, + }, ], details: { status: "failed" }, }; } - if (finished) { - deleteSession(params.sessionId); + + 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", + name: session ? deriveSessionName(session.command) : undefined, + }, + }; + } + if (finished) { + deleteSession(params.sessionId); + return { + content: [ + { type: "text", text: `Removed session ${params.sessionId}.` }, + ], + details: { status: "completed" }, + }; + } return { content: [ - { type: "text", text: `Removed session ${params.sessionId}.` }, + { type: "text", text: `No session found for ${params.sessionId}` }, ], - details: { status: "completed" }, + details: { status: "failed" }, }; } - 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" }, - }; - }, -}; + return { + content: [ + { type: "text", text: `Unknown action ${params.action as string}` }, + ], + details: { status: "failed" }, + }; + }, + }; +} + +export const processTool = createProcessTool(); function clampNumber( value: number | undefined, @@ -715,6 +768,62 @@ function truncateMiddle(str: string, max: number) { return `${str.slice(0, half)}...${str.slice(str.length - half)}`; } +function sliceLogLines( + text: string, + offset?: number, + limit?: number, +): { slice: string; totalLines: number; totalChars: number } { + if (!text) return { slice: "", totalLines: 0, totalChars: 0 }; + const normalized = text.replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + const totalLines = lines.length; + const totalChars = text.length; + let start = + typeof offset === "number" && Number.isFinite(offset) + ? Math.max(0, Math.floor(offset)) + : 0; + if (limit !== undefined && offset === undefined) { + const tailCount = Math.max(0, Math.floor(limit)); + start = Math.max(totalLines - tailCount, 0); + } + const end = + typeof limit === "number" && Number.isFinite(limit) + ? start + Math.max(0, Math.floor(limit)) + : undefined; + return { slice: lines.slice(start, end).join("\n"), totalLines, totalChars }; +} + +function deriveSessionName(command: string): string | undefined { + const tokens = tokenizeCommand(command); + if (tokens.length === 0) return undefined; + const verb = tokens[0]; + let target = tokens.slice(1).find((t) => !t.startsWith("-")); + if (!target) target = tokens[1]; + if (!target) return verb; + const cleaned = truncateMiddle(stripQuotes(target), 48); + return `${stripQuotes(verb)} ${cleaned}`; +} + +function tokenizeCommand(command: string): string[] { + const matches = + command.match(/(?:[^\s"']+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g) ?? []; + return matches.map((token) => stripQuotes(token)).filter(Boolean); +} + +function stripQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + function formatDuration(ms: number) { if (ms < 1000) return `${ms}ms`; const seconds = Math.floor(ms / 1000); diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 5fd25fd44..3caa05214 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -325,7 +325,9 @@ export async function runEmbeddedPiAgent(params: { await loadWorkspaceBootstrapFiles(resolvedWorkspace); const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); - const tools = createClawdisCodingTools(); + const tools = createClawdisCodingTools({ + bash: params.config?.agent?.bash, + }); const machineName = await getMachineDisplayName(); const runtimeInfo = { host: machineName, diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 0a1d2edf3..bc95eb4b1 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -32,6 +32,9 @@ describe("createClawdisCodingTools", () => { anyOf?: Array<{ properties?: Record }>; properties?: Record; }; + if (!Array.isArray(parameters.anyOf) || parameters.anyOf.length === 0) { + continue; + } const actionValues = new Set(); for (const variant of parameters.anyOf ?? []) { const action = variant?.properties?.action as @@ -45,6 +48,9 @@ describe("createClawdisCodingTools", () => { } } + if (actionValues.size <= 1) { + continue; + } const mergedAction = parameters.properties?.action as | { const?: unknown; enum?: unknown[] } | undefined; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 904ddad07..16bf9e776 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -4,7 +4,12 @@ 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 { + type BashToolDefaults, + type ProcessToolDefaults, + createBashTool, + createProcessTool, +} from "./bash-tools.js"; import { createClawdisTools } from "./clawdis-tools.js"; import { sanitizeToolResultImages } from "./tool-images.js"; @@ -288,18 +293,24 @@ function createClawdisReadTool(base: AnyAgentTool): AnyAgentTool { }; } -export function createClawdisCodingTools(): AnyAgentTool[] { +export function createClawdisCodingTools(options?: { + bash?: BashToolDefaults & ProcessToolDefaults; +}): AnyAgentTool[] { + const bashToolName = "bash"; const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { if (tool.name === readTool.name) return [createClawdisReadTool(tool)]; - if (tool.name === bashTool.name) return []; + if (tool.name === bashToolName) return []; return [tool as AnyAgentTool]; }); - const tools: AnyAgentTool[] = [ + const bashTool = createBashTool(options?.bash); + const processTool = createProcessTool({ + cleanupMs: options?.bash?.cleanupMs, + }); + return [ ...base, - bashTool as unknown as AnyAgentTool, - processTool as unknown as AnyAgentTool, + bashTool, + processTool, createWhatsAppLoginTool(), ...createClawdisTools(), - ]; - return tools.map(normalizeToolParameters); + ].map(normalizeToolParameters); } diff --git a/src/config/config.ts b/src/config/config.ts index 8d2a99f7c..ab31d041d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -325,6 +325,15 @@ export type ClawdisConfig = { typingIntervalSeconds?: number; /** Periodic background heartbeat runs (minutes). 0 disables. */ heartbeatMinutes?: number; + /** Bash tool defaults. */ + 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; + }; }; routing?: RoutingConfig; messages?: MessagesConfig; @@ -573,6 +582,13 @@ const ClawdisSchema = z.object({ mediaMaxMb: z.number().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(), heartbeatMinutes: z.number().nonnegative().optional(), + bash: z + .object({ + backgroundMs: z.number().int().positive().optional(), + timeoutSec: z.number().int().positive().optional(), + cleanupMs: z.number().int().positive().optional(), + }) + .optional(), }) .optional(), routing: RoutingSchema,