Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/adopt-external-children.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aliou/pi-processes": minor
---

Add an adopt API so other extensions can hand an already-running child process over to the manager. `ProcessManager.adopt(name, command, cwd, child, { initialStdout, initialStderr, startTime })` registers an externally spawned child (detached process group, piped stdio) as a managed process, and the new `processes:command:adopt` event-bus channel exposes it cross-extension. Adopted processes get the full managed lifecycle: log capture (including output produced before the handover), liveness watching, kill/stop, notifications, and dock/`/ps` visibility. This enables tools like a bash override that moves a long-running foreground command into the background without losing output.
279 changes: 279 additions & 0 deletions examples/bash-background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
/**
* Example Bash tool override for Pi.
*
* Copy this file to ~/.pi/agent/extensions/bash-background.ts. It requires a
* pi-processes version that provides the `processes:command:adopt` channel.
*
* - Press ctrl+shift+b to move a running Bash command to pi-processes.
* - A tool timeout moves the command instead of stopping it.
* - A command with no timeout moves after AUTO_BACKGROUND_MS.
*/
import { type ChildProcess, spawn } from "node:child_process";
import { existsSync } from "node:fs";
import {
type ExtensionAPI,
getShellConfig,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

const ADOPT_CHANNEL = "processes:command:adopt";
const AUTO_BACKGROUND_MS = 120_000;
const MAX_CAPTURED_BYTES = 8 * 1024 * 1024;
const RESULT_TAIL_CHARS = 4_000;

const parameters = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(
Type.Number({
description:
"Time limit in seconds. At the limit, the command moves to the background.",
}),
),
});

type BackgroundReason = "user" | "timeout" | "auto";

interface AdoptResult {
ok: boolean;
info?: { id: string; name: string; pid: number };
error?: string;
}

interface RunningCommand {
background: (reason: BackgroundReason) => void;
}

class OutputBuffer {
private chunks: Buffer[] = [];
private byteLength = 0;

append(data: Buffer): void {
this.chunks.push(data);
this.byteLength += data.length;
while (this.byteLength > MAX_CAPTURED_BYTES && this.chunks.length > 1) {
const dropped = this.chunks.shift();
if (dropped) this.byteLength -= dropped.length;
}
}

bytes(): Buffer {
return Buffer.concat(this.chunks);
}

tail(): string {
const text = this.bytes().toString("utf8").trim();
if (text.length <= RESULT_TAIL_CHARS) return text;
return `[...output truncated...]\n${text.slice(-RESULT_TAIL_CHARS)}`;
}
}

function stopProcessGroup(pid: number): void {
try {
process.kill(-pid, "SIGKILL");
} catch {
try {
process.kill(pid, "SIGKILL");
} catch (_error) {
void _error; // The process already ended.
}
}
}

function processName(command: string): string {
const executable = command.trim().split(/\s+/)[0]?.split("/").pop() ?? "cmd";
return `bg-${executable.slice(0, 20)}-${Date.now().toString(36).slice(-4)}`;
}

function elapsedSeconds(startedAt: number): number {
return Math.round((Date.now() - startedAt) / 1000);
}

export default function bashBackground(pi: ExtensionAPI) {
const running = new Set<RunningCommand>();

pi.registerShortcut("ctrl+shift+b", {
description: "Move the running Bash command to the background",
handler: async (ctx) => {
if (running.size === 0) {
ctx.ui.notify("No Bash command is running.", "info");
return;
}
for (const command of running) command.background("user");
},
});

pi.registerTool({
name: "bash",
label: "bash",
description:
"Execute a Bash command. A long command can move to pi-processes when the user presses ctrl+shift+b, when its timeout expires, or after two minutes without a timeout.",
parameters,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const { command, timeout } = params;
const cwd = ctx.cwd;
if (!existsSync(cwd)) {
throw new Error(`Working directory does not exist: ${cwd}`);
}
if (signal?.aborted) throw new Error("Command aborted");

// Resolve shell like pi's native bash tool (handles NixOS etc.).
const shellConfig = getShellConfig();
const child = spawn(shellConfig.shell, [...shellConfig.args, command], {
cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
detached: true,
});
const stdoutBuf = new OutputBuffer();
const stderrBuf = new OutputBuffer();
const startedAt = Date.now();

return await new Promise((resolve, reject) => {
let settled = false;
let backgroundTimer: NodeJS.Timeout | undefined;

const onStdout = (data: Buffer) => {
stdoutBuf.append(data);
onUpdate?.({
content: [{ type: "text", text: stdoutBuf.tail() }],
details: {},
});
};

const onStderr = (data: Buffer) => {
stderrBuf.append(data);
onUpdate?.({
content: [{ type: "text", text: stderrBuf.tail() }],
details: {},
});
};

const cleanup = () => {
settled = true;
running.delete(active);
if (backgroundTimer) clearTimeout(backgroundTimer);
signal?.removeEventListener("abort", onAbort);
child.stdout?.off("data", onStdout);
child.stderr?.off("data", onStderr);
child.off("close", onClose);
child.off("error", onError);
};

const combinedTail = () => {
const out = stdoutBuf.tail();
const err = stderrBuf.tail();
if (out && err) return `${out}\n${err}`;
return out || err;
};

const fail = (message: string) => {
const tail = combinedTail();
reject(new Error(`${tail ? `${tail}\n\n` : ""}${message}`));
};

const onAbort = () => {
if (settled) return;
if (child.pid) stopProcessGroup(child.pid);
cleanup();
fail("Command aborted");
};

const onClose = (
code: number | null,
signalCode: NodeJS.Signals | null,
) => {
if (settled) return;
cleanup();
if (signalCode) {
fail(`Command terminated by ${signalCode}`);
} else if (code !== 0 && code !== null) {
fail(`Command exited with code ${code}`);
} else {
resolve({
content: [
{ type: "text", text: combinedTail() || "(no output)" },
],
details: {},
});
}
};

const onError = (error: Error) => {
if (settled) return;
cleanup();
reject(error);
};

const background = (reason: BackgroundReason) => {
if (settled || !child.pid) return;

// Stop reading first. The event bus and reply are synchronous, so
// pi-processes installs its listeners before this call returns.
child.stdout?.off("data", onStdout);
child.stderr?.off("data", onStderr);

let result: AdoptResult | undefined;
pi.events.emit(ADOPT_CHANNEL, {
name: processName(command),
command,
cwd,
child: child as ChildProcess,
initialStdout: stdoutBuf.bytes(),
initialStderr: stderrBuf.bytes(),
startTime: startedAt,
reply: (reply: AdoptResult) => {
result = reply;
},
});

if (!result?.ok || !result.info) {
child.stdout?.on("data", onStdout);
child.stderr?.on("data", onStderr);
if (reason !== "timeout") return;

stopProcessGroup(child.pid);
cleanup();
fail(
`Command timed out and background handover failed: ${result?.error ?? "no adopt listener"}`,
);
return;
}

cleanup();
const { id, name, pid } = result.info;
const tail = combinedTail();
const note =
`Command moved to background after ${elapsedSeconds(startedAt)}s (${reason}). ` +
`It is still running as process ${id} ("${name}", pid ${pid}). ` +
`Use the process tool to read output or stop it.`;
resolve({
content: [
{ type: "text", text: `${tail ? `${tail}\n\n` : ""}${note}` },
],
details: { backgrounded: true, processId: id, reason },
});
};

const active: RunningCommand = { background };
running.add(active);
child.stdout?.on("data", onStdout);
child.stderr?.on("data", onStderr);
child.on("close", onClose);
child.on("error", onError);
signal?.addEventListener("abort", onAbort, { once: true });

if (timeout !== undefined && Number.isFinite(timeout) && timeout > 0) {
backgroundTimer = setTimeout(
() => background("timeout"),
timeout * 1000,
);
} else {
backgroundTimer = setTimeout(
() => background("auto"),
AUTO_BACKGROUND_MS,
);
}
});
},
});
}
38 changes: 38 additions & 0 deletions extensions/processes/handlers/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ChildProcess } from "node:child_process";
import { createEventBus } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";

Expand Down Expand Up @@ -101,6 +102,43 @@ describe("registerCommandHandlers", () => {
expect(reply).toHaveBeenCalledWith(2);
});

it("adopts external children with default notifications", () => {
const events = createEventBus();
const registry = createNotificationRegistry();
const child = { pid: 123 } as ChildProcess;
const info = makeInfo({ status: "running", endTime: null, success: null });
const manager = {
adopt: vi.fn(() => info),
} as unknown as ProcessManager;
const reply = vi.fn();

registerCommandHandlers(events, manager, registry);
events.emit(CHANNELS.COMMAND_ADOPT, {
name: "grep",
command: "grep -R needle .",
cwd: "/repo",
child,
initialStdout: Buffer.from("partial output\n"),
initialStderr: Buffer.from("early stderr\n"),
startTime: 1000,
reply,
});

expect(manager.adopt).toHaveBeenCalledWith(
"grep",
"grep -R needle .",
"/repo",
child,
{
initialStdout: Buffer.from("partial output\n"),
initialStderr: Buffer.from("early stderr\n"),
startTime: 1000,
},
);
expect(registry.get(info.id)).toEqual({});
expect(reply).toHaveBeenCalledWith({ ok: true, info });
});

it("swallows requester reply errors", async () => {
const events = createEventBus();
const registry = createNotificationRegistry();
Expand Down
26 changes: 25 additions & 1 deletion extensions/processes/handlers/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ProcessManager } from "../../../src/manager";
import type { KillResult } from "../../../src/types";
import {
CHANNELS,
type CommandAdoptPayload,
type CommandClearPayload,
type CommandKillPayload,
type CommandStartPayload,
Expand Down Expand Up @@ -51,13 +52,36 @@ export function registerCommandHandlers(

safeReply(command.reply, manager.clearFinished());
}),
events.on(CHANNELS.COMMAND_ADOPT, (payload) => {
const command = payload as CommandAdoptPayload;

try {
const info = manager.adopt(
command.name,
command.command,
command.cwd,
command.child,
{
initialStdout: command.initialStdout,
initialStderr: command.initialStderr,
startTime: command.startTime,
},
);
notifications.register(info.id, {});
safeReply(command.reply, { ok: true, info });
} catch (error) {
safeReply(command.reply, {
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}),
];

return () => {
for (const dispose of disposers) dispose();
};
}

function safeReply<T>(reply: (result: T) => void, result: T): void {
try {
reply(result);
Expand Down
3 changes: 3 additions & 0 deletions extensions/shared/protocol/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const CHANNELS = {
COMMAND_START: "processes:command:start",
COMMAND_KILL: "processes:command:kill",
COMMAND_CLEAR: "processes:command:clear",
// Other extensions emit this to hand an already-running child process
// over to the manager (e.g. backgrounding a foreground tool command).
COMMAND_ADOPT: "processes:command:adopt",
// Pin handled by the dock extension, if loaded.
COMMAND_PIN: "processes:command:pin",

Expand Down
Loading