pi-gang 0.1.0

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.
@@ -0,0 +1,618 @@
1
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { execFile } from "child_process";
5
+ import { promisify } from "util";
6
+ import { chmodSync, mkdirSync, writeFileSync } from "fs";
7
+ import { basename, join, dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { homedir } from "os";
10
+ import { ORCHESTRATOR, Roster, buildMemberEnv, computeMemberRuntimeSnapshot, isValidRole, type Member, type MemberRuntimeSnapshot } from "./members.ts";
11
+ import {
12
+ GANG_SESSION,
13
+ TMUX_BIN,
14
+ hasSessionArgs,
15
+ newSessionArgs,
16
+ splitArgs,
17
+ remainOnExitArgs,
18
+ tiledLayoutArgs,
19
+ memberCommand,
20
+ listPanesArgs,
21
+ listDetailedPanesArgs,
22
+ listAllPanesArgs,
23
+ listAllDetailedPanesArgs,
24
+ killPaneArgs,
25
+ killSessionArgs,
26
+ parsePaneList,
27
+ parseDetailedPaneList,
28
+ type PaneDetails,
29
+ } from "./tmux.ts";
30
+ import { FeedClient } from "./feed-client.ts";
31
+ import { GUI_PORT } from "../intercom/gui/server.js";
32
+ import { MissionControlOverlay } from "./ui/mission-control.ts";
33
+ import { SUPERINTENDENT_NAMED_EVENT, GANG_MEMBER_REPORT_EVENT, type GangMemberReportEvent } from "./events.ts";
34
+
35
+ const execFileP = promisify(execFile);
36
+
37
+ const GANG_DIR = dirname(fileURLToPath(import.meta.url));
38
+ const GANG_INDEX = join(GANG_DIR, "index.ts");
39
+ const INTERCOM_INDEX = join(GANG_DIR, "..", "intercom", "index.ts");
40
+
41
+ const COMMAND_COMPLETIONS: AutocompleteItem[] = [
42
+ { value: "watch", label: "watch", description: "Open mission control" },
43
+ { value: "url", label: "url", description: "Show browser mission-control URL" },
44
+ { value: "name ", label: "name", description: "Show or set this session's own name" },
45
+ { value: "spawn ", label: "spawn", description: "Spawn a member: spawn [@name] [-t <level>] <task>" },
46
+ { value: "list", label: "list", description: "Show spawned members with live pane diagnostics" },
47
+ { value: "clean", label: "clean", description: "Reap finished panes (clean --force kills reported-done members too)" },
48
+ { value: "stop ", label: "stop", description: "Stop one member or stop --all-finished" },
49
+ ];
50
+
51
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
52
+ type GangToolDetails = Member | { name: string } | { error: true } | undefined;
53
+ type ToolResult = AgentToolResult<GangToolDetails>;
54
+
55
+ type SpawnCommandParseResult =
56
+ | { ok: true; name?: string; task: string; thinkingLevel?: string }
57
+ | { ok: false; error: string };
58
+
59
+ function isThinkingLevel(value: string): boolean {
60
+ return THINKING_LEVELS.includes(value as (typeof THINKING_LEVELS)[number]);
61
+ }
62
+
63
+ export function parseSpawnCommand(rest: string): SpawnCommandParseResult {
64
+ const tokens = rest.trim().split(/\s+/).filter(Boolean);
65
+ let name: string | undefined;
66
+ let thinkingLevel: string | undefined;
67
+ let index = 0;
68
+
69
+ // Consume leading options in any order: `-t/--thinking <level>` and a single `@name`.
70
+ // The first token that is neither begins the task (task is free text, so a name needs the @ sigil).
71
+ const readLead = (): boolean => {
72
+ const token = tokens[index];
73
+ if (!token) return false;
74
+ if (token === "--thinking" || token === "-t") {
75
+ const value = tokens[index + 1];
76
+ if (!value) {
77
+ throw new Error("--thinking requires a level: off, minimal, low, medium, high, or xhigh.");
78
+ }
79
+ if (!isThinkingLevel(value)) {
80
+ throw new Error(`Invalid thinking level "${value}". Use off, minimal, low, medium, high, or xhigh.`);
81
+ }
82
+ thinkingLevel = value;
83
+ index += 2;
84
+ return true;
85
+ }
86
+ if (token.startsWith("--thinking=")) {
87
+ const value = token.slice("--thinking=".length);
88
+ if (!isThinkingLevel(value)) {
89
+ throw new Error(`Invalid thinking level "${value}". Use off, minimal, low, medium, high, or xhigh.`);
90
+ }
91
+ thinkingLevel = value;
92
+ index += 1;
93
+ return true;
94
+ }
95
+ if (token.startsWith("@") && name === undefined) {
96
+ const candidate = token.slice(1);
97
+ if (!isValidRole(candidate)) {
98
+ throw new Error(`Invalid name "${candidate}". Use letters, digits, _ or - (start with a letter, max 32 chars).`);
99
+ }
100
+ name = candidate;
101
+ index += 1;
102
+ return true;
103
+ }
104
+ return false;
105
+ };
106
+
107
+ try {
108
+ while (readLead()) {}
109
+ } catch (error) {
110
+ return { ok: false, error: getErrorMessage(error) };
111
+ }
112
+
113
+ const task = tokens.slice(index).join(" ").trim();
114
+ if (!task) {
115
+ return { ok: false, error: "Usage: /gang spawn [@name] [-t <level>] <task>" };
116
+ }
117
+ return { ok: true, name, task, thinkingLevel };
118
+ }
119
+
120
+ export function getGangArgumentCompletions(prefix: string): AutocompleteItem[] | null {
121
+ const trimmedStart = prefix.trimStart();
122
+ if (!trimmedStart.includes(" ")) {
123
+ const completions = COMMAND_COMPLETIONS.filter((item) => item.label.startsWith(trimmedStart));
124
+ return completions.length > 0 ? completions : null;
125
+ }
126
+
127
+ // spawn grammar is `[@name] [-t <level>] <task>`; task is free text, so only the leading
128
+ // thinking flag and its level are completable.
129
+ const levelMatch = trimmedStart.match(/^(spawn\s+(?:@\S+\s+)?(?:--thinking|-t)\s+)(\S*)$/);
130
+ if (levelMatch) {
131
+ const lead = levelMatch[1];
132
+ const levelPrefix = levelMatch[2] ?? "";
133
+ const completions = THINKING_LEVELS.filter((level) => level.startsWith(levelPrefix)).map((level) => ({
134
+ value: `${lead}${level} `,
135
+ label: level,
136
+ description: `Use ${level} thinking`,
137
+ }));
138
+ return completions.length > 0 ? completions : null;
139
+ }
140
+
141
+ const flagMatch = trimmedStart.match(/^(spawn\s+(?:@\S+\s+)?)(-{1,2}\S*)$/);
142
+ if (flagMatch) {
143
+ const lead = flagMatch[1];
144
+ const flagPrefix = flagMatch[2] ?? "";
145
+ if ("--thinking".startsWith(flagPrefix) || "-t".startsWith(flagPrefix)) {
146
+ return [{ value: `${lead}--thinking `, label: "--thinking", description: "Set member thinking level" }];
147
+ }
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ function getErrorMessage(error: unknown): string {
154
+ return error instanceof Error ? error.message : String(error);
155
+ }
156
+
157
+ function count(n: number, noun: string): string {
158
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
159
+ }
160
+
161
+ function taskFileContent(role: string, task: string, orchestrator: string): string {
162
+ return [
163
+ `Task: ${task}`,
164
+ "",
165
+ `You are "${role}", a member of a gang supervised by "${orchestrator}". Work autonomously.`,
166
+ "If you spawn a teammate or your role needs more specificity, name yourself first with the gang tool:",
167
+ " gang({ action: \"name\", name: \"<clear role/name>\" })",
168
+ "When you are finished, report your result to your supervisor and end your session in one call:",
169
+ ` intercom({ action: "send", to: "${orchestrator}", message: "<your result>", done: true })`,
170
+ "That delivers your result, then exits this session so your finished pane can be cleaned up.",
171
+ "If you get blocked and need a decision, use the contact_supervisor tool instead.",
172
+ "",
173
+ ].join("\n");
174
+ }
175
+
176
+ export function missionControlUrl(port = GUI_PORT): string {
177
+ return `http://127.0.0.1:${port}`;
178
+ }
179
+
180
+ function defaultSuperintendentName(cwd = process.cwd()): string {
181
+ const project = basename(cwd).trim();
182
+ return project ? `superintendent of ${project}` : ORCHESTRATOR;
183
+ }
184
+
185
+ export default function gangExtension(pi: ExtensionAPI) {
186
+ const roster = new Roster();
187
+ let orchestratorName = ORCHESTRATOR;
188
+ // When pi itself runs inside tmux (and exposes its pane via $TMUX_PANE), members split into pi's own
189
+ // window — visible alongside pi; otherwise they go to a dedicated detached `gang` session you attach
190
+ // to. Gate on TMUX_PANE so we always have a concrete pane to target (a no-`-t` split lands in whatever
191
+ // window is *currently active*, not pi's); no pane → fall back to detached. Fixed per superintendent process.
192
+ const tmuxPane = process.env.TMUX_PANE ?? "";
193
+ const inTmux = !!process.env.TMUX && !!tmuxPane;
194
+
195
+ async function runTmux(args: string[]): Promise<string> {
196
+ const { stdout } = await execFileP(TMUX_BIN, args);
197
+ return stdout.trim();
198
+ }
199
+
200
+ async function ensureGangSession(): Promise<void> {
201
+ try {
202
+ await runTmux(hasSessionArgs());
203
+ } catch {
204
+ await runTmux(newSessionArgs());
205
+ }
206
+ }
207
+
208
+ function writeTaskFile(role: string, task: string, index: number): string {
209
+ const dir = join(homedir(), ".pi/agent/gang", roster.runId);
210
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
211
+ if (process.platform !== "win32") chmodSync(dir, 0o700);
212
+ const file = join(dir, `${index}-${role}.md`);
213
+ writeFileSync(file, taskFileContent(role, task, orchestratorName), { mode: 0o600 });
214
+ if (process.platform !== "win32") chmodSync(file, 0o600);
215
+ return file;
216
+ }
217
+
218
+ async function spawnMember(task: string, cwd: string, opts: { name?: string; thinkingLevel?: string } = {}): Promise<Member> {
219
+ const { name, thinkingLevel } = opts;
220
+ if (name !== undefined && !isValidRole(name)) {
221
+ throw new Error(`Invalid name "${name}". Use letters, digits, _ or - (start with a letter, max 32 chars).`);
222
+ }
223
+ if (thinkingLevel && !isThinkingLevel(thinkingLevel)) {
224
+ throw new Error(`Invalid thinking level "${thinkingLevel}". Use off, minimal, low, medium, high, or xhigh.`);
225
+ }
226
+ // Claim the superintendent identity lazily — first spawn is when we actually need to be addressable. Push
227
+ // it onto the bus now so this member can reach us by name without waiting for our next turn.
228
+ if (!pi.getSessionName()?.trim()) {
229
+ pi.setSessionName(orchestratorName);
230
+ pi.events.emit(SUPERINTENDENT_NAMED_EVENT, undefined);
231
+ }
232
+ const index = roster.nextIndex();
233
+ const role = name ?? `m${index + 1}`;
234
+ if (roster.hasRole(role)) {
235
+ throw new Error(`Member "${role}" already exists in this gang. Stop it first or choose a different role.`);
236
+ }
237
+ const taskFile = writeTaskFile(role, task, index);
238
+ // Forward PATH so the pane resolves `pi` even if the tmux server started with a minimal env.
239
+ const env = { PATH: process.env.PATH ?? "", ...buildMemberEnv({ role, runId: roster.runId, index, orchestrator: orchestratorName }) };
240
+ const command = memberCommand({ role, taskFile, intercomIndex: INTERCOM_INDEX, gangIndex: GANG_INDEX, thinkingLevel });
241
+
242
+ if (!inTmux) await ensureGangSession();
243
+ let paneId: string;
244
+ try {
245
+ paneId = await runTmux(
246
+ splitArgs(inTmux ? { target: tmuxPane, cwd, env, command, detached: true } : { target: GANG_SESSION, cwd, env, command }),
247
+ );
248
+ } catch (error) {
249
+ // In-tmux we split the user's real (bounded) window; a full window rejects with a raw tmux error.
250
+ if (inTmux) throw new Error(`tmux couldn't add a pane (${getErrorMessage(error)}) — your tmux window may be full. Close a pane or run \`/gang clean\`, then retry.`);
251
+ throw error;
252
+ }
253
+ const member: Member = { role, task, index, paneId, runId: roster.runId, spawnedAt: Date.now(), thinkingLevel };
254
+ roster.add(member);
255
+ // Best-effort polish; failures here must not lose the (already-tracked) member.
256
+ await runTmux(remainOnExitArgs(paneId)).catch(() => {});
257
+ if (inTmux) {
258
+ // Re-tile only when pi's window holds nothing but gang panes — never reshuffle the user's own panes.
259
+ if (await windowIsGangOnly(tmuxPane)) await runTmux(tiledLayoutArgs(tmuxPane)).catch(() => {});
260
+ } else {
261
+ await runTmux(tiledLayoutArgs(GANG_SESSION)).catch(() => {});
262
+ }
263
+ return member;
264
+ }
265
+
266
+ // True when every pane sharing `pane`'s window is pi's own pane or a tracked gang member — i.e.
267
+ // re-tiling that window won't disturb any of the user's own panes. Best-effort: unknown → false.
268
+ async function windowIsGangOnly(pane: string): Promise<boolean> {
269
+ try {
270
+ const inWindow = parsePaneList(await runTmux(listPanesArgs(pane)));
271
+ const allowed = new Set([pane, ...roster.list().map((m) => m.paneId)]);
272
+ return inWindow.every((p) => allowed.has(p.paneId));
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+
278
+ function relativeAge(timestamp?: number): string {
279
+ if (!timestamp) return "never";
280
+ const ms = Math.max(0, Date.now() - timestamp);
281
+ if (ms < 1000) return "just now";
282
+ const seconds = Math.floor(ms / 1000);
283
+ if (seconds < 60) return `${seconds}s ago`;
284
+ const minutes = Math.floor(seconds / 60);
285
+ if (minutes < 60) return `${minutes}m ago`;
286
+ const hours = Math.floor(minutes / 60);
287
+ if (hours < 24) return `${hours}h ago`;
288
+ const days = Math.floor(hours / 24);
289
+ return `${days}d ago`;
290
+ }
291
+
292
+ async function loadTrackedPaneDetails(): Promise<{ panes: Map<string, PaneDetails>; warning?: string }> {
293
+ const members = roster.list();
294
+ if (members.length === 0) return { panes: new Map() };
295
+ const ids = new Set(members.map((member) => member.paneId));
296
+ try {
297
+ const listed = inTmux
298
+ ? parseDetailedPaneList(await runTmux(listAllDetailedPanesArgs()))
299
+ : parseDetailedPaneList(await runTmux(listDetailedPanesArgs()));
300
+ const panes = new Map(listed.filter((pane) => ids.has(pane.paneId)).map((pane) => [pane.paneId, pane]));
301
+ return { panes };
302
+ } catch (error) {
303
+ return {
304
+ panes: new Map(),
305
+ warning: inTmux
306
+ ? `Couldn't list tmux panes (${getErrorMessage(error)})`
307
+ : "No gang session",
308
+ };
309
+ }
310
+ }
311
+
312
+ async function loadMemberSnapshots(): Promise<{ snapshots: MemberRuntimeSnapshot[]; warning?: string }> {
313
+ const { panes, warning } = await loadTrackedPaneDetails();
314
+ return {
315
+ snapshots: roster.list().map((member) => computeMemberRuntimeSnapshot(member, panes.get(member.paneId))),
316
+ warning,
317
+ };
318
+ }
319
+
320
+ async function killExistingPanes(snapshots: MemberRuntimeSnapshot[]): Promise<number> {
321
+ let killed = 0;
322
+ const seen = new Set<string>();
323
+ for (const snapshot of snapshots) {
324
+ if (!snapshot.paneExists || seen.has(snapshot.member.paneId)) continue;
325
+ seen.add(snapshot.member.paneId);
326
+ await runTmux(killPaneArgs(snapshot.member.paneId)).catch(() => {});
327
+ killed += 1;
328
+ }
329
+ return killed;
330
+ }
331
+
332
+ async function stopMember(role: string): Promise<string> {
333
+ const known = roster.findByRole(role);
334
+ if (!known) return `No gang member named "${role}".`;
335
+ const { snapshots, warning } = await loadMemberSnapshots();
336
+ const target = snapshots.find((snapshot) => snapshot.member.role === role) ?? computeMemberRuntimeSnapshot(known);
337
+ const killed = await killExistingPanes([target]);
338
+ roster.removeByRoles(new Set([role]));
339
+ const reason = target.state === "running"
340
+ ? "killed a live pane"
341
+ : target.state === "reported_done"
342
+ ? "killed a reported-done pane"
343
+ : target.state === "pane_dead"
344
+ ? "reaped a finished pane"
345
+ : "pane was already gone";
346
+ return `${warning ? `${warning}. ` : ""}Stopped "${role}": ${reason}; removed it from the roster.`;
347
+ }
348
+
349
+ async function stopFinishedMembers(): Promise<string> {
350
+ const { snapshots, warning } = await loadMemberSnapshots();
351
+ if (snapshots.length === 0) return "No gang members to stop.";
352
+ const finished = snapshots.filter((snapshot) => snapshot.reapable);
353
+ if (finished.length === 0) {
354
+ return `${warning ? `${warning}. ` : ""}No finished members to stop — ${count(snapshots.length, "member")} still running.`;
355
+ }
356
+ const killed = await killExistingPanes(finished);
357
+ const removed = roster.removeByRoles(new Set(finished.map((snapshot) => snapshot.member.role)));
358
+ const reported = finished.filter((snapshot) => snapshot.state === "reported_done").length;
359
+ const dead = finished.filter((snapshot) => snapshot.state === "pane_dead").length;
360
+ const missing = finished.filter((snapshot) => snapshot.state === "pane_missing").length;
361
+ return `${warning ? `${warning}. ` : ""}Stopped ${count(removed, "finished member")}: killed ${count(killed, "pane")}; removed ${reported} reported_done, ${dead} pane_dead, ${missing} pane_missing. ${count(roster.list().length, "member")} still running.`;
362
+ }
363
+
364
+ /**
365
+ * Reap finished members. Default: kill dead panes and prune missing panes from the roster, leaving
366
+ * running members alone. `force`: also kills members that already reported back. `all`: stop every
367
+ * tracked member (kill-session when detached; kill each member pane when pi runs inside tmux).
368
+ */
369
+ async function cleanGang(opts: { all?: boolean; force?: boolean } = {}): Promise<string> {
370
+ const { snapshots, warning } = await loadMemberSnapshots();
371
+ if (snapshots.length === 0) {
372
+ return warning === "No gang session" ? "No gang session — nothing to clean." : "No gang members to clean.";
373
+ }
374
+
375
+ if (opts.all) {
376
+ if (inTmux) await killExistingPanes(snapshots);
377
+ else if (warning !== "No gang session") await runTmux(killSessionArgs()).catch(() => {});
378
+ const stopped = roster.list().length;
379
+ roster.clear();
380
+ return `${warning && warning !== "No gang session" ? `${warning}. ` : ""}Stopped the gang: cleared ${count(stopped, "member")}.`;
381
+ }
382
+
383
+ if (opts.force) {
384
+ const forceTargets = snapshots.filter((snapshot) => snapshot.reapable);
385
+ if (forceTargets.length === 0) {
386
+ return `${warning ? `${warning}. ` : ""}Nothing to force-clean — ${count(snapshots.length, "member")} still running.`;
387
+ }
388
+ const killed = await killExistingPanes(forceTargets);
389
+ const removed = roster.removeByRoles(new Set(forceTargets.map((snapshot) => snapshot.member.role)));
390
+ return `${warning ? `${warning}. ` : ""}Force-cleaned ${count(removed, "member")}: killed ${count(killed, "pane")}. ${count(roster.list().length, "member")} still running.`;
391
+ }
392
+
393
+ const dead = snapshots.filter((snapshot) => snapshot.state === "pane_dead");
394
+ const missing = snapshots.filter((snapshot) => snapshot.state === "pane_missing");
395
+ const killed = await killExistingPanes(dead);
396
+ const removed = roster.removeByRoles(new Set([...dead, ...missing].map((snapshot) => snapshot.member.role)));
397
+ const running = roster.list().length;
398
+ if (dead.length === 0 && missing.length === 0) {
399
+ return `${warning ? `${warning}. ` : ""}Nothing to reap — ${count(running, "member")} still running.`;
400
+ }
401
+ return `${warning ? `${warning}. ` : ""}Reaped ${count(killed, "finished pane")}, pruned ${count(removed - dead.length, "stale member")} from the roster. ${count(running, "member")} still running.`;
402
+ }
403
+
404
+ // Where to look for members: in-tmux they're splits in the current window; else the detached session.
405
+ function watchHint(): string {
406
+ return inTmux ? "Members are splits in your current tmux window." : `Watch live: tmux attach -t ${GANG_SESSION}`;
407
+ }
408
+
409
+ async function formatRoster(): Promise<string> {
410
+ const watch = watchHint();
411
+ const { snapshots, warning } = await loadMemberSnapshots();
412
+ if (snapshots.length === 0) {
413
+ return `No gang members spawned yet (run ${roster.runId.slice(0, 8)}). Use \`/gang spawn <task>\` to launch one.`;
414
+ }
415
+ const rows = snapshots.map((snapshot) => {
416
+ const m = snapshot.member;
417
+ const preview = m.task.replace(/\s+/g, " ").slice(0, 60);
418
+ const thinking = m.thinkingLevel ? ` — thinking ${m.thinkingLevel}` : "";
419
+ const command = snapshot.currentCommand ? ` — cmd ${snapshot.currentCommand}` : "";
420
+ const reported = m.reportedDoneAt ? ` — reported ${relativeAge(m.reportedDoneAt)}` : " — reported no";
421
+ const lastReport = m.lastReportText ? ` — last: ${m.lastReportText.replace(/\s+/g, " ").slice(0, 40)}` : "";
422
+ return `• ${m.role} — pane ${m.paneId}${thinking} — ${snapshot.state} — exists ${snapshot.paneExists ? "yes" : "no"} — alive ${snapshot.processAlive ? "yes" : "no"}${command}${reported} — reapable ${snapshot.reapable ? "yes" : "no"}${lastReport} — ${preview}`;
423
+ });
424
+ const diagnostic = warning ? `\ntmux: ${warning}` : "";
425
+ return `Gang members (run ${roster.runId.slice(0, 8)}). ${watch}${diagnostic}\n${rows.join("\n")}`;
426
+ }
427
+
428
+ function spawnedMessage(m: Member): string {
429
+ const thinking = m.thinkingLevel ? ` with ${m.thinkingLevel} thinking` : "";
430
+ const where = inTmux ? "split into your current window" : `session: ${GANG_SESSION}`;
431
+ return [
432
+ `Launched member "${m.role}"${thinking} in tmux pane ${m.paneId} (${where}).`,
433
+ watchHint(),
434
+ `Its result will arrive here as an intercom message from "${m.role}" — keep working; don't block on it.`,
435
+ ].join("\n");
436
+ }
437
+
438
+ // In-pi mission control: same live feed as the browser GUI, rendered as a TUI overlay.
439
+ async function openMissionControl(ctx: ExtensionContext): Promise<void> {
440
+ if (!ctx.hasUI) return;
441
+ const feed = new FeedClient().start();
442
+ try {
443
+ await ctx.ui.custom<void>((tui, theme, keybindings, done) =>
444
+ new MissionControlOverlay(tui, theme, keybindings, feed, done,
445
+ () => Object.fromEntries(roster.list().map((m) => [m.role, m.task]))));
446
+ } finally {
447
+ feed.stop();
448
+ }
449
+ }
450
+
451
+ // Compute the orchestrator identity but DON'T persist it yet. Naming every session at startup
452
+ // floods `pi -r` with identical "superintendent of <folder>" entries; we claim the name lazily on the first
453
+ // spawn (see spawnMember), so sessions that never use gang keep their natural resume title.
454
+ // A child already has its --name role as its session name, so existingName keeps that.
455
+ pi.on("session_start", (_event, ctx: ExtensionContext) => {
456
+ const existingName = pi.getSessionName()?.trim();
457
+ orchestratorName = existingName || defaultSuperintendentName(ctx.cwd ?? process.cwd());
458
+ });
459
+
460
+ pi.events.on(GANG_MEMBER_REPORT_EVENT, (payload) => {
461
+ const report = payload as GangMemberReportEvent | undefined;
462
+ const sender = report?.fromName?.trim() || report?.fromId?.trim() || "";
463
+ if (!sender || report?.expectsReply) return;
464
+ roster.markReportedDone(sender, report?.timestamp, report?.text);
465
+ });
466
+
467
+ pi.registerTool({
468
+ name: "gang",
469
+ label: "Gang",
470
+ description: `Optional, user-directed delegation: use gang only when the user explicitly asks for it or approves it. Do not spawn members automatically or silently; if delegation would help, mention gang and ask first.
471
+
472
+ Fire up and track visible subagent "members" as live tmux panes.
473
+
474
+ Usage:
475
+ gang({ action: "spawn", task: "..." }) → launch a member (auto-named m1, m2, …)
476
+ gang({ action: "spawn", task: "...", role: "reviewer", thinking: "high" }) → launch with an explicit name
477
+ gang({ action: "list" }) → show members with live pane diagnostics
478
+ gang({ action: "clean" }) → reap dead/missing panes + prune the roster
479
+ gang({ action: "clean", force: true }) → also kill members that already reported back
480
+ gang({ action: "stop", role: "reviewer" }) → stop one member immediately
481
+ gang({ action: "stop", finished: true }) → stop all reapable members
482
+ gang({ action: "name", name: "superintendent of private evals" }) → name this agent/session
483
+
484
+ Only "task" is required for spawn. spawn returns immediately. The member runs its own pi session in a tmux pane (${inTmux ? "split into your current window" : `watch: tmux attach -t ${GANG_SESSION}`}). When done it sends its result back to you ("superintendent") as an intercom message — it does NOT return here. Keep working; handle the result when it arrives.`,
485
+ promptSnippet: "User-directed only: use gang only when the user explicitly asks or approves; never spawn members automatically or silently. If delegation would help, mention gang and ask first. Name yourself before spawning nested agents; results arrive asynchronously via intercom.",
486
+ parameters: Type.Object({
487
+ action: Type.String({ description: "'spawn', 'list', 'clean', 'stop', or 'name'" }),
488
+ role: Type.Optional(Type.String({ description: "Spawn name/role, or the specific member to stop when action='stop'." })),
489
+ task: Type.Optional(Type.String({ description: "What the member should do (required for spawn)" })),
490
+ thinking: Type.Optional(Type.String({ description: "Optional Pi thinking level for spawn: off, minimal, low, medium, high, or xhigh" })),
491
+ name: Type.Optional(Type.String({ description: "New name for this agent/session when action='name'" })),
492
+ all: Type.Optional(Type.Boolean({ description: "When action='clean', stop every tracked member." })),
493
+ force: Type.Optional(Type.Boolean({ description: "When action='clean', also kill members that already reported back." })),
494
+ finished: Type.Optional(Type.Boolean({ description: "When action='stop', stop all reapable members instead of one named role." })),
495
+ }),
496
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<ToolResult> {
497
+ const action = params.action;
498
+ if (action === "spawn") {
499
+ if (typeof params.task !== "string" || !params.task) {
500
+ return { content: [{ type: "text", text: "spawn requires 'task'." }], details: { error: true } };
501
+ }
502
+ const name = typeof params.role === "string" && params.role.trim() ? params.role.trim() : undefined;
503
+ const thinkingLevel = typeof params.thinking === "string" ? params.thinking : undefined;
504
+ try {
505
+ orchestratorName = pi.getSessionName()?.trim() || orchestratorName;
506
+ const member = await spawnMember(params.task, ctx.cwd ?? process.cwd(), { name, thinkingLevel });
507
+ return { content: [{ type: "text", text: spawnedMessage(member) }], details: member };
508
+ } catch (error) {
509
+ return { content: [{ type: "text", text: `gang spawn failed: ${getErrorMessage(error)}` }], details: { error: true } };
510
+ }
511
+ }
512
+ if (action === "list") {
513
+ return { content: [{ type: "text", text: await formatRoster() }], details: undefined };
514
+ }
515
+ if (action === "clean") {
516
+ return { content: [{ type: "text", text: await cleanGang({ all: params.all === true, force: params.force === true }) }], details: undefined };
517
+ }
518
+ if (action === "stop") {
519
+ if (params.finished === true) {
520
+ return { content: [{ type: "text", text: await stopFinishedMembers() }], details: undefined };
521
+ }
522
+ const role = typeof params.role === "string" ? params.role.trim() : "";
523
+ if (!role) {
524
+ return { content: [{ type: "text", text: "stop requires 'role', or set finished:true to stop all reapable members." }], details: { error: true } };
525
+ }
526
+ return { content: [{ type: "text", text: await stopMember(role) }], details: undefined };
527
+ }
528
+ if (action === "name") {
529
+ const nextName = typeof params.name === "string" ? params.name.trim() : "";
530
+ if (!nextName) {
531
+ const currentName = pi.getSessionName()?.trim() || orchestratorName;
532
+ return { content: [{ type: "text", text: `Agent name: ${currentName}` }], details: undefined };
533
+ }
534
+ pi.setSessionName(nextName);
535
+ orchestratorName = nextName;
536
+ return { content: [{ type: "text", text: `Agent name set: ${nextName}` }], details: { name: nextName } };
537
+ }
538
+ return { content: [{ type: "text", text: `Unknown action "${action}". Use 'spawn', 'list', 'clean', 'stop', or 'name'.` }], details: { error: true } };
539
+ },
540
+ });
541
+
542
+ pi.registerCommand("gang", {
543
+ description: "Mission control (/gang watch), roster (/gang), or spawn (/gang spawn [@name] [-t <level>] <task>)",
544
+ getArgumentCompletions: getGangArgumentCompletions,
545
+ async handler(args, ctx: ExtensionContext) {
546
+ const say = (msg: string, level: "info" | "warning" | "error") => {
547
+ if (ctx.hasUI) ctx.ui.notify(msg, level);
548
+ };
549
+ const trimmed = args.trim();
550
+ if (trimmed === "watch") {
551
+ await openMissionControl(ctx);
552
+ return;
553
+ }
554
+ if (trimmed === "url") {
555
+ say(`Mission control: ${missionControlUrl()}`, "info");
556
+ return;
557
+ }
558
+ if (trimmed === "name") {
559
+ orchestratorName = pi.getSessionName()?.trim() || orchestratorName;
560
+ say(`Agent name: ${orchestratorName}`, "info");
561
+ return;
562
+ }
563
+ if (trimmed.startsWith("name ")) {
564
+ const nextName = trimmed.slice("name ".length).trim();
565
+ if (!nextName) {
566
+ say("Usage: /gang name <name>", "warning");
567
+ return;
568
+ }
569
+ orchestratorName = nextName;
570
+ pi.setSessionName(nextName);
571
+ say(`Agent name set: ${nextName}`, "info");
572
+ return;
573
+ }
574
+ if (trimmed === "" || trimmed === "list") {
575
+ say(await formatRoster(), "info");
576
+ return;
577
+ }
578
+ if (trimmed === "clean" || trimmed === "clean all" || trimmed === "clean --force") {
579
+ say(await cleanGang({ all: trimmed === "clean all", force: trimmed === "clean --force" }), "info");
580
+ return;
581
+ }
582
+ if (trimmed === "stop") {
583
+ say("Usage: /gang stop <member> or /gang stop --all-finished", "warning");
584
+ return;
585
+ }
586
+ if (trimmed === "stop --all-finished") {
587
+ say(await stopFinishedMembers(), "info");
588
+ return;
589
+ }
590
+ if (trimmed.startsWith("stop ")) {
591
+ say(await stopMember(trimmed.slice("stop ".length).trim()), "info");
592
+ return;
593
+ }
594
+ if (trimmed.startsWith("spawn")) {
595
+ const parsed = parseSpawnCommand(trimmed.slice("spawn".length));
596
+ if (!parsed.ok) {
597
+ say(parsed.error, "warning");
598
+ return;
599
+ }
600
+ try {
601
+ orchestratorName = pi.getSessionName()?.trim() || orchestratorName;
602
+ const member = await spawnMember(parsed.task, ctx.cwd ?? process.cwd(), { name: parsed.name, thinkingLevel: parsed.thinkingLevel });
603
+ const thinking = member.thinkingLevel ? ` (${member.thinkingLevel} thinking)` : "";
604
+ say(`Launched "${member.role}"${thinking} in pane ${member.paneId}. ${watchHint()}`, "info");
605
+ } catch (error) {
606
+ say(`gang spawn failed: ${getErrorMessage(error)}`, "error");
607
+ }
608
+ return;
609
+ }
610
+ say(`Unknown gang command "${trimmed}". Use /gang, /gang list, /gang clean [all|--force], /gang stop <member>, /gang stop --all-finished, /gang url, /gang name <name>, /gang watch, or /gang spawn [@name] [-t <level>] <task>.`, "warning");
611
+ },
612
+ });
613
+
614
+ pi.registerShortcut("alt+g", {
615
+ description: "Open gang mission control",
616
+ handler: async (ctx) => openMissionControl(ctx),
617
+ });
618
+ }