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,124 @@
1
+ import { randomUUID } from "crypto";
2
+ import type { PaneDetails } from "./tmux.ts";
3
+
4
+ /** The superintendent every member reports to. */
5
+ export const ORCHESTRATOR = "superintendent";
6
+
7
+ /** A spawned gang member. */
8
+ export interface Member {
9
+ role: string;
10
+ task: string;
11
+ index: number;
12
+ paneId: string;
13
+ runId: string;
14
+ spawnedAt: number;
15
+ thinkingLevel?: string;
16
+ reportedDoneAt?: number;
17
+ lastReportText?: string;
18
+ }
19
+
20
+ export interface MemberRuntimeSnapshot {
21
+ member: Member;
22
+ paneExists: boolean;
23
+ paneDead: boolean;
24
+ processAlive: boolean;
25
+ currentCommand?: string;
26
+ reportedDone: boolean;
27
+ state: "running" | "reported_done" | "pane_dead" | "pane_missing";
28
+ reapable: boolean;
29
+ }
30
+
31
+ export function computeMemberRuntimeSnapshot(member: Member, pane?: PaneDetails): MemberRuntimeSnapshot {
32
+ const paneExists = !!pane;
33
+ const paneDead = pane?.dead ?? false;
34
+ const reportedDone = typeof member.reportedDoneAt === "number";
35
+ const state = !paneExists
36
+ ? "pane_missing"
37
+ : paneDead
38
+ ? "pane_dead"
39
+ : reportedDone
40
+ ? "reported_done"
41
+ : "running";
42
+ return {
43
+ member,
44
+ paneExists,
45
+ paneDead,
46
+ processAlive: paneExists && !paneDead,
47
+ currentCommand: pane?.currentCommand,
48
+ reportedDone,
49
+ state,
50
+ reapable: state !== "running",
51
+ };
52
+ }
53
+
54
+ /**
55
+ * The 5 env vars pi-intercom reads at child startup (index.ts readChildOrchestratorMetadata):
56
+ * presence name comes from pi's --name flag, but these unlock the contact_supervisor tool and
57
+ * tell the member who its supervisor is.
58
+ */
59
+ export function buildMemberEnv(opts: { role: string; runId: string; index: number; orchestrator?: string }): Record<string, string> {
60
+ return {
61
+ PI_SUBAGENT_INTERCOM_SESSION_NAME: opts.role,
62
+ PI_SUBAGENT_ORCHESTRATOR_TARGET: opts.orchestrator ?? ORCHESTRATOR,
63
+ PI_SUBAGENT_RUN_ID: opts.runId,
64
+ PI_SUBAGENT_CHILD_AGENT: opts.role,
65
+ PI_SUBAGENT_CHILD_INDEX: String(opts.index),
66
+ };
67
+ }
68
+
69
+ /** A role must be a safe token: valid tmux session name, shell-safe, addressable on the bus. */
70
+ export function isValidRole(role: string): boolean {
71
+ return /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/.test(role);
72
+ }
73
+
74
+ /** Tracks the members one superintendent session has spawned. One runId per superintendent process. */
75
+ export class Roster {
76
+ readonly runId = randomUUID();
77
+ private members: Member[] = [];
78
+ private counter = 0;
79
+
80
+ nextIndex(): number {
81
+ return this.counter++;
82
+ }
83
+
84
+ add(member: Member): void {
85
+ this.members.push(member);
86
+ }
87
+
88
+ hasRole(role: string): boolean {
89
+ return this.members.some((member) => member.role === role);
90
+ }
91
+
92
+ findByRole(role: string): Member | undefined {
93
+ return this.members.find((member) => member.role === role);
94
+ }
95
+
96
+ markReportedDone(role: string, reportedDoneAt = Date.now(), text?: string): boolean {
97
+ const member = this.findByRole(role);
98
+ if (!member) return false;
99
+ member.reportedDoneAt = reportedDoneAt;
100
+ if (typeof text === "string" && text.trim()) member.lastReportText = text.trim();
101
+ return true;
102
+ }
103
+
104
+ list(): Member[] {
105
+ return [...this.members];
106
+ }
107
+
108
+ removeByRoles(roles: Set<string>): number {
109
+ const before = this.members.length;
110
+ this.members = this.members.filter((member) => !roles.has(member.role));
111
+ return before - this.members.length;
112
+ }
113
+
114
+ /** Drop members whose pane is gone/dead; returns how many were removed. */
115
+ prune(alivePaneIds: Set<string>): number {
116
+ const before = this.members.length;
117
+ this.members = this.members.filter((m) => alivePaneIds.has(m.paneId));
118
+ return before - this.members.length;
119
+ }
120
+
121
+ clear(): void {
122
+ this.members = [];
123
+ }
124
+ }
@@ -0,0 +1,151 @@
1
+ import { existsSync } from "fs";
2
+
3
+ /** The dedicated tmux session that hosts all members. Watch with: tmux attach -t gang */
4
+ export const GANG_SESSION = "gang";
5
+
6
+ /** Prefer Homebrew tmux (the user's), fall back to PATH. Override with GANG_TMUX_BIN. */
7
+ export const TMUX_BIN =
8
+ process.env.GANG_TMUX_BIN ||
9
+ (existsSync("/opt/homebrew/bin/tmux") ? "/opt/homebrew/bin/tmux" : "tmux");
10
+
11
+ /** `-e KEY=VAL` flags that set the spawned pane's environment. */
12
+ export function envFlags(env: Record<string, string>): string[] {
13
+ return Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
14
+ }
15
+
16
+ export function hasSessionArgs(session = GANG_SESSION): string[] {
17
+ return ["has-session", "-t", session];
18
+ }
19
+
20
+ export function newSessionArgs(session = GANG_SESSION): string[] {
21
+ return ["new-session", "-d", "-s", session, "-x", "220", "-y", "50"];
22
+ }
23
+
24
+ /**
25
+ * Split a new pane and print its pane id (`-P -F '#{pane_id}'`). `target` is what to split: a session
26
+ * (e.g. `gang`) or a specific pane id (e.g. pi's own `$TMUX_PANE`, to split alongside the caller).
27
+ * Without a target, tmux splits whatever window is *currently active* — so always pass one to be
28
+ * deterministic. `detached` adds `-d` so the new pane doesn't steal focus (keeps you driving pi).
29
+ */
30
+ export function splitArgs(opts: { target?: string; cwd: string; env: Record<string, string>; command: string; detached?: boolean }): string[] {
31
+ const target = opts.target ? ["-t", opts.target] : [];
32
+ return [
33
+ "split-window",
34
+ ...target,
35
+ ...(opts.detached ? ["-d"] : []),
36
+ "-P",
37
+ "-F",
38
+ "#{pane_id}",
39
+ "-c",
40
+ opts.cwd,
41
+ ...envFlags(opts.env),
42
+ opts.command,
43
+ ];
44
+ }
45
+
46
+ /** Keep a member's pane visible after its pi process exits (so you can read the final state). */
47
+ export function remainOnExitArgs(paneId: string): string[] {
48
+ return ["set-option", "-p", "-t", paneId, "remain-on-exit", "on"];
49
+ }
50
+
51
+ /** Re-tile so every member pane stays visible as the gang grows. Target a session or a pane's window. */
52
+ export function tiledLayoutArgs(target: string = GANG_SESSION): string[] {
53
+ return ["select-layout", "-t", target, "tiled"];
54
+ }
55
+
56
+ /** Focus a member's pane (Phase 5 GUI click-to-focus). */
57
+ export function selectPaneArgs(paneId: string): string[] {
58
+ return ["select-pane", "-t", paneId];
59
+ }
60
+
61
+ /** List every pane in the session with its dead flag: lines of `#{pane_id} #{pane_dead}`. */
62
+ export function listPanesArgs(session = GANG_SESSION): string[] {
63
+ return ["list-panes", "-t", session, "-F", "#{pane_id} #{pane_dead}"];
64
+ }
65
+
66
+ /** List every pane in the session with lightweight live diagnostics. */
67
+ export function listDetailedPanesArgs(session = GANG_SESSION): string[] {
68
+ return ["list-panes", "-t", session, "-F", "#{pane_id}\t#{pane_dead}\t#{pane_current_command}\t#{pane_pid}"];
69
+ }
70
+
71
+ /** Server-wide pane list (`-a`) — for in-tmux mode, where members live in the user's own windows. */
72
+ export function listAllPanesArgs(): string[] {
73
+ return ["list-panes", "-a", "-F", "#{pane_id} #{pane_dead}"];
74
+ }
75
+
76
+ /** Server-wide pane list with lightweight live diagnostics. */
77
+ export function listAllDetailedPanesArgs(): string[] {
78
+ return ["list-panes", "-a", "-F", "#{pane_id}\t#{pane_dead}\t#{pane_current_command}\t#{pane_pid}"];
79
+ }
80
+
81
+ /** Reap one finished pane (a member whose pi process exited but remain-on-exit kept it). */
82
+ export function killPaneArgs(paneId: string): string[] {
83
+ return ["kill-pane", "-t", paneId];
84
+ }
85
+
86
+ /** Nuke the whole gang session — stops every member, running or not. */
87
+ export function killSessionArgs(session = GANG_SESSION): string[] {
88
+ return ["kill-session", "-t", session];
89
+ }
90
+
91
+ export interface PaneState {
92
+ paneId: string;
93
+ dead: boolean;
94
+ }
95
+
96
+ export interface PaneDetails extends PaneState {
97
+ currentCommand?: string;
98
+ pid?: number;
99
+ }
100
+
101
+ /** Parse `list-panes -F '#{pane_id} #{pane_dead}'` output. `pane_dead` is 1 for a finished pane. */
102
+ export function parsePaneList(stdout: string): PaneState[] {
103
+ return stdout
104
+ .split("\n")
105
+ .map((line) => line.trim())
106
+ .filter(Boolean)
107
+ .map((line) => {
108
+ const [paneId, dead] = line.split(/\s+/);
109
+ return { paneId, dead: dead === "1" };
110
+ });
111
+ }
112
+
113
+ /** Parse tab-separated `list-panes` diagnostics. */
114
+ export function parseDetailedPaneList(stdout: string): PaneDetails[] {
115
+ return stdout
116
+ .split("\n")
117
+ .map((line) => line.trim())
118
+ .filter(Boolean)
119
+ .map((line) => {
120
+ const [paneId, dead, currentCommand, pid] = line.split("\t");
121
+ return {
122
+ paneId,
123
+ dead: dead === "1",
124
+ currentCommand: currentCommand || undefined,
125
+ pid: pid ? Number(pid) : undefined,
126
+ };
127
+ });
128
+ }
129
+
130
+ function shellQuote(value: string): string {
131
+ return `'${value.replace(/'/g, `'\\''`)}'`;
132
+ }
133
+
134
+ /**
135
+ * The shell-command tmux runs in the member's pane. Task is passed via @file (no shell quoting of
136
+ * task text). --no-extensions + explicit -e load exactly intercom+gang, regardless of install state.
137
+ */
138
+ export function memberCommand(opts: { role: string; taskFile: string; intercomIndex: string; gangIndex: string; thinkingLevel?: string }): string {
139
+ return [
140
+ "pi",
141
+ "--name",
142
+ opts.role,
143
+ ...(opts.thinkingLevel ? ["--thinking", opts.thinkingLevel] : []),
144
+ "--no-extensions",
145
+ "-e",
146
+ shellQuote(opts.intercomIndex),
147
+ "-e",
148
+ shellQuote(opts.gangIndex),
149
+ `@${shellQuote(opts.taskFile)}`,
150
+ ].join(" ");
151
+ }
@@ -0,0 +1,234 @@
1
+ import type { Component, TUI } from "@earendil-works/pi-tui";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
4
+ import type { SessionInfo } from "../../intercom/types.js";
5
+ import type { FeedClient, FeedEvent } from "../feed-client.js";
6
+
7
+ export interface FeedItem {
8
+ ts: number;
9
+ from: string;
10
+ to: string;
11
+ text: string;
12
+ expectsReply?: boolean;
13
+ }
14
+
15
+ export interface MissionState {
16
+ members: SessionInfo[];
17
+ feed: FeedItem[];
18
+ online: boolean;
19
+ /** Current time for uptime/idle math; defaults to Date.now() when omitted (keeps tests deterministic). */
20
+ now?: number;
21
+ /** role → task, joined from the superintendent's Roster so each member shows what it's doing. */
22
+ tasks?: Record<string, string>;
23
+ }
24
+
25
+ /** Minimal theme surface so render() is unit-testable with a plain stub. */
26
+ interface RenderTheme {
27
+ fg(name: string, text: string): string;
28
+ bold(text: string): string;
29
+ }
30
+
31
+ function statusGlyph(status: string): string {
32
+ if (status.startsWith("tool:")) return "◆";
33
+ if (status.startsWith("thinking")) return "◐";
34
+ if (status.startsWith("idle")) return "●";
35
+ return "·";
36
+ }
37
+
38
+ function statusColor(status: string): string {
39
+ if (status.startsWith("tool:")) return "accent";
40
+ if (status.startsWith("thinking")) return "warning";
41
+ if (status.startsWith("idle")) return "success";
42
+ return "muted";
43
+ }
44
+
45
+ function pad(text: string, width: number): string {
46
+ const gap = width - visibleWidth(text);
47
+ return gap > 0 ? text + " ".repeat(gap) : text;
48
+ }
49
+
50
+ function hhmm(ts: number): string {
51
+ const d = new Date(ts);
52
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
53
+ }
54
+
55
+ /** Compact duration: 3s / 12m / 2h / 1d. */
56
+ function fmtDur(ms: number): string {
57
+ const s = Math.floor(ms / 1000);
58
+ if (s < 60) return `${s}s`;
59
+ const m = Math.floor(s / 60);
60
+ if (m < 60) return `${m}m`;
61
+ const h = Math.floor(m / 60);
62
+ if (h < 24) return `${h}h`;
63
+ return `${Math.floor(h / 24)}d`;
64
+ }
65
+
66
+ /** Count members by status category for the header histogram. */
67
+ function statusHistogram(members: SessionInfo[], theme: RenderTheme): string {
68
+ let tool = 0, thinking = 0, idle = 0, other = 0;
69
+ for (const m of members) {
70
+ const s = m.status || "";
71
+ if (s.startsWith("tool:")) tool++;
72
+ else if (s.startsWith("thinking")) thinking++;
73
+ else if (s.startsWith("idle")) idle++;
74
+ else other++;
75
+ }
76
+ const chip = (n: number, glyph: string, color: string) => (n ? theme.fg(color, `${glyph}${n}`) : "");
77
+ return [
78
+ chip(tool, "◆", "accent"),
79
+ chip(thinking, "◐", "warning"),
80
+ chip(idle, "●", "success"),
81
+ chip(other, "·", "muted"),
82
+ ].filter(Boolean).join(" ");
83
+ }
84
+
85
+ /** Pure renderer: state + theme + width → terminal lines. No I/O, fully testable. */
86
+ export function renderMissionControl(state: MissionState, theme: RenderTheme, width: number): string[] {
87
+ const inner = Math.max(44, Math.min(width - 2, 104));
88
+ const content = inner - 4; // 1 border + 1 space padding each side
89
+ const border = (s: string) => theme.fg("accent", s);
90
+ const lines: string[] = [];
91
+
92
+ const row = (text = "") => {
93
+ const clipped = truncateToWidth(text, content, "", true);
94
+ return `${border("│")} ${clipped}${" ".repeat(Math.max(0, content - visibleWidth(clipped)))} ${border("│")}`;
95
+ };
96
+
97
+ // Title bar with right-aligned connection state.
98
+ const title = theme.bold("gang") + theme.fg("muted", " · mission control");
99
+ const live = state.online ? theme.fg("success", "● live") : theme.fg("muted", "○ offline");
100
+ const titleGap = Math.max(1, content - visibleWidth(title) - visibleWidth(live));
101
+ lines.push(border(`╭${"─".repeat(inner - 2)}╮`));
102
+ lines.push(row(`${title}${" ".repeat(titleGap)}${live}`));
103
+ lines.push(border(`├${"─".repeat(inner - 2)}┤`));
104
+
105
+ // Members. Header carries a status histogram (◆tool ◐think ●idle) right-aligned.
106
+ const now = state.now ?? Date.now();
107
+ const tasks = state.tasks ?? {};
108
+ const head = theme.fg("muted", `MEMBERS · ${state.members.length}`);
109
+ const hist = statusHistogram(state.members, theme);
110
+ const headGap = Math.max(2, content - visibleWidth(head) - visibleWidth(hist));
111
+ lines.push(row(hist ? `${head}${" ".repeat(headGap)}${hist}` : head));
112
+ if (state.members.length === 0) {
113
+ lines.push(row(theme.fg("dim", " no members yet — gang spawn <role> <task>")));
114
+ } else {
115
+ const nameWidth = Math.min(18, Math.max(6, ...state.members.map((m) => visibleWidth(m.name || m.id.slice(0, 8)))));
116
+ const statusWidth = Math.min(16, Math.max(4, ...state.members.map((m) => visibleWidth(m.status || "—"))));
117
+ for (const m of state.members) {
118
+ const status = m.status || "—";
119
+ const name = m.name || m.id.slice(0, 8);
120
+ const color = statusColor(status);
121
+ const glyph = theme.fg(color, statusGlyph(status));
122
+ const shown = status.length > statusWidth ? status.slice(0, statusWidth - 1) + "…" : status;
123
+ const label = theme.fg(color, pad(shown, statusWidth));
124
+ const up = m.startedAt > 0 ? fmtDur(now - m.startedAt) : "";
125
+ const idle = m.lastActivity > 0 ? `·${fmtDur(now - m.lastActivity)}` : "";
126
+ const metrics = theme.fg("dim", pad(`${up} ${idle}`.trim(), 9));
127
+ const taskText = tasks[name] ? theme.fg("dim", tasks[name].replace(/\s+/g, " ")) : "";
128
+ lines.push(row(`${glyph} ${theme.bold(pad(name, nameWidth))} ${label} ${metrics} ${taskText}`));
129
+ }
130
+ }
131
+ lines.push(row());
132
+
133
+ // Feed (newest first).
134
+ lines.push(row(theme.fg("muted", "FEED")));
135
+ if (state.feed.length === 0) {
136
+ lines.push(row(theme.fg("dim", " waiting for messages…")));
137
+ } else {
138
+ const routeWidth = Math.min(
139
+ 26,
140
+ Math.max(...state.feed.map((f) => visibleWidth(`${f.from} → ${f.to}`))),
141
+ );
142
+ for (const f of state.feed.slice(0, 12)) {
143
+ const route = `${theme.fg("accent", f.from)} ${theme.fg("dim", "→")} ${theme.fg("warning", f.to)}`;
144
+ const routePadded = route + " ".repeat(Math.max(0, routeWidth - visibleWidth(`${f.from} → ${f.to}`)));
145
+ const flag = f.expectsReply ? theme.fg("warning", " (awaiting reply)") : "";
146
+ const time = theme.fg("dim", hhmm(f.ts));
147
+ const text = f.text.replace(/\s+/g, " ");
148
+ lines.push(row(`${time} ${routePadded} ${theme.fg("text", text)}${flag}`));
149
+ }
150
+ }
151
+
152
+ lines.push(border(`├${"─".repeat(inner - 2)}┤`));
153
+ lines.push(row(theme.fg("dim", "esc close")));
154
+ lines.push(border(`╰${"─".repeat(inner - 2)}╯`));
155
+ return lines;
156
+ }
157
+
158
+ export class MissionControlOverlay implements Component {
159
+ private members = new Map<string, SessionInfo>();
160
+ private feed: FeedItem[] = [];
161
+ private online = false;
162
+
163
+ constructor(
164
+ private readonly tui: TUI,
165
+ private readonly theme: Theme,
166
+ private readonly keybindings: KeybindingsManager,
167
+ feed: FeedClient,
168
+ private readonly done: () => void,
169
+ private readonly getTasks: () => Record<string, string> = () => ({}),
170
+ ) {
171
+ feed.on("status", (s: string) => {
172
+ this.online = s === "up";
173
+ this.tui.requestRender();
174
+ });
175
+ feed.on("event", (ev: FeedEvent) => {
176
+ this.apply(ev);
177
+ this.tui.requestRender();
178
+ });
179
+ }
180
+
181
+ private apply(ev: FeedEvent): void {
182
+ switch (ev.type) {
183
+ case "snapshot":
184
+ this.members.clear();
185
+ for (const s of (ev.sessions as SessionInfo[]) ?? []) this.members.set(s.id, s);
186
+ // Buffer is oldest→newest; overlay renders feed[0] as newest, so reverse.
187
+ this.feed = ((ev.feed as Array<Record<string, unknown>>) ?? [])
188
+ .map((m) => ({
189
+ ts: (m.ts as number) ?? Date.now(),
190
+ from: m.from as string,
191
+ to: m.to as string,
192
+ text: (m.text as string) ?? "",
193
+ expectsReply: m.expectsReply as boolean | undefined,
194
+ }))
195
+ .reverse();
196
+ break;
197
+ case "session_joined":
198
+ case "presence_update": {
199
+ const s = ev.session as SessionInfo;
200
+ if (s) this.members.set(s.id, s);
201
+ break;
202
+ }
203
+ case "session_left":
204
+ this.members.delete(ev.sessionId as string);
205
+ break;
206
+ case "message":
207
+ this.feed.unshift({
208
+ ts: (ev.ts as number) ?? Date.now(),
209
+ from: ev.from as string,
210
+ to: ev.to as string,
211
+ text: (ev.text as string) ?? "",
212
+ expectsReply: ev.expectsReply as boolean | undefined,
213
+ });
214
+ this.feed = this.feed.slice(0, 50);
215
+ break;
216
+ }
217
+ }
218
+
219
+ invalidate(): void {}
220
+
221
+ handleInput(data: string): void {
222
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
223
+ this.done();
224
+ }
225
+ }
226
+
227
+ render(width: number): string[] {
228
+ return renderMissionControl(
229
+ { members: [...this.members.values()], feed: this.feed, online: this.online, now: Date.now(), tasks: this.getTasks() },
230
+ this.theme,
231
+ width,
232
+ );
233
+ }
234
+ }