diff --git a/src/agents/bash-process-registry.test.ts b/src/agents/bash-process-registry.test.ts index 1c28facc4..2c5b04561 100644 --- a/src/agents/bash-process-registry.test.ts +++ b/src/agents/bash-process-registry.test.ts @@ -1,4 +1,6 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { beforeEach, describe, expect, it } from "vitest"; +import type { ProcessSession } from "./bash-process-registry.js"; import { addSession, appendOutput, @@ -8,20 +10,16 @@ import { resetProcessRegistryForTests, } from "./bash-process-registry.js"; -type DummyChild = { - pid?: number; -}; - describe("bash process registry", () => { beforeEach(() => { resetProcessRegistryForTests(); }); it("captures output and truncates", () => { - const session = { + const session: ProcessSession = { id: "sess", command: "echo test", - child: { pid: 123 } as DummyChild, + child: { pid: 123 } as ChildProcessWithoutNullStreams, startedAt: Date.now(), cwd: "/tmp", maxOutputChars: 10, @@ -37,19 +35,19 @@ describe("bash process registry", () => { backgrounded: false, }; - addSession(session as any); - appendOutput(session as any, "stdout", "0123456789"); - appendOutput(session as any, "stdout", "abcdef"); + addSession(session); + appendOutput(session, "stdout", "0123456789"); + appendOutput(session, "stdout", "abcdef"); expect(session.aggregated).toBe("6789abcdef"); expect(session.truncated).toBe(true); }); it("only persists finished sessions when backgrounded", () => { - const session = { + const session: ProcessSession = { id: "sess", command: "echo test", - child: { pid: 123 } as DummyChild, + child: { pid: 123 } as ChildProcessWithoutNullStreams, startedAt: Date.now(), cwd: "/tmp", maxOutputChars: 100, @@ -65,12 +63,12 @@ describe("bash process registry", () => { backgrounded: false, }; - addSession(session as any); - markExited(session as any, 0, null, "completed"); + addSession(session); + markExited(session, 0, null, "completed"); expect(listFinishedSessions()).toHaveLength(0); - markBackgrounded(session as any); - markExited(session as any, 0, null, "completed"); + markBackgrounded(session); + markExited(session, 0, null, "completed"); expect(listFinishedSessions()).toHaveLength(1); }); }); diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index 35ac3dd90..44bf39dcf 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -79,12 +79,17 @@ export function appendOutput( ) { session.pendingStdout ??= []; session.pendingStderr ??= []; - const buffer = stream === "stdout" ? 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); + const aggregated = trimWithCap( + session.aggregated + chunk, + session.maxOutputChars, + ); session.truncated = - session.truncated || aggregated.length < session.aggregated.length + chunk.length; + session.truncated || + aggregated.length < session.aggregated.length + chunk.length; session.aggregated = aggregated; session.tail = tail(session.aggregated, 2000); } @@ -175,6 +180,9 @@ 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, JOB_TTL_MS / 6), + ); sweeper.unref?.(); } diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 775d0ecf9..ef4f61bbe 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { bashTool, 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)); @@ -49,7 +49,9 @@ describe("bash tool backgrounding", () => { 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; + 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 index 2679e678a..fa8dd7688 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, @@ -16,7 +16,11 @@ import { markBackgrounded, markExited, } 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( @@ -79,7 +83,7 @@ export const bashTool: AgentTool = { description: "Execute bash with background continuation. Use yieldMs/background to continue later via process tool.", parameters: bashSchema, - execute: async (toolCallId, args, signal, onUpdate) => { + execute: async (_toolCallId, args, signal, onUpdate) => { const params = args as { command: string; workdir?: string; @@ -106,12 +110,13 @@ export const bashTool: AgentTool = { 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, ...(params.env ?? {}) }, + env: { ...process.env, ...env }, detached: true, stdio: ["pipe", "pipe", "pipe"], }, @@ -243,8 +248,11 @@ export const bashTool: AgentTool = { 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"; + 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; @@ -352,7 +360,10 @@ export const processTool: AgentTool = { ); return { content: [ - { type: "text", text: lines.join("\n") || "No running or recent sessions." }, + { + type: "text", + text: lines.join("\n") || "No running or recent sessions.", + }, ], details: { status: "completed", sessions: [...running, ...finished] }, }; @@ -360,7 +371,9 @@ export const processTool: AgentTool = { if (!params.sessionId) { return { - content: [{ type: "text", text: "sessionId is required for this action." }], + content: [ + { type: "text", text: "sessionId is required for this action." }, + ], details: { status: "failed" }, }; } @@ -389,7 +402,8 @@ export const processTool: AgentTool = { }, ], details: { - status: finished.status === "completed" ? "completed" : "failed", + status: + finished.status === "completed" ? "completed" : "failed", sessionId: params.sessionId, exitCode: finished.exitCode ?? undefined, aggregated: finished.aggregated, @@ -398,7 +412,10 @@ export const processTool: AgentTool = { } return { content: [ - { type: "text", text: `No session found for ${params.sessionId}` }, + { + type: "text", + text: `No session found for ${params.sessionId}`, + }, ], details: { status: "failed" }, }; @@ -421,7 +438,12 @@ export const processTool: AgentTool = { if (exited) { const status = exitCode === 0 && exitSignal == null ? "completed" : "failed"; - markExited(session, session.exitCode ?? null, session.exitSignal ?? null, status); + markExited( + session, + session.exitCode ?? null, + session.exitSignal ?? null, + status, + ); } const status = exited ? exitCode === 0 && exitSignal == null @@ -470,9 +492,7 @@ export const processTool: AgentTool = { const total = session.aggregated.length; const slice = session.aggregated.slice( params.offset ?? 0, - params.limit - ? (params.offset ?? 0) + params.limit - : undefined, + params.limit ? (params.offset ?? 0) + params.limit : undefined, ); return { content: [{ type: "text", text: slice || "(no output yet)" }], @@ -488,15 +508,12 @@ export const processTool: AgentTool = { const total = finished.aggregated.length; const slice = finished.aggregated.slice( params.offset ?? 0, - params.limit - ? (params.offset ?? 0) + params.limit - : undefined, + params.limit ? (params.offset ?? 0) + params.limit : undefined, ); - 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, @@ -519,7 +536,10 @@ 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" }, }; @@ -572,7 +592,10 @@ 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" }, }; diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index 488c65a53..6302b51d3 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -11,9 +11,20 @@ export function getShellConfig(): { shell: string; args: string[] } { } export function sanitizeBinaryOutput(text: string): string { - return text - .replace(/[\p{Format}\p{Surrogate}]/gu, "") - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); + const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, ""); + if (!scrubbed) return scrubbed; + const chunks: string[] = []; + for (const char of scrubbed) { + const code = char.codePointAt(0); + if (code == null) continue; + if (code === 0x09 || code === 0x0a || code === 0x0d) { + chunks.push(char); + continue; + } + if (code < 0x20) continue; + chunks.push(char); + } + return chunks.join(""); } export function killProcessTree(pid: number): void {