@rol1n/subagent 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (7) hide show
  1. package/README.md +34 -2
  2. package/bridge.ts +171 -0
  3. package/index.ts +590 -0
  4. package/mux.ts +621 -0
  5. package/package.json +41 -4
  6. package/session.ts +166 -0
  7. package/ui.ts +100 -0
package/index.ts ADDED
@@ -0,0 +1,590 @@
1
+ /**
2
+ * subagent/index.ts — Unified Homogeneous Subagent Extension
3
+ *
4
+ * Core Principles:
5
+ * - Every subagent is a standard, full-featured Pi instance (peer worker).
6
+ * - Single native tool: `subagent` (supports spawn, message/reply, and stop).
7
+ * - Multi-worker concurrency with smart multiplexer pane management (tmux & Zellij).
8
+ * - Non-blocking async dispatch: results and questions delivered as steer events.
9
+ * - Cross-platform: Native Windows (PowerShell + Zellij) & POSIX (bash + tmux/Zellij).
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { Box, Container, type SelectItem, SelectList, Text, type TUI } from "@earendil-works/pi-tui";
14
+ import { Type } from "typebox";
15
+ import { randomUUID } from "node:crypto";
16
+ import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { homedir, tmpdir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ import type { ChildProcess } from "node:child_process";
22
+ import {
23
+ closeSurface,
24
+ createSurface,
25
+ getWorkerBackend,
26
+ isMuxAvailable,
27
+ isProcessSurface,
28
+ pollForExit,
29
+ renderSubagentCommand,
30
+ sendCommand,
31
+ sendLongCommand,
32
+ spawnProcessWorker,
33
+ type WorkerBackend,
34
+ } from "./mux.ts";
35
+ import { findLastAssistantMessage } from "./session.ts";
36
+ import { renderWorkersWidget, type WorkerStatus } from "./ui.ts";
37
+
38
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
39
+
40
+ interface RunningWorker {
41
+ id: string;
42
+ name: string;
43
+ task: string;
44
+ surface: string;
45
+ sessionFile: string;
46
+ startTime: number;
47
+ status: "running" | "waiting" | "done" | "error";
48
+ question?: string;
49
+ error?: string;
50
+ abortController: AbortController;
51
+ childProcess?: ChildProcess;
52
+ backend: WorkerBackend;
53
+ logFile?: string;
54
+ }
55
+
56
+ const SubagentParams = Type.Object({
57
+ task: Type.Optional(
58
+ Type.String({
59
+ description:
60
+ "Task or prompt for the worker. For a new worker, describe the complete objective. " +
61
+ "For an existing worker, provide the follow-up instruction or answer to its question.",
62
+ }),
63
+ ),
64
+ name: Type.Optional(
65
+ Type.String({
66
+ description:
67
+ "Descriptive label for the worker (e.g. 'frontend', 'db-migration', 'tester'). " +
68
+ "Defaults to 'worker-1', 'worker-2', etc. if omitted.",
69
+ }),
70
+ ),
71
+ target: Type.Optional(
72
+ Type.String({
73
+ description:
74
+ "Name of an existing worker to direct this message to (for replying to its question, " +
75
+ "providing additional guidance, or stopping it).",
76
+ }),
77
+ ),
78
+ action: Type.Optional(
79
+ Type.Union(
80
+ [Type.Literal("spawn"), Type.Literal("send"), Type.Literal("stop")],
81
+ {
82
+ description:
83
+ "Action to perform: 'spawn' (start new worker, default), 'send' (steer/reply to worker), 'stop' (kill worker).",
84
+ },
85
+ ),
86
+ ),
87
+ model: Type.Optional(
88
+ Type.String({
89
+ description: "Optional model override for this worker (e.g. a faster or stronger model).",
90
+ }),
91
+ ),
92
+ cwd: Type.Optional(
93
+ Type.String({
94
+ description: "Optional working directory for the worker.",
95
+ }),
96
+ ),
97
+ });
98
+
99
+ export default function subagentExtension(pi: ExtensionAPI) {
100
+ // Prevent child subagents from self-hosting or infinite recursion
101
+ if (process.env.PI_SUBAGENT_ACTIVE === "1") {
102
+ return;
103
+ }
104
+
105
+ // Active workers state
106
+ const activeWorkers = new Map<string, RunningWorker>();
107
+ const reservedNames = new Set<string>();
108
+
109
+ let widgetContext: ExtensionContext | undefined;
110
+ let widgetTimer: ReturnType<typeof setInterval> | undefined;
111
+
112
+ const refreshWidget = (): void => {
113
+ if (!widgetContext || !widgetContext.hasUI) return;
114
+ const workers: WorkerStatus[] = Array.from(activeWorkers.values()).map((w) => ({
115
+ name: w.name,
116
+ state: w.status,
117
+ startTime: w.startTime,
118
+ sessionFile: w.sessionFile,
119
+ question: w.question,
120
+ error: w.error,
121
+ backend: w.backend,
122
+ }));
123
+
124
+ if (workers.length === 0) {
125
+ widgetContext.ui.setWidget("subagents", undefined);
126
+ if (widgetTimer) {
127
+ clearInterval(widgetTimer);
128
+ widgetTimer = undefined;
129
+ }
130
+ return;
131
+ }
132
+
133
+ widgetContext.ui.setWidget("subagents", renderWorkersWidget(workers, widgetContext.ui.theme), {
134
+ placement: "aboveEditor",
135
+ });
136
+
137
+ if (!widgetTimer) {
138
+ widgetTimer = setInterval(refreshWidget, 1000);
139
+ widgetTimer.unref();
140
+ }
141
+ };
142
+
143
+ function allocateUniqueName(preferred?: string): string {
144
+ const base = preferred?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-") || "worker";
145
+ if (!activeWorkers.has(base) && !reservedNames.has(base)) {
146
+ return base;
147
+ }
148
+ let index = 2;
149
+ while (activeWorkers.has(`${base}-${index}`) || reservedNames.has(`${base}-${index}`)) {
150
+ index++;
151
+ }
152
+ return `${base}-${index}`;
153
+ }
154
+
155
+ // ── Watcher Loop ──
156
+ async function watchWorker(worker: RunningWorker, signal: AbortSignal): Promise<void> {
157
+ try {
158
+ const result = await pollForExit(worker.surface, signal, {
159
+ interval: 1000,
160
+ sessionFile: worker.sessionFile,
161
+ childProcess: worker.childProcess,
162
+ onTick: () => {
163
+ // Check for .ask question sidecar
164
+ const askFile = `${worker.sessionFile}.ask`;
165
+ if (existsSync(askFile)) {
166
+ try {
167
+ const data = JSON.parse(readFileSync(askFile, "utf-8"));
168
+ if (worker.status !== "waiting" && data.question) {
169
+ worker.status = "waiting";
170
+ worker.question = data.question;
171
+ refreshWidget();
172
+
173
+ pi.sendMessage(
174
+ {
175
+ customType: "subagent_question",
176
+ content:
177
+ `[Subagent "${worker.name}" question]\n\n${data.question}\n\n` +
178
+ `Reply with subagent({ target: "${worker.name}", task: "Your answer" }).`,
179
+ display: true,
180
+ details: {
181
+ name: worker.name,
182
+ question: data.question,
183
+ },
184
+ },
185
+ { triggerTurn: true, deliverAs: "steer" },
186
+ );
187
+ }
188
+ } catch {}
189
+ }
190
+ },
191
+ });
192
+
193
+ // Worker finished
194
+ const elapsed = Math.floor((Date.now() - worker.startTime) / 1000);
195
+ const summary =
196
+ findLastAssistantMessage(worker.sessionFile) ??
197
+ (result.errorMessage
198
+ ? `Worker failed with error: ${result.errorMessage}`
199
+ : result.exitCode !== 0
200
+ ? `Worker exited with status ${result.exitCode}`
201
+ : "Worker completed without output text.");
202
+
203
+ closeSurface(worker.surface, worker.childProcess);
204
+ activeWorkers.delete(worker.name);
205
+ refreshWidget();
206
+
207
+ pi.sendMessage(
208
+ {
209
+ customType: "subagent_result",
210
+ content: `[Subagent "${worker.name}" report]\n\n${summary}`,
211
+ display: true,
212
+ details: {
213
+ name: worker.name,
214
+ task: worker.task,
215
+ exitCode: result.exitCode,
216
+ elapsed,
217
+ sessionFile: worker.sessionFile,
218
+ backend: worker.backend,
219
+ logFile: worker.logFile,
220
+ },
221
+ },
222
+ { triggerTurn: true, deliverAs: "steer" },
223
+ );
224
+ } catch (err: any) {
225
+ closeSurface(worker.surface, worker.childProcess);
226
+ activeWorkers.delete(worker.name);
227
+ refreshWidget();
228
+
229
+ if (!signal.aborted) {
230
+ pi.sendMessage(
231
+ {
232
+ customType: "subagent_result",
233
+ content: `[Subagent "${worker.name}" stopped unexpectedly]: ${err?.message ?? String(err)}`,
234
+ display: true,
235
+ details: { name: worker.name, error: err?.message, backend: worker.backend },
236
+ },
237
+ { triggerTurn: true, deliverAs: "steer" },
238
+ );
239
+ }
240
+ }
241
+ }
242
+
243
+ // ── Register Tool: subagent ──
244
+ pi.registerTool({
245
+ name: "subagent",
246
+ label: "Subagent",
247
+ description:
248
+ "Dispatch, message, or stop an independent, full-powered Pi worker in a split terminal pane. " +
249
+ "Every worker has complete Pi capabilities (read, write, bash, edit, skills, extensions). " +
250
+ "This call is NON-BLOCKING: it returns immediately after launching or messaging. " +
251
+ "When a worker completes or asks a question, results are delivered automatically as steer messages. " +
252
+ "You can launch MULTIPLE parallel workers in a single turn by calling this tool multiple times.",
253
+ promptSnippet:
254
+ "Spawn or communicate with parallel Pi subagent workers. Returns immediately; results arrive automatically via steer.",
255
+ parameters: SubagentParams,
256
+
257
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
258
+ const action = params.action ?? (params.target ? "send" : "spawn");
259
+
260
+ // ── Action: Stop ──
261
+ if (action === "stop") {
262
+ const targetName = params.target || params.name;
263
+ if (!targetName) {
264
+ return {
265
+ content: [{ type: "text", text: "Error: Please specify the worker 'target' or 'name' to stop." }],
266
+ details: { error: "target required" },
267
+ };
268
+ }
269
+ const worker = activeWorkers.get(targetName);
270
+ if (!worker) {
271
+ return {
272
+ content: [{ type: "text", text: `No active worker named "${targetName}".` }],
273
+ details: { error: "worker not found" },
274
+ };
275
+ }
276
+
277
+ worker.abortController.abort();
278
+ closeSurface(worker.surface, worker.childProcess);
279
+ activeWorkers.delete(targetName);
280
+ refreshWidget();
281
+
282
+ const typeHint = worker.backend === "process" ? "process killed" : "pane closed";
283
+ return {
284
+ content: [{ type: "text", text: `Worker "${targetName}" stopped (${typeHint}).` }],
285
+ details: { status: "stopped", name: targetName },
286
+ };
287
+ }
288
+
289
+ // ── Action: Send (Steer / Reply) ──
290
+ if (action === "send" || params.target) {
291
+ const targetName = params.target || params.name;
292
+ if (!targetName) {
293
+ return {
294
+ content: [{ type: "text", text: "Error: Please specify the 'target' worker to send instructions to." }],
295
+ details: { error: "target required" },
296
+ };
297
+ }
298
+
299
+ const worker = activeWorkers.get(targetName);
300
+ if (!worker) {
301
+ const known = Array.from(activeWorkers.keys()).join(", ") || "(none)";
302
+ return {
303
+ content: [{ type: "text", text: `No active worker named "${targetName}". Active workers: ${known}.` }],
304
+ details: { error: "worker not found" },
305
+ };
306
+ }
307
+
308
+ if (!params.task?.trim()) {
309
+ return {
310
+ content: [{ type: "text", text: "Error: `task` parameter is required when sending a message." }],
311
+ details: { error: "task required" },
312
+ };
313
+ }
314
+
315
+ // Deliver via reply file to unblock ask_question tool
316
+ const replyFile = `${worker.sessionFile}.reply`;
317
+ writeFileSync(replyFile, params.task, "utf8");
318
+
319
+ // Clear waiting question if answering
320
+ if (worker.status === "waiting") {
321
+ worker.status = "running";
322
+ worker.question = undefined;
323
+ refreshWidget();
324
+ } else if (!isProcessSurface(worker.surface)) {
325
+ // If in mux mode and not waiting on ask_question, also send keys to terminal pane
326
+ const flatMessage = params.task.replace(/\r?\n/g, " ");
327
+ sendCommand(worker.surface, flatMessage);
328
+ }
329
+
330
+ return {
331
+ content: [
332
+ {
333
+ type: "text",
334
+ text:
335
+ `Message delivered to worker "${targetName}". It will process the instruction and ` +
336
+ `steer results back when finished.`,
337
+ },
338
+ ],
339
+ details: { status: "message_delivered", name: targetName },
340
+ };
341
+ }
342
+
343
+ // ── Action: Spawn (Default) ──
344
+ if (!params.task?.trim()) {
345
+ return {
346
+ content: [{ type: "text", text: "Error: `task` is required to spawn a new subagent worker." }],
347
+ details: { error: "task required" },
348
+ };
349
+ }
350
+
351
+ // Determine backend: tmux, zellij, or process (headless)
352
+ const backend = getWorkerBackend();
353
+
354
+ // Allocate and reserve name
355
+ const workerName = allocateUniqueName(params.name);
356
+ reservedNames.add(workerName);
357
+
358
+ const id = randomUUID().slice(0, 8);
359
+ let surface: string;
360
+ try {
361
+ surface = createSurface(workerName, backend);
362
+ } catch (err: any) {
363
+ reservedNames.delete(workerName);
364
+ return {
365
+ content: [{ type: "text", text: `Failed to create worker surface: ${err.message}` }],
366
+ details: { error: err.message },
367
+ };
368
+ }
369
+
370
+ // Prepare session file & log file
371
+ const sessionsDir = join(homedir(), ".pi", "agent", "subagent-sessions");
372
+ mkdirSync(sessionsDir, { recursive: true });
373
+ const sessionFile = join(sessionsDir, `${Date.now()}_${workerName}_${id}.jsonl`);
374
+ const logFile = `${sessionFile}.log`;
375
+
376
+ const bridgePath = join(extensionDir, "bridge.ts");
377
+ const parts: string[] = ["pi", "--session", sessionFile, "-e", bridgePath];
378
+
379
+ if (params.model) {
380
+ parts.push("--model", params.model);
381
+ }
382
+
383
+ // Environment variables passed to worker
384
+ const envPairs: Array<[string, string]> = [
385
+ ["PI_SUBAGENT_ACTIVE", "1"],
386
+ ["PI_SUBAGENT_NAME", workerName],
387
+ ["PI_SUBAGENT_SESSION", sessionFile],
388
+ ["PI_SUBAGENT_SURFACE", surface],
389
+ ["PI_SUBAGENT_BACKEND", backend],
390
+ ["PI_SUBAGENT_PARENT_ID", ctx.sessionManager.getSessionId()],
391
+ ];
392
+
393
+ if (process.env.PI_CODING_AGENT_DIR) {
394
+ envPairs.push(["PI_CODING_AGENT_DIR", process.env.PI_CODING_AGENT_DIR]);
395
+ }
396
+
397
+ // Deliver task: if long or multiline, write to artifact file
398
+ let taskArg: string;
399
+ if (params.task.length > 300 || params.task.includes("\n")) {
400
+ const artifactsDir = join(homedir(), ".pi", "agent", "artifacts", "subagents");
401
+ mkdirSync(artifactsDir, { recursive: true });
402
+ const taskPath = join(artifactsDir, `${workerName}-${id}.md`);
403
+ writeFileSync(taskPath, params.task, "utf8");
404
+ taskArg = `@${taskPath}`;
405
+ } else {
406
+ taskArg = params.task;
407
+ }
408
+ parts.push(taskArg);
409
+
410
+ let childProcess: ChildProcess | undefined;
411
+
412
+ if (backend === "process") {
413
+ childProcess = spawnProcessWorker({
414
+ cwd: params.cwd ?? null,
415
+ env: envPairs,
416
+ args: parts,
417
+ logPath: logFile,
418
+ });
419
+ } else {
420
+ const { script } = renderSubagentCommand({
421
+ cwd: params.cwd ?? null,
422
+ env: envPairs,
423
+ args: parts,
424
+ });
425
+ sendLongCommand(surface, script);
426
+ }
427
+
428
+ // Track running worker
429
+ const abortController = new AbortController();
430
+ const worker: RunningWorker = {
431
+ id,
432
+ name: workerName,
433
+ task: params.task,
434
+ surface,
435
+ sessionFile,
436
+ startTime: Date.now(),
437
+ status: "running",
438
+ abortController,
439
+ childProcess,
440
+ backend,
441
+ logFile,
442
+ };
443
+
444
+ activeWorkers.set(workerName, worker);
445
+ reservedNames.delete(workerName);
446
+ refreshWidget();
447
+
448
+ // Start non-blocking watcher in background
449
+ void watchWorker(worker, abortController.signal);
450
+
451
+ const modeHint =
452
+ backend === "process"
453
+ ? "running silently in background process"
454
+ : `running in ${backend} split pane`;
455
+
456
+ return {
457
+ content: [
458
+ {
459
+ type: "text",
460
+ text:
461
+ `Worker "${workerName}" launched (${modeHint}). ` +
462
+ `Do NOT write polling loops or sleep commands. The harness will deliver results automatically ` +
463
+ `as a steer message when the worker finishes. You can now continue your work or spawn other workers in parallel.`,
464
+ },
465
+ ],
466
+ details: {
467
+ id,
468
+ name: workerName,
469
+ status: "started",
470
+ surface,
471
+ backend,
472
+ logFile: backend === "process" ? logFile : undefined,
473
+ },
474
+ };
475
+ },
476
+
477
+ renderCall(args, theme) {
478
+ const partial = args as Record<string, unknown>;
479
+ const action = String(partial.action || (partial.target ? "send" : "spawn"));
480
+ const name = String(partial.target || partial.name || "worker");
481
+ const title = theme.fg("toolTitle", theme.bold(`subagent (${action}) `));
482
+ const targetTag = theme.fg("accent", `[${name}] `);
483
+ const taskPreview = typeof partial.task === "string" ? partial.task.slice(0, 60).replace(/\n/g, " ") : "";
484
+ return new Text(`${title}${targetTag}${theme.fg("muted", taskPreview)}`, 0, 0);
485
+ },
486
+ });
487
+
488
+ // ── Register Message Renderers ──
489
+ pi.registerMessageRenderer("subagent_result", (message, options, theme) => {
490
+ const details = (message.details as any) || {};
491
+ const name = details.name || "worker";
492
+ const pad = options?.outputPad ?? 1;
493
+ const box = new Box(pad, 0, (text: string) => {
494
+ try {
495
+ return theme.bg("toolSuccessBg", text);
496
+ } catch {
497
+ return text;
498
+ }
499
+ });
500
+ const title = theme.bold(theme.fg("success", `✔ [Subagent: ${name}] Completed`));
501
+ const meta = details.elapsed ? theme.fg("dim", ` (${details.elapsed}s)`) : "";
502
+ const body = new Text(`${title}${meta}\n\n${message.content}`, 0, 0);
503
+ box.addChild(body);
504
+ return box;
505
+ });
506
+
507
+ pi.registerMessageRenderer("subagent_question", (message, options, theme) => {
508
+ const details = (message.details as any) || {};
509
+ const name = details.name || "worker";
510
+ const pad = options?.outputPad ?? 1;
511
+ const box = new Box(pad, 0, (text: string) => {
512
+ try {
513
+ return theme.bg("customMessageBg", text);
514
+ } catch {
515
+ return text;
516
+ }
517
+ });
518
+ const title = theme.bold(theme.fg("warning", `◆ [Subagent: ${name}] Question / Blocked`));
519
+ const body = new Text(`${title}\n\n${message.content}`, 0, 0);
520
+ box.addChild(body);
521
+ return box;
522
+ });
523
+
524
+ // ── Register Interactive Command: /subagent ──
525
+ pi.registerCommand("subagent", {
526
+ description: "Manage and inspect active subagent workers",
527
+ handler: async (_args, ctx) => {
528
+ const workers = Array.from(activeWorkers.values());
529
+ if (workers.length === 0) {
530
+ ctx.ui.notify("No active subagent workers currently running.", "info");
531
+ return;
532
+ }
533
+
534
+ const items: SelectItem[] = workers.map((w) => ({
535
+ value: w.name,
536
+ label: `${w.name} [${w.status}] pane:${w.surface} task: ${w.task.slice(0, 30)}...`,
537
+ }));
538
+
539
+ await ctx.ui.custom<string | undefined>((customTui, theme, _keybindings, done) => {
540
+ const list = new SelectList(items, Math.min(items.length, 10), {
541
+ selectedPrefix: (text) => theme.fg("accent", text),
542
+ selectedText: (text) => theme.fg("accent", text),
543
+ description: (text) => theme.fg("muted", text),
544
+ scrollInfo: (text) => theme.fg("dim", text),
545
+ noMatch: (text) => theme.fg("warning", text),
546
+ });
547
+ list.onSelect = (item) => {
548
+ ctx.ui.notify(`Selected worker: ${item.value}`, "info");
549
+ done(item.value);
550
+ };
551
+ list.onCancel = () => done(undefined);
552
+
553
+ const container = new Container();
554
+ container.addChild(new Text(theme.fg("accent", theme.bold("Active Subagents")), 1, 0));
555
+ container.addChild(list);
556
+
557
+ return {
558
+ render: (width) => container.render(width),
559
+ invalidate: () => container.invalidate(),
560
+ handleInput: (data) => {
561
+ list.handleInput(data);
562
+ customTui.requestRender();
563
+ },
564
+ };
565
+ });
566
+ },
567
+ });
568
+
569
+ // ── Session Lifecycle Hooks ──
570
+ pi.on("session_start", (_event, ctx) => {
571
+ widgetContext = ctx;
572
+ refreshWidget();
573
+ });
574
+
575
+ pi.on("session_shutdown", (_event, ctx) => {
576
+ if (widgetTimer) {
577
+ clearInterval(widgetTimer);
578
+ widgetTimer = undefined;
579
+ }
580
+ widgetContext = undefined;
581
+ ctx.ui.setWidget("subagents", undefined);
582
+
583
+ // Clean up all active worker panes
584
+ for (const worker of activeWorkers.values()) {
585
+ worker.abortController.abort();
586
+ closeSurface(worker.surface, worker.childProcess);
587
+ }
588
+ activeWorkers.clear();
589
+ });
590
+ }