src / tools / process.ts
src / tools / process.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "child_process";
import { z } from "zod";
import { clamp, type Workspace } from "../workspace";
/** Hard ceilings so a model cannot wedge or drown the plugin process. */
const MAX_CONCURRENT = 5;
const MAX_RETAINED = 20;
const MAX_LINES = 500;
const MAX_BUFFER_BYTES = 128 * 1024;
const MAX_LINE_CHARS = 500;
const MAX_OUTPUT_CHARS = 8000;
const KILL_GRACE_MS = 2000;
const DEAD_TTL_MS = 30 * 60 * 1000;
interface OutputLine {
seq: number;
stream: "out" | "err";
text: string;
}
interface ProcessHandle {
label: string;
command: string;
pid: number | undefined;
child: ChildProcessWithoutNullStreams;
startedAtMs: number;
alive: boolean;
exitCode: number | null;
exitSignal: string | null;
exitedAtMs: number;
spawnError: string;
lines: OutputLine[];
bufferedBytes: number;
nextSeq: number;
dropped: number;
/** Highest sequence number already handed to the model, for since_last reads. */
lastReadSeq: number;
partial: { out: string; err: string };
}
/**
* Background children outlive a single tool call, so the registry lives at
* module scope -- it survives for as long as the plugin process does.
*/
const processes = new Map<string, ProcessHandle>();
function aliveCount(): number {
let count = 0;
for (const handle of processes.values()) {
if (handle.alive) count++;
}
return count;
}
/**
* Dead entries stay readable for a while after exit, then get dropped. Called at
* the top of every tool so no timer is needed to keep the map from growing.
*/
function reap(): void {
const now = Date.now();
for (const handle of [...processes.values()]) {
if (!handle.alive && now - handle.exitedAtMs > DEAD_TTL_MS) {
processes.delete(handle.label);
}
}
if (processes.size <= MAX_RETAINED) return;
const dead = [...processes.values()]
.filter((handle) => !handle.alive)
.sort((a, b) => a.exitedAtMs - b.exitedAtMs);
while (processes.size > MAX_RETAINED && dead.length > 0) {
processes.delete(dead.shift()!.label);
}
}
function pushLine(handle: ProcessHandle, stream: "out" | "err", raw: string): void {
const text = raw.replace(/\r$/, "").slice(0, MAX_LINE_CHARS);
handle.lines.push({ seq: handle.nextSeq++, stream, text });
handle.bufferedBytes += text.length;
while (
handle.lines.length > MAX_LINES ||
(handle.bufferedBytes > MAX_BUFFER_BYTES && handle.lines.length > 1)
) {
const evicted = handle.lines.shift()!;
handle.bufferedBytes -= evicted.text.length;
handle.dropped++;
}
}
function ingest(handle: ProcessHandle, stream: "out" | "err", chunk: string): void {
const combined = handle.partial[stream] + chunk;
const parts = combined.split("\n");
handle.partial[stream] = parts.pop() ?? "";
for (const part of parts) pushLine(handle, stream, part);
// A prompt with no trailing newline would otherwise never be shown.
if (handle.partial[stream].length > MAX_LINE_CHARS) {
pushLine(handle, stream, handle.partial[stream]);
handle.partial[stream] = "";
}
}
function flushPartials(handle: ProcessHandle): void {
for (const stream of ["out", "err"] as const) {
if (handle.partial[stream] !== "") {
pushLine(handle, stream, handle.partial[stream]);
handle.partial[stream] = "";
}
}
}
function waitForExit(handle: ProcessHandle, ms: number): Promise<boolean> {
if (!handle.alive) return Promise.resolve(true);
return new Promise((resolve) => {
const timer = setTimeout(() => {
handle.child.removeListener("exit", onExit);
resolve(!handle.alive);
}, ms);
const onExit = (): void => {
clearTimeout(timer);
resolve(true);
};
handle.child.once("exit", onExit);
});
}
function taskkillTree(pid: number): Promise<string> {
return new Promise((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
windowsHide: true,
stdio: "ignore",
});
killer.on("error", (err) => resolve(`taskkill could not run (${err.message})`));
killer.on("close", (code) =>
resolve(code === 0 ? "killed the process tree with taskkill /t /f" : `taskkill exited ${code}`),
);
});
}
/**
* Measured on Windows: with shell:true the direct child is cmd.exe, and killing
* only that leaves the real server running and still writing to our pipes. So
* the tree goes first, while cmd.exe is still its parent, and child.kill() is
* the backstop rather than the opening move.
*/
async function terminate(handle: ProcessHandle): Promise<string> {
const pid = handle.pid;
const steps: string[] = [];
if (process.platform === "win32" && pid !== undefined) {
steps.push(await taskkillTree(pid));
if (await waitForExit(handle, KILL_GRACE_MS)) return steps.join(", then ");
}
handle.child.kill();
steps.push("sent the default kill signal");
if (await waitForExit(handle, KILL_GRACE_MS)) return steps.join(", then ");
if (pid !== undefined && process.platform !== "win32") {
try {
process.kill(pid, "SIGKILL");
steps.push("sent SIGKILL");
} catch (caught) {
steps.push(`SIGKILL failed (${(caught as Error).message})`);
}
await waitForExit(handle, KILL_GRACE_MS);
}
return steps.join(", then ");
}
// A dev server must not outlive the plugin that started it. Only sync work is
// possible during 'exit', hence spawnSync.
process.once("exit", () => {
for (const handle of processes.values()) {
if (!handle.alive || handle.pid === undefined) continue;
try {
if (process.platform === "win32") {
spawnSync("taskkill", ["/pid", String(handle.pid), "/t", "/f"], {
windowsHide: true,
stdio: "ignore",
});
} else {
process.kill(handle.pid, "SIGKILL");
}
} catch {
// Nothing useful to do while the process is already tearing down.
}
}
});
function statusOf(handle: ProcessHandle): string {
if (handle.spawnError !== "") return `failed to start (${handle.spawnError})`;
if (handle.alive) {
const secs = Math.round((Date.now() - handle.startedAtMs) / 1000);
return `running (pid ${handle.pid ?? "?"}, up ${secs}s)`;
}
if (handle.exitSignal !== null) return `exited (killed by ${handle.exitSignal})`;
return `exited (code ${handle.exitCode ?? "unknown"})`;
}
export function processTools(ws: Workspace): Tool[] {
if (!ws.allowShell) return [];
const tools: Tool[] = [];
tools.push(
tool({
name: "start_process",
description:
"Start a long-running command in the background and return immediately. Use this for " +
"dev servers, file watchers, and anything that does not exit on its own -- run_command " +
"kills those at the timeout. The process keeps running between messages. Read its output " +
"later with read_process_output and shut it down with stop_process.",
parameters: {
label: z
.string()
.describe("Short unique name for this process, used to read or stop it later."),
command: z.string().describe("The command line to run, e.g. 'npm run dev'."),
},
implementation: async ({ label, command }, ctx) => {
reap();
const existing = processes.get(label);
if (existing !== undefined && existing.alive) {
// Tolerate duplicate tool calls: same label, same command is a no-op.
if (existing.command === command) {
return `Process "${label}" is already running (pid ${existing.pid ?? "?"}). Nothing started.`;
}
return `Error: a process named "${label}" is already running a different command (${existing.command}). Stop it first or pick another label.`;
}
if (aliveCount() >= MAX_CONCURRENT) {
return `Error: ${MAX_CONCURRENT} background processes are already running. Use list_processes and stop_process before starting another.`;
}
ctx.status(`Starting "${label}"`);
ctx.warn(`Background process in ${ws.root}: ${command}`);
let child: ChildProcessWithoutNullStreams;
try {
child = spawn(command, {
shell: true,
cwd: ws.root,
windowsHide: true,
});
} catch (caught) {
return `Error: could not start "${command}": ${(caught as Error).message}. Check the command spelling and try run_command for a quick one-shot test.`;
}
const handle: ProcessHandle = {
label,
command,
pid: child.pid,
child,
startedAtMs: Date.now(),
alive: true,
exitCode: null,
exitSignal: null,
exitedAtMs: 0,
spawnError: "",
lines: [],
bufferedBytes: 0,
nextSeq: 1,
dropped: 0,
lastReadSeq: 0,
partial: { out: "", err: "" },
};
child.stdout.setEncoding("utf-8");
child.stderr.setEncoding("utf-8");
child.stdout.on("data", (chunk: string) => ingest(handle, "out", chunk));
child.stderr.on("data", (chunk: string) => ingest(handle, "err", chunk));
// Broken pipes on a dying child must not take the plugin down.
child.stdin.on("error", () => undefined);
child.on("error", (err) => {
handle.spawnError = err.message;
handle.alive = false;
handle.exitedAtMs = Date.now();
pushLine(handle, "err", `[spawn error] ${err.message}`);
});
child.on("exit", (code, signal) => {
handle.alive = false;
handle.exitCode = code;
handle.exitSignal = signal;
handle.exitedAtMs = Date.now();
flushPartials(handle);
pushLine(
handle,
"err",
`[process exited] ${signal !== null ? `killed by ${signal}` : `code ${code ?? "unknown"}`}`,
);
});
child.on("close", () => flushPartials(handle));
processes.set(label, handle);
return (
`Started "${label}" (pid ${child.pid ?? "unknown"}): ${command}\n` +
`Working directory: ${ws.root}\n` +
`It is running in the background. Call read_process_output("${label}") in a moment to see what it printed.`
);
},
}),
);
tools.push(
tool({
name: "read_process_output",
description:
"Read recent output from a background process started with start_process, and check " +
"whether it is still running or has exited. By default it returns only what is new since " +
"your last read, so you can poll it repeatedly while waiting for a server to come up or a " +
"build to finish. Lines are tagged [out] or [err].",
parameters: {
label: z.string().describe("The process label given to start_process."),
lines: z
.number()
.int()
.min(1)
.max(200)
.default(50)
.describe("Maximum number of output lines to return."),
since_last: z
.boolean()
.default(true)
.describe(
"True returns only output produced since your previous read. False re-reads the whole buffer.",
),
},
implementation: async ({ label, lines, since_last }, ctx) => {
reap();
const handle = processes.get(label);
if (handle === undefined) {
return `Error: no process named "${label}". Use list_processes to see what exists, or start_process to start one.`;
}
ctx.status(`Reading "${label}"`);
const header = [`Process "${label}" -- ${statusOf(handle)}`, `Command: ${handle.command}`];
let selected = since_last
? handle.lines.filter((line) => line.seq > handle.lastReadSeq)
: handle.lines;
const notes: string[] = [];
if (handle.dropped > 0 && !since_last) {
notes.push(`${handle.dropped} older line(s) were dropped from the buffer.`);
}
if (selected.length > lines) {
notes.push(
`Only the newest ${lines} of ${selected.length} available line(s) are shown; raise 'lines' or read again.`,
);
selected = selected.slice(-lines);
}
handle.lastReadSeq = handle.nextSeq - 1;
if (selected.length === 0) {
const why = since_last
? "No new output since your last read."
: "No output has been produced yet.";
return [...header, "", why].join("\n");
}
const body = selected
.map((line) => `[${line.stream === "err" ? "err" : "out"}] ${line.text}`)
.join("\n");
const text = [...header, ...(notes.length > 0 ? ["", notes.join(" ")] : []), "", body].join(
"\n",
);
return clamp(text, MAX_OUTPUT_CHARS, `output of "${label}"`);
},
}),
);
tools.push(
tool({
name: "write_process_input",
description:
"Send a line of text to the standard input of a running background process. Use this to " +
"answer a prompt it is waiting on or to drive a REPL. A newline is added for you.",
parameters: {
label: z.string().describe("The process label given to start_process."),
text: z.string().describe("The line to send. Do not include a trailing newline."),
},
implementation: async ({ label, text }, ctx) => {
reap();
const handle = processes.get(label);
if (handle === undefined) {
return `Error: no process named "${label}". Use list_processes to see what is running.`;
}
if (!handle.alive) {
return `Error: process "${label}" has already ${statusOf(handle)}, so it cannot read input. Start it again with start_process.`;
}
if (!handle.child.stdin.writable) {
return `Error: the standard input of "${label}" is closed. It may not accept input at all.`;
}
ctx.status(`Writing to "${label}"`);
try {
handle.child.stdin.write(`${text}\n`);
} catch (caught) {
return `Error: could not write to "${label}": ${(caught as Error).message}. Check it is still running with list_processes.`;
}
return `Sent to "${label}": ${text.slice(0, 200)}\nRead the reply with read_process_output("${label}").`;
},
}),
);
tools.push(
tool({
name: "stop_process",
description:
"Stop a background process started with start_process. Its output stays readable " +
"afterwards. Always stop a dev server when you are finished with it.",
parameters: {
label: z.string().describe("The process label given to start_process."),
},
implementation: async ({ label }, ctx) => {
reap();
const handle = processes.get(label);
if (handle === undefined) {
return `Error: no process named "${label}". Use list_processes to see what is running.`;
}
if (!handle.alive) {
return `Process "${label}" was already ${statusOf(handle)}. Nothing to stop.`;
}
ctx.status(`Stopping "${label}"`);
const outcome = await terminate(handle);
if (handle.alive) {
return (
`Warning: "${label}" (pid ${handle.pid ?? "unknown"}) is still alive after I ${outcome}. ` +
`Stop it from a terminal or Task Manager.`
);
}
return `Stopped "${label}": ${outcome}. Its output is still readable with read_process_output("${label}").`;
},
}),
);
tools.push(
tool({
name: "list_processes",
description:
"List every background process started in this session, whether it is still running, " +
"and its exit code if it finished. Call this when you are not sure what is already up.",
parameters: {},
implementation: async (_params, ctx) => {
reap();
ctx.status("Listing processes");
if (processes.size === 0) {
return "No background processes have been started. Use start_process for a dev server or watcher.";
}
const rows: string[] = [];
for (const handle of processes.values()) {
const pending = handle.nextSeq - 1 - handle.lastReadSeq;
rows.push(
`${handle.label}: ${statusOf(handle)}, ${pending} unread line(s) -- ${handle.command}`,
);
}
return clamp(
`${aliveCount()} of ${processes.size} still running (cap ${MAX_CONCURRENT}):\n${rows.join("\n")}`,
MAX_OUTPUT_CHARS,
"process list",
);
},
}),
);
return tools;
}
import { tool, type Tool } from "@lmstudio/sdk";
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "child_process";
import { z } from "zod";
import { clamp, type Workspace } from "../workspace";
/** Hard ceilings so a model cannot wedge or drown the plugin process. */
const MAX_CONCURRENT = 5;
const MAX_RETAINED = 20;
const MAX_LINES = 500;
const MAX_BUFFER_BYTES = 128 * 1024;
const MAX_LINE_CHARS = 500;
const MAX_OUTPUT_CHARS = 8000;
const KILL_GRACE_MS = 2000;
const DEAD_TTL_MS = 30 * 60 * 1000;
interface OutputLine {
seq: number;
stream: "out" | "err";
text: string;
}
interface ProcessHandle {
label: string;
command: string;
pid: number | undefined;
child: ChildProcessWithoutNullStreams;
startedAtMs: number;
alive: boolean;
exitCode: number | null;
exitSignal: string | null;
exitedAtMs: number;
spawnError: string;
lines: OutputLine[];
bufferedBytes: number;
nextSeq: number;
dropped: number;
/** Highest sequence number already handed to the model, for since_last reads. */
lastReadSeq: number;
partial: { out: string; err: string };
}
/**
* Background children outlive a single tool call, so the registry lives at
* module scope -- it survives for as long as the plugin process does.
*/
const processes = new Map<string, ProcessHandle>();
function aliveCount(): number {
let count = 0;
for (const handle of processes.values()) {
if (handle.alive) count++;
}
return count;
}
/**
* Dead entries stay readable for a while after exit, then get dropped. Called at
* the top of every tool so no timer is needed to keep the map from growing.
*/
function reap(): void {
const now = Date.now();
for (const handle of [...processes.values()]) {
if (!handle.alive && now - handle.exitedAtMs > DEAD_TTL_MS) {
processes.delete(handle.label);
}
}
if (processes.size <= MAX_RETAINED) return;
const dead = [...processes.values()]
.filter((handle) => !handle.alive)
.sort((a, b) => a.exitedAtMs - b.exitedAtMs);
while (processes.size > MAX_RETAINED && dead.length > 0) {
processes.delete(dead.shift()!.label);
}
}
function pushLine(handle: ProcessHandle, stream: "out" | "err", raw: string): void {
const text = raw.replace(/\r$/, "").slice(0, MAX_LINE_CHARS);
handle.lines.push({ seq: handle.nextSeq++, stream, text });
handle.bufferedBytes += text.length;
while (
handle.lines.length > MAX_LINES ||
(handle.bufferedBytes > MAX_BUFFER_BYTES && handle.lines.length > 1)
) {
const evicted = handle.lines.shift()!;
handle.bufferedBytes -= evicted.text.length;
handle.dropped++;
}
}
function ingest(handle: ProcessHandle, stream: "out" | "err", chunk: string): void {
const combined = handle.partial[stream] + chunk;
const parts = combined.split("\n");
handle.partial[stream] = parts.pop() ?? "";
for (const part of parts) pushLine(handle, stream, part);
// A prompt with no trailing newline would otherwise never be shown.
if (handle.partial[stream].length > MAX_LINE_CHARS) {
pushLine(handle, stream, handle.partial[stream]);
handle.partial[stream] = "";
}
}
function flushPartials(handle: ProcessHandle): void {
for (const stream of ["out", "err"] as const) {
if (handle.partial[stream] !== "") {
pushLine(handle, stream, handle.partial[stream]);
handle.partial[stream] = "";
}
}
}
function waitForExit(handle: ProcessHandle, ms: number): Promise<boolean> {
if (!handle.alive) return Promise.resolve(true);
return new Promise((resolve) => {
const timer = setTimeout(() => {
handle.child.removeListener("exit", onExit);
resolve(!handle.alive);
}, ms);
const onExit = (): void => {
clearTimeout(timer);
resolve(true);
};
handle.child.once("exit", onExit);
});
}
function taskkillTree(pid: number): Promise<string> {
return new Promise((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
windowsHide: true,
stdio: "ignore",
});
killer.on("error", (err) => resolve(`taskkill could not run (${err.message})`));
killer.on("close", (code) =>
resolve(code === 0 ? "killed the process tree with taskkill /t /f" : `taskkill exited ${code}`),
);
});
}
/**
* Measured on Windows: with shell:true the direct child is cmd.exe, and killing
* only that leaves the real server running and still writing to our pipes. So
* the tree goes first, while cmd.exe is still its parent, and child.kill() is
* the backstop rather than the opening move.
*/
async function terminate(handle: ProcessHandle): Promise<string> {
const pid = handle.pid;
const steps: string[] = [];
if (process.platform === "win32" && pid !== undefined) {
steps.push(await taskkillTree(pid));
if (await waitForExit(handle, KILL_GRACE_MS)) return steps.join(", then ");
}
handle.child.kill();
steps.push("sent the default kill signal");
if (await waitForExit(handle, KILL_GRACE_MS)) return steps.join(", then ");
if (pid !== undefined && process.platform !== "win32") {
try {
process.kill(pid, "SIGKILL");
steps.push("sent SIGKILL");
} catch (caught) {
steps.push(`SIGKILL failed (${(caught as Error).message})`);
}
await waitForExit(handle, KILL_GRACE_MS);
}
return steps.join(", then ");
}
// A dev server must not outlive the plugin that started it. Only sync work is
// possible during 'exit', hence spawnSync.
process.once("exit", () => {
for (const handle of processes.values()) {
if (!handle.alive || handle.pid === undefined) continue;
try {
if (process.platform === "win32") {
spawnSync("taskkill", ["/pid", String(handle.pid), "/t", "/f"], {
windowsHide: true,
stdio: "ignore",
});
} else {
process.kill(handle.pid, "SIGKILL");
}
} catch {
// Nothing useful to do while the process is already tearing down.
}
}
});
function statusOf(handle: ProcessHandle): string {
if (handle.spawnError !== "") return `failed to start (${handle.spawnError})`;
if (handle.alive) {
const secs = Math.round((Date.now() - handle.startedAtMs) / 1000);
return `running (pid ${handle.pid ?? "?"}, up ${secs}s)`;
}
if (handle.exitSignal !== null) return `exited (killed by ${handle.exitSignal})`;
return `exited (code ${handle.exitCode ?? "unknown"})`;
}
export function processTools(ws: Workspace): Tool[] {
if (!ws.allowShell) return [];
const tools: Tool[] = [];
tools.push(
tool({
name: "start_process",
description:
"Start a long-running command in the background and return immediately. Use this for " +
"dev servers, file watchers, and anything that does not exit on its own -- run_command " +
"kills those at the timeout. The process keeps running between messages. Read its output " +
"later with read_process_output and shut it down with stop_process.",
parameters: {
label: z
.string()
.describe("Short unique name for this process, used to read or stop it later."),
command: z.string().describe("The command line to run, e.g. 'npm run dev'."),
},
implementation: async ({ label, command }, ctx) => {
reap();
const existing = processes.get(label);
if (existing !== undefined && existing.alive) {
// Tolerate duplicate tool calls: same label, same command is a no-op.
if (existing.command === command) {
return `Process "${label}" is already running (pid ${existing.pid ?? "?"}). Nothing started.`;
}
return `Error: a process named "${label}" is already running a different command (${existing.command}). Stop it first or pick another label.`;
}
if (aliveCount() >= MAX_CONCURRENT) {
return `Error: ${MAX_CONCURRENT} background processes are already running. Use list_processes and stop_process before starting another.`;
}
ctx.status(`Starting "${label}"`);
ctx.warn(`Background process in ${ws.root}: ${command}`);
let child: ChildProcessWithoutNullStreams;
try {
child = spawn(command, {
shell: true,
cwd: ws.root,
windowsHide: true,
});
} catch (caught) {
return `Error: could not start "${command}": ${(caught as Error).message}. Check the command spelling and try run_command for a quick one-shot test.`;
}
const handle: ProcessHandle = {
label,
command,
pid: child.pid,
child,
startedAtMs: Date.now(),
alive: true,
exitCode: null,
exitSignal: null,
exitedAtMs: 0,
spawnError: "",
lines: [],
bufferedBytes: 0,
nextSeq: 1,
dropped: 0,
lastReadSeq: 0,
partial: { out: "", err: "" },
};
child.stdout.setEncoding("utf-8");
child.stderr.setEncoding("utf-8");
child.stdout.on("data", (chunk: string) => ingest(handle, "out", chunk));
child.stderr.on("data", (chunk: string) => ingest(handle, "err", chunk));
// Broken pipes on a dying child must not take the plugin down.
child.stdin.on("error", () => undefined);
child.on("error", (err) => {
handle.spawnError = err.message;
handle.alive = false;
handle.exitedAtMs = Date.now();
pushLine(handle, "err", `[spawn error] ${err.message}`);
});
child.on("exit", (code, signal) => {
handle.alive = false;
handle.exitCode = code;
handle.exitSignal = signal;
handle.exitedAtMs = Date.now();
flushPartials(handle);
pushLine(
handle,
"err",
`[process exited] ${signal !== null ? `killed by ${signal}` : `code ${code ?? "unknown"}`}`,
);
});
child.on("close", () => flushPartials(handle));
processes.set(label, handle);
return (
`Started "${label}" (pid ${child.pid ?? "unknown"}): ${command}\n` +
`Working directory: ${ws.root}\n` +
`It is running in the background. Call read_process_output("${label}") in a moment to see what it printed.`
);
},
}),
);
tools.push(
tool({
name: "read_process_output",
description:
"Read recent output from a background process started with start_process, and check " +
"whether it is still running or has exited. By default it returns only what is new since " +
"your last read, so you can poll it repeatedly while waiting for a server to come up or a " +
"build to finish. Lines are tagged [out] or [err].",
parameters: {
label: z.string().describe("The process label given to start_process."),
lines: z
.number()
.int()
.min(1)
.max(200)
.default(50)
.describe("Maximum number of output lines to return."),
since_last: z
.boolean()
.default(true)
.describe(
"True returns only output produced since your previous read. False re-reads the whole buffer.",
),
},
implementation: async ({ label, lines, since_last }, ctx) => {
reap();
const handle = processes.get(label);
if (handle === undefined) {
return `Error: no process named "${label}". Use list_processes to see what exists, or start_process to start one.`;
}
ctx.status(`Reading "${label}"`);
const header = [`Process "${label}" -- ${statusOf(handle)}`, `Command: ${handle.command}`];
let selected = since_last
? handle.lines.filter((line) => line.seq > handle.lastReadSeq)
: handle.lines;
const notes: string[] = [];
if (handle.dropped > 0 && !since_last) {
notes.push(`${handle.dropped} older line(s) were dropped from the buffer.`);
}
if (selected.length > lines) {
notes.push(
`Only the newest ${lines} of ${selected.length} available line(s) are shown; raise 'lines' or read again.`,
);
selected = selected.slice(-lines);
}
handle.lastReadSeq = handle.nextSeq - 1;
if (selected.length === 0) {
const why = since_last
? "No new output since your last read."
: "No output has been produced yet.";
return [...header, "", why].join("\n");
}
const body = selected
.map((line) => `[${line.stream === "err" ? "err" : "out"}] ${line.text}`)
.join("\n");
const text = [...header, ...(notes.length > 0 ? ["", notes.join(" ")] : []), "", body].join(
"\n",
);
return clamp(text, MAX_OUTPUT_CHARS, `output of "${label}"`);
},
}),
);
tools.push(
tool({
name: "write_process_input",
description:
"Send a line of text to the standard input of a running background process. Use this to " +
"answer a prompt it is waiting on or to drive a REPL. A newline is added for you.",
parameters: {
label: z.string().describe("The process label given to start_process."),
text: z.string().describe("The line to send. Do not include a trailing newline."),
},
implementation: async ({ label, text }, ctx) => {
reap();
const handle = processes.get(label);
if (handle === undefined) {
return `Error: no process named "${label}". Use list_processes to see what is running.`;
}
if (!handle.alive) {
return `Error: process "${label}" has already ${statusOf(handle)}, so it cannot read input. Start it again with start_process.`;
}
if (!handle.child.stdin.writable) {
return `Error: the standard input of "${label}" is closed. It may not accept input at all.`;
}
ctx.status(`Writing to "${label}"`);
try {
handle.child.stdin.write(`${text}\n`);
} catch (caught) {
return `Error: could not write to "${label}": ${(caught as Error).message}. Check it is still running with list_processes.`;
}
return `Sent to "${label}": ${text.slice(0, 200)}\nRead the reply with read_process_output("${label}").`;
},
}),
);
tools.push(
tool({
name: "stop_process",
description:
"Stop a background process started with start_process. Its output stays readable " +
"afterwards. Always stop a dev server when you are finished with it.",
parameters: {
label: z.string().describe("The process label given to start_process."),
},
implementation: async ({ label }, ctx) => {
reap();
const handle = processes.get(label);
if (handle === undefined) {
return `Error: no process named "${label}". Use list_processes to see what is running.`;
}
if (!handle.alive) {
return `Process "${label}" was already ${statusOf(handle)}. Nothing to stop.`;
}
ctx.status(`Stopping "${label}"`);
const outcome = await terminate(handle);
if (handle.alive) {
return (
`Warning: "${label}" (pid ${handle.pid ?? "unknown"}) is still alive after I ${outcome}. ` +
`Stop it from a terminal or Task Manager.`
);
}
return `Stopped "${label}": ${outcome}. Its output is still readable with read_process_output("${label}").`;
},
}),
);
tools.push(
tool({
name: "list_processes",
description:
"List every background process started in this session, whether it is still running, " +
"and its exit code if it finished. Call this when you are not sure what is already up.",
parameters: {},
implementation: async (_params, ctx) => {
reap();
ctx.status("Listing processes");
if (processes.size === 0) {
return "No background processes have been started. Use start_process for a dev server or watcher.";
}
const rows: string[] = [];
for (const handle of processes.values()) {
const pending = handle.nextSeq - 1 - handle.lastReadSeq;
rows.push(
`${handle.label}: ${statusOf(handle)}, ${pending} unread line(s) -- ${handle.command}`,
);
}
return clamp(
`${aliveCount()} of ${processes.size} still running (cap ${MAX_CONCURRENT}):\n${rows.join("\n")}`,
MAX_OUTPUT_CHARS,
"process list",
);
},
}),
);
return tools;
}