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
67 changes: 67 additions & 0 deletions extensions/processes-debug/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

import { requestStart } from "../processes/client";

/**
* Debug extension for pi-processes.
*
* This extension is NOT listed in `package.json` under `pi.extensions`.
* Load it explicitly via `pi -ne -e ~/pi-processes-debug/`.
*
* It provides "programmatic" control over process management by exposing
* slash commands that go through the core extension's protocol channels,
* so processes started here are fully visible to the agent's `process` tool.
* This lets you start a background process during debugging without needing
* to prompt the agent.
*/
export default async function processesDebugExtension(
pi: ExtensionAPI,
): Promise<void> {
const events = pi.events;

pi.registerCommand("debug:ps:start", {
description:
"Start a background process (debug). Usage: /debug:ps:start <name> <command>",
handler: async (args: string, ctx) => {
const parsed = parseStartArgs(args);
if (!parsed) {
ctx.ui.notify("Usage: /debug:ps:start <name> <command>", "warning");
return;
}

const result = await requestStart(events, {
name: parsed.name,
command: parsed.command,
cwd: ctx.cwd,
});

if (result.ok) {
ctx.ui.notify(
`Started ${result.process.name} (${result.process.id}) — pid ${result.process.pid}`,
"info",
);
} else {
ctx.ui.notify(`Failed to start process: ${result.error}`, "warning");
}
},
});
}

interface ParsedStartArgs {
name: string;
command: string;
}

function parseStartArgs(args: string): ParsedStartArgs | null {
const trimmed = args.trim();
if (!trimmed) return null;

const firstSpace = trimmed.indexOf(" ");
if (firstSpace === -1) return null;

const name = trimmed.slice(0, firstSpace);
const command = trimmed.slice(firstSpace + 1).trim();
if (!command) return null;

return { name, command };
}
22 changes: 22 additions & 0 deletions extensions/processes/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
type CommandKillPayload,
type CommandPinPayload,
type CommandPinResult,
type CommandStartPayload,
type CommandStartResult,
type ProcessProtocolConfig,
type RequestCombinedOutputPayload,
type RequestConfigPayload,
Expand All @@ -24,6 +26,26 @@ import {

export type ProcessLogLine = { type: "stdout" | "stderr"; text: string };

/**
* Start a managed process via the core extension's protocol channel. Resolves
* when the core handler replies. The started process is fully visible to the
* agent via the `process` tool (list, output, stop, etc.).
*/
export function requestStart(
events: EventBus,
options: { name: string; command: string; cwd?: string },
): Promise<CommandStartResult> {
return new Promise((resolve) => {
const payload: CommandStartPayload = {
name: options.name,
command: options.command,
cwd: options.cwd,
reply: (result) => resolve(result),
};
events.emit(CHANNELS.COMMAND_START, payload);
});
}

export function requestProcessList(events: EventBus): ProcessInfo[] {
let processes: ProcessInfo[] = [];
const payload: RequestListPayload = {
Expand Down
19 changes: 19 additions & 0 deletions extensions/processes/handlers/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CHANNELS,
type CommandClearPayload,
type CommandKillPayload,
type CommandStartPayload,
} from "../../shared/protocol";
import type { NotificationRegistry } from "../notifications/registry";
import { killIntentionally } from "./kill-process";
Expand All @@ -16,6 +17,24 @@ export function registerCommandHandlers(
notifications: NotificationRegistry,
): () => void {
const disposers = [
events.on(CHANNELS.COMMAND_START, (payload) => {
const command = payload as CommandStartPayload;

try {
const info = manager.start(
command.name,
command.command,
command.cwd ?? process.cwd(),
);
notifications.register(info.id, {});
safeReply(command.reply, { ok: true, process: info });
} catch (err) {
safeReply(command.reply, {
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
}),
events.on(CHANNELS.COMMAND_KILL, (payload) => {
const command = payload as CommandKillPayload;

Expand Down
1 change: 1 addition & 0 deletions extensions/shared/protocol/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const CHANNELS = {
REQUEST_CONFIG: "processes:request:config",

// Command channels (UI -> core, callback)
COMMAND_START: "processes:command:start",
COMMAND_KILL: "processes:command:kill",
COMMAND_CLEAR: "processes:command:clear",
// Pin handled by the dock extension, if loaded.
Expand Down
13 changes: 12 additions & 1 deletion extensions/shared/protocol/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import type { KillResult } from "../../../src/types";
import type { KillResult, ProcessInfo } from "../../../src/types";

// UI emits, core handles then calls reply.

export interface CommandStartPayload {
name: string;
command: string;
cwd?: string;
reply: (result: CommandStartResult) => void;
}

export type CommandStartResult =
| { ok: true; process: ProcessInfo }
| { ok: false; error: string };

export interface CommandKillPayload {
id: string;
signal?: NodeJS.Signals;
Expand Down
2 changes: 2 additions & 0 deletions extensions/shared/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type {
CommandKillPayload,
CommandPinPayload,
CommandPinResult,
CommandStartPayload,
CommandStartResult,
} from "./commands";
export type {
LogsChunkPayload,
Expand Down