diff --git a/extensions/processes-debug/index.ts b/extensions/processes-debug/index.ts new file mode 100644 index 0000000..e7b911f --- /dev/null +++ b/extensions/processes-debug/index.ts @@ -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 { + const events = pi.events; + + pi.registerCommand("debug:ps:start", { + description: + "Start a background process (debug). Usage: /debug:ps:start ", + handler: async (args: string, ctx) => { + const parsed = parseStartArgs(args); + if (!parsed) { + ctx.ui.notify("Usage: /debug:ps:start ", "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 }; +} diff --git a/extensions/processes/client.ts b/extensions/processes/client.ts index d2bd2a9..5a86714 100644 --- a/extensions/processes/client.ts +++ b/extensions/processes/client.ts @@ -15,6 +15,8 @@ import { type CommandKillPayload, type CommandPinPayload, type CommandPinResult, + type CommandStartPayload, + type CommandStartResult, type ProcessProtocolConfig, type RequestCombinedOutputPayload, type RequestConfigPayload, @@ -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 { + 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 = { diff --git a/extensions/processes/handlers/commands.ts b/extensions/processes/handlers/commands.ts index d3c0e64..968ecad 100644 --- a/extensions/processes/handlers/commands.ts +++ b/extensions/processes/handlers/commands.ts @@ -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"; @@ -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; diff --git a/extensions/shared/protocol/channels.ts b/extensions/shared/protocol/channels.ts index 7520a7b..4b4b467 100644 --- a/extensions/shared/protocol/channels.ts +++ b/extensions/shared/protocol/channels.ts @@ -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. diff --git a/extensions/shared/protocol/commands.ts b/extensions/shared/protocol/commands.ts index f4909f6..2321a76 100644 --- a/extensions/shared/protocol/commands.ts +++ b/extensions/shared/protocol/commands.ts @@ -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; diff --git a/extensions/shared/protocol/index.ts b/extensions/shared/protocol/index.ts index e330b22..87b2456 100644 --- a/extensions/shared/protocol/index.ts +++ b/extensions/shared/protocol/index.ts @@ -10,6 +10,8 @@ export type { CommandKillPayload, CommandPinPayload, CommandPinResult, + CommandStartPayload, + CommandStartResult, } from "./commands"; export type { LogsChunkPayload,