@fradser/pi-agent-teams 0.5.3 → 0.7.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.
package/src/ui.ts CHANGED
@@ -1,69 +1,50 @@
1
- /** Passive teammate widget and interactive full-screen console. */
1
+ /** Passive team widget and the /agent-teams management console
2
+ * (session teammates + persistent agent roles + board). */
2
3
 
3
4
  import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
4
5
  import { createPiThemeStyle, PI_SPINNER_FRAMES, PI_SPINNER_INTERVAL_MS } from "@fradser/pi-kit";
5
6
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
6
- import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
7
7
  import {
8
8
  clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
9
- } from "./console-viewport";
10
- import { getNodeByWorkerKey, getState, listNodes } from "./state";
11
- import { cancelNodeAndTerminate, ensureLivePoll } from "./run-machine";
12
- import type { Node } from "./types";
13
-
14
- function cap(text: string | undefined, maxBytes = DEFAULT_MAX_BYTES): string {
15
- if (!text) return "";
16
- if (text.length <= maxBytes) return text;
17
- const t = truncateTail(text, { maxLines: DEFAULT_MAX_LINES, maxBytes });
18
- return `${t.content}\n…[truncated ${text.length - t.content.length} chars]`;
19
- }
20
-
21
- // ── Team UI: passive widget + full-screen console ──────────────────
22
- // The widget below the editor is DISPLAY-ONLY. ALL interaction happens in the
23
- // full-screen Team Console (/teammate), which owns input via ctx.ui.custom.
9
+ } from "./console-viewport.ts";
10
+ import { fitTeammateRow, formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
11
+ import { discoverAgents, resolveAgent, type AgentDefinition } from "./agents.ts";
12
+ import { getState, getTeammate, listTasks, listTeammates, livingTeammates } from "./state.ts";
13
+ import {
14
+ ensureLivePoll,
15
+ formatSilenceDuration,
16
+ runtimeDirPath,
17
+ shutdownTeammate,
18
+ STALL_NOTICE_MS,
19
+ stallSilenceMs,
20
+ } from "./team-machine.ts";
21
+ import type { Teammate } from "./types.ts";
22
+ import { inboxPath } from "./statefile.ts";
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
24
25
 
25
26
  const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
26
27
  let spinnerTimer: ReturnType<typeof setInterval> | undefined;
27
28
  let spinnerFrame = 0;
28
29
 
29
- /** Stable per-node color (independent of row order). */
30
30
  function hashName(name: string): number {
31
31
  let h = 0;
32
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
32
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(0) + i * 7) | 0;
33
33
  return Math.abs(h);
34
34
  }
35
35
 
36
- interface PanelRow {
37
- key: string;
38
- }
39
-
40
- /** Console/widget rows contain node status and leader-report access. */
41
- function buildPanelRows(): PanelRow[] {
42
- return listNodes().map((node) => ({ key: node.workerKey }));
43
- }
44
-
45
- function runningTeammateLabel(node: Node): string {
46
- const frame = PI_SPINNER_FRAMES[spinnerFrame];
47
- const tool = node.spawn?.activeTool;
48
- const thinking = (node.spawn?.liveThinking ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
49
- const live = (node.spawn?.liveText ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
50
- // Like the memory package's activity line: show what the worker is doing
51
- // right now — current tool when one is executing, else its live reasoning,
52
- // else the latest assistant text.
53
- const activity = tool
54
- ?? (thinking ? truncateToWidth(thinking, 48) : undefined)
55
- ?? (live ? truncateToWidth(live, 48) : "Working...");
56
- return `${frame} ${activity}`;
36
+ function colorFor(name: string): (typeof TEAM_COLORS)[number] {
37
+ return TEAM_COLORS[hashName(name) % TEAM_COLORS.length];
57
38
  }
58
39
 
59
40
  function ensureSpinner(): void {
60
- const running = listNodes().some((node) => node.status === "running");
61
- if (running && !spinnerTimer) {
41
+ const working = livingTeammates().some(isWorking);
42
+ if (working && !spinnerTimer) {
62
43
  spinnerTimer = setInterval(() => {
63
44
  spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
64
45
  }, PI_SPINNER_INTERVAL_MS);
65
46
  spinnerTimer.unref?.();
66
- } else if (!running && spinnerTimer) {
47
+ } else if (!working && spinnerTimer) {
67
48
  clearInterval(spinnerTimer);
68
49
  spinnerTimer = undefined;
69
50
  }
@@ -80,8 +61,7 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
80
61
  if (!ctx?.ui?.setWidget) return;
81
62
  if (ctx.mode && ctx.mode !== "tui") return;
82
63
 
83
- const running = listNodes().filter((node) => node.status === "running");
84
- if (running.length === 0) {
64
+ if (livingTeammates().filter(isWorking).length === 0) {
85
65
  ctx.ui.setWidget("teammate", undefined);
86
66
  return;
87
67
  }
@@ -90,37 +70,22 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
90
70
  const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
91
71
  timer.unref?.();
92
72
  const style = createPiThemeStyle(theme);
93
- const assignedColors = new Map<string, (typeof TEAM_COLORS)[number]>();
94
73
  return {
95
74
  placement: "belowEditor",
96
75
  render: (width: number) => {
97
- const running = listNodes().filter((n) => n.status === "running");
98
- if (running.length === 0) return [];
99
- const runningKeys = new Set(running.map((node) => node.workerKey));
100
- for (const key of assignedColors.keys()) {
101
- if (!runningKeys.has(key)) assignedColors.delete(key);
102
- }
103
- const usedColors = new Set(assignedColors.values());
104
- for (const node of running) {
105
- if (!assignedColors.has(node.workerKey)) {
106
- const start = hashName(node.workerKey) % TEAM_COLORS.length;
107
- const color = TEAM_COLORS.find((_, offset) => !usedColors.has(TEAM_COLORS[(start + offset) % TEAM_COLORS.length]));
108
- assignedColors.set(node.workerKey, color ?? TEAM_COLORS[start]);
109
- usedColors.add(color ?? TEAM_COLORS[start]);
110
- }
111
- }
76
+ // Only WORKING teammates appear above the input box; idle and
77
+ // stopped teammates stay in the /agent-teams console instead.
78
+ const working = livingTeammates().filter(isWorking);
79
+ if (working.length === 0) return [];
112
80
  const lines: string[] = [];
113
- for (const node of running) {
114
- const label = runningTeammateLabel(node);
115
- const color = assignedColors.get(node.workerKey) ?? TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
116
- const name = style.fg(color, node.id);
117
- const role = style.dim(`(${node.agent})`);
118
- const separator = label.indexOf(" ");
119
- const spinner = separator === -1 ? label : label.slice(0, separator);
120
- const activityText = separator === -1 ? "Working..." : label.slice(separator + 1).trim();
121
- const activity = theme.bold(style.fg("accent", activityText));
122
- const line = ` ${spinner} ${name} ${role} · ${activity}`;
123
- lines.push(truncateToWidth(line, Math.max(10, width - 1)));
81
+ for (const teammate of working) {
82
+ lines.push(fitTeammateRow(
83
+ PI_SPINNER_FRAMES[spinnerFrame],
84
+ style.fg(colorFor(teammate.name), teammate.name),
85
+ runningTeammateActivity(teammate) + stallSuffix(teammate),
86
+ width,
87
+ (activity) => theme.bold(style.fg("accent", activity)),
88
+ ));
124
89
  }
125
90
  return lines;
126
91
  },
@@ -130,104 +95,241 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
130
95
  });
131
96
  }
132
97
 
98
+ function isWorking(teammate: { status: string }): boolean {
99
+ return teammate.status === "working" || teammate.status === "starting";
100
+ }
101
+
102
+ /** Silence marker appended once a working teammate passes the stall notice threshold. */
103
+ function stallSuffix(teammate: Teammate): string {
104
+ if (!isWorking(teammate)) return "";
105
+ const silence = stallSilenceMs(teammate);
106
+ return silence !== undefined && STALL_NOTICE_MS > 0 && silence >= STALL_NOTICE_MS
107
+ ? ` · stalled for ${formatSilenceDuration(silence)}`
108
+ : "";
109
+ }
110
+
133
111
  export function refreshTeamUI(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
134
112
  ensureTeamWidget(ctx);
135
113
  ensureLivePoll();
136
114
  ensureSpinner();
137
115
  }
138
116
 
117
+ function cap(text: string | undefined, maxBytes = 2000): string {
118
+ if (!text) return "";
119
+ return text.length <= maxBytes ? text : `${text.slice(0, maxBytes)}\n…[truncated ${text.length - maxBytes} chars]`;
120
+ }
121
+
122
+ // ── Detail builders ───────────────────────────────────────────────
139
123
 
140
- /** Full content of a node as shown on its detail page: spawn lifecycle, live
141
- * worker text, captured output, result and error. */
142
- function buildNodeSection(node: Node): string[] {
143
- const lines: string[] = [`- [${node.id}] ${node.status}: ${node.agent}`];
144
- if (node.prompt) lines.push(` Task: ${cap(node.prompt, 2000)}`);
145
- lines.push(` Access: ${node.access} | Paths: ${node.paths.join(", ")}`);
146
- if (node.dependsOn.length > 0) lines.push(` Depends on: ${node.dependsOn.join(", ")}`);
147
-
148
- const spawn = node.spawn;
149
- if (spawn) {
150
- const stateLabel = spawn.status === "running" ? `running (pid ${spawn.pid})` : spawn.status;
151
- lines.push(` Spawn: ${stateLabel} | Isolation: ${spawn.isolation ?? "none"}`);
152
- if (spawn.startedAt) lines.push(` Started: ${new Date(spawn.startedAt).toLocaleString()}`);
153
- if (spawn.finishedAt) lines.push(` Finished: ${new Date(spawn.finishedAt).toLocaleString()}`);
154
- if (spawn.exitCode !== undefined) lines.push(` Exit code: ${spawn.exitCode}`);
155
- if (spawn.timedOut) lines.push(" Timed out: yes");
156
- if (spawn.usage) {
157
- const u = spawn.usage;
158
- lines.push(` Usage: ${u.totalTokens} tokens (in ${u.input} / out ${u.output}) | cost $${u.cost}`);
159
- }
160
- if (spawn.turns) lines.push(` Worker turns: ${spawn.turns}`);
161
- if (spawn.activeTool) lines.push(` Current tool: ${spawn.activeTool}`);
162
- if (spawn.error) lines.push(` Spawn error: ${spawn.error}`);
163
- if (spawn.status === "running") {
164
- lines.push(" --- Live worker activity ---");
165
- const activity = [
166
- ...(spawn.liveThinking?.trim() ? spawn.liveThinking.split("\n").map((l) => ` ${l}`) : []),
167
- ...(spawn.liveText?.trim() ? spawn.liveText.split("\n").map((l) => ` ${l}`) : []),
168
- ];
169
- lines.push(...(activity.length > 0 ? activity : [" Waiting for the worker's first response…"]));
124
+ interface PeerMailLine {
125
+ direction: "sent" | "received";
126
+ counterpart: string;
127
+ subject: string;
128
+ body: string;
129
+ timestamp: number;
130
+ }
131
+
132
+ function readPeerMail(teammateName: string): PeerMailLine[] {
133
+ const runtimeDir = runtimeDirPath();
134
+ if (!runtimeDir) return [];
135
+ const mailDirPath = path.dirname(inboxPath(path.join(runtimeDir, "state.json"), teammateName));
136
+ let entries: string[] = [];
137
+ try {
138
+ entries = fs.readdirSync(mailDirPath).filter((name) => name.startsWith("inbox-") && name.endsWith(".jsonl"));
139
+ } catch {
140
+ return [];
141
+ }
142
+ const lines: PeerMailLine[] = [];
143
+ for (const entry of entries) {
144
+ const recipient = decodeURIComponent(entry.slice("inbox-".length, -".jsonl".length));
145
+ let raw = "";
146
+ try {
147
+ raw = fs.readFileSync(path.join(mailDirPath, entry), "utf-8");
148
+ } catch {
149
+ continue;
170
150
  }
171
- if (spawn.stdout) {
172
- lines.push(" --- Worker output ---");
173
- lines.push(...spawn.stdout.split("\n"));
151
+ for (const line of raw.split("\n")) {
152
+ if (!line.trim()) continue;
153
+ try {
154
+ const message = JSON.parse(line) as { id?: string; from?: string; subject?: string; body?: string; timestamp?: number };
155
+ if (!message.id || !message.from || !message.subject) continue;
156
+ if (message.from !== teammateName && recipient !== teammateName) continue;
157
+ lines.push({
158
+ direction: message.from === teammateName ? "sent" : "received",
159
+ counterpart: message.from === teammateName ? recipient : message.from,
160
+ subject: message.subject,
161
+ body: message.body ?? "",
162
+ timestamp: message.timestamp ?? 0,
163
+ });
164
+ } catch {
165
+ continue;
166
+ }
174
167
  }
175
- if (spawn.stderr) {
176
- lines.push(" --- Worker stderr ---");
177
- lines.push(...spawn.stderr.split("\n"));
168
+ }
169
+ return lines.sort((a, b) => a.timestamp - b.timestamp);
170
+ }
171
+
172
+ function indent(text: string): string[] {
173
+ return text.split("\n").map((line) => ` ${line}`);
174
+ }
175
+
176
+ function displaySource(sourcePath: string): string {
177
+ const relative = path.relative(process.cwd(), sourcePath);
178
+ return relative.startsWith("..") ? sourcePath : `./${relative}`;
179
+ }
180
+
181
+ function buildTeammateDetail(name: string): string[] {
182
+ const teammate = getTeammate(name);
183
+ if (!teammate) return ["(teammate removed from the roster)"];
184
+ const reports = getState().leaderMailbox.filter((message) => message.from === name);
185
+ const lines: string[] = [
186
+ `@${teammate.name} (${teammate.agent}) [${teammate.status}]`,
187
+ "",
188
+ "== teammate ==",
189
+ ` Status: ${teammate.status}${teammate.currentTaskId ? ` | Task: ${teammate.currentTaskId}` : ""}`,
190
+ ` Spawn: ${teammate.pid > 0 ? `pid ${teammate.pid}` : "pid unknown"} | Isolation: ${teammate.isolation}`,
191
+ ` Created: ${new Date(teammate.createdAt).toLocaleString()}`,
192
+ ];
193
+ if (teammate.stoppedAt) lines.push(` Stopped: ${new Date(teammate.stoppedAt).toLocaleString()}`);
194
+ if (isWorking(teammate) && teammate.lastOutputAt !== undefined) {
195
+ const silence = stallSilenceMs(teammate);
196
+ if (silence !== undefined) {
197
+ lines.push(` Last output: ${new Date(teammate.lastOutputAt).toLocaleString()} (${formatSilenceDuration(silence)} ago${stallSuffix(teammate) ? " — stalled" : ""})`);
178
198
  }
179
199
  }
200
+ if (teammate.usage) lines.push(` Usage: ${teammate.usage.totalTokens} tokens | $${teammate.usage.cost.toFixed(4)}`);
201
+ if (teammate.error) lines.push(` Error: ${teammate.error}`);
202
+
203
+ lines.push("", `== reports to leader (${reports.length}) ==`);
204
+ if (reports.length === 0) lines.push(" (none)");
205
+ for (const report of reports) {
206
+ lines.push(` -> [${report.subject}] ${new Date(report.timestamp).toLocaleString()}`, ...indent(cap(report.body)), "");
207
+ }
180
208
 
181
- if (node.result && node.result !== spawn?.stdout) lines.push(` Result: ${node.result}`);
182
- if (node.errorMessage) lines.push(` Error: ${node.errorMessage}`);
209
+ const peer = readPeerMail(name);
210
+ lines.push(`== peer mail (${peer.length}) ==`);
211
+ if (peer.length === 0) lines.push(" (none)");
212
+ for (const mail of peer) {
213
+ const arrow = mail.direction === "sent" ? `to @${mail.counterpart}` : `from @${mail.counterpart}`;
214
+ lines.push(` ${arrow} [${mail.subject}] ${new Date(mail.timestamp).toLocaleString()}`, ...indent(cap(mail.body)), "");
215
+ }
183
216
  return lines;
184
217
  }
185
218
 
186
- /** Node detail: node section + reports this node sent to team-leader. */
187
- function buildNodeDetail(workerKey: string): string[] {
188
- const entry = getNodeByWorkerKey(workerKey);
189
- if (!entry) return ["(removed)"];
190
- const { run, node } = entry;
191
- const outgoing = getState().leaderMailbox.filter((message) => message.from === workerKey);
219
+ function buildTaskDetail(taskId: string): string[] {
220
+ const tasks = listTasks();
221
+ const task = tasks.find((candidate) => candidate.id === taskId);
222
+ if (!task) return ["(task not on the board)"];
223
+ const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
224
+ return [
225
+ `[${task.id}] ${task.subject}`,
226
+ "",
227
+ "== task ==",
228
+ ` Status: ${task.status}${task.claimedBy ? ` (@${task.claimedBy})` : ""}`,
229
+ ...(task.description ? [` Description:`, ...indent(cap(task.description))] : []),
230
+ ...(task.dependsOn.length > 0
231
+ ? [` Depends on: ${task.dependsOn.join(", ")} (${task.dependsOn.every((dep) => byId.get(dep)?.status === "completed") ? "met" : "unmet"})`]
232
+ : []),
233
+ ` Verify: ${task.verify ?? "(none)"}`,
234
+ ...(task.result ? [" Result:", ...indent(cap(task.result))] : []),
235
+ ...(task.errorMessage ? [" Error:", ...indent(cap(task.errorMessage))] : []),
236
+ ` Created: ${new Date(task.createdAt).toLocaleString()}`,
237
+ ...(task.completedAt ? [` Completed: ${new Date(task.completedAt).toLocaleString()}`] : []),
238
+ ];
239
+ }
192
240
 
193
- const lines: string[] = [
194
- `${node.workerKey} (${node.agent}) [${node.status}] — run ${run.status}`,
241
+ function buildRoleDetail(name: string): string[] {
242
+ const def = resolveAgent(name);
243
+ if (!def) return ["(agent definition not found)"];
244
+ return [
245
+ `@${def.name} (${def.scope})${def.gitManaged ? " [git-managed]" : " [local]"}`,
195
246
  "",
196
- "== node ==",
197
- ...buildNodeSection(node),
247
+ "== role ==",
248
+ ` Source: ${displaySource(def.source)}`,
249
+ ` Description: ${def.description || "(none)"}`,
250
+ ` Tools: ${def.tools.join(", ") || "(role defaults)"}`,
251
+ ` Model: ${def.model ?? "(session default)"}`,
252
+ ` Verify: ${def.verify ?? "(none)"}`,
253
+ ` Worktree: ${def.worktree ? "true" : "false"}`,
254
+ ` Living instances: ${livingTeammates().filter((teammate) => teammate.agent === def.name).length}`,
198
255
  "",
199
- `== reports to team-leader (${outgoing.length}) ==`,
200
- ...(outgoing.length === 0
201
- ? ["(none)"]
202
- : outgoing.flatMap((m) => {
203
- const time = new Date(m.timestamp).toLocaleString();
204
- return [
205
- `→ [${m.id}] ${m.subject} | ${time}`,
206
- m.runId ? ` run ${m.runId}` : "",
207
- m.body,
208
- "",
209
- ];
210
- })),
256
+ "== role prompt ==",
257
+ ...indent(cap(def.prompt)),
211
258
  ];
212
- return lines;
213
259
  }
214
260
 
215
- /** Full-screen Team Console — owns input via ctx.ui.custom, so ↑/↓ and Enter
216
- * are safe in here and nothing is intercepted globally. Modes: list / detail. */
261
+ // ── Full-screen console ───────────────────────────────────────────
262
+
263
+ type ConsolePage = "roster" | "board";
264
+
265
+ interface RosterRow {
266
+ key: string;
267
+ kind: "teammate";
268
+ }
269
+ interface RoleRow {
270
+ key: string;
271
+ kind: "role";
272
+ }
273
+ interface BoardRow {
274
+ key: string;
275
+ kind: "task";
276
+ }
277
+ type ConsoleRow = RosterRow | RoleRow | BoardRow;
278
+
279
+ /** SGR mouse-wheel events carry a button code; return signed line delta. */
280
+ function wheelDelta(data: string): number | undefined {
281
+ const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
282
+ if (!match) return undefined;
283
+ const button = Number.parseInt(match[1], 10);
284
+ if ((button & 64) === 0) return undefined;
285
+ const direction = button & 3;
286
+ if (direction === 0) return -1;
287
+ if (direction === 1) return 1;
288
+ return undefined;
289
+ }
290
+
291
+ /** Chrome lines around the list viewport: border, header, spacer, footer, border. */
292
+ const LIST_CHROME_LINES = 5;
293
+
294
+ /** Full-screen Team Console — owns input via ctx.ui.custom. Pages: roster /
295
+ * board; each row opens a scrolling detail view. */
217
296
  export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void> {
218
297
  return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
298
+ let page: ConsolePage = "roster";
219
299
  let mode: "list" | "detail" = "list";
220
- let selected = 0;
300
+ let selectedRoster = 0;
301
+ let selectedBoard = 0;
302
+ let listOffset = 0;
303
+ let detailKind: ConsoleRow["kind"] = "teammate";
221
304
  let detailKey = "";
222
305
  let offset = 0;
306
+ let confirmName: string | undefined;
223
307
  let closed = false;
224
308
  let renderTimer: ReturnType<typeof setInterval> | undefined;
309
+
310
+ // Definitions are reread lazily so mid-session role creation shows up,
311
+ // but never once per animation frame.
312
+ let rolesCache: AgentDefinition[] = [];
313
+ let rolesCacheAt = 0;
314
+ const ROLES_CACHE_TTL_MS = 2000;
315
+ const getRoles = (): AgentDefinition[] => {
316
+ const now = Date.now();
317
+ if (now - rolesCacheAt > ROLES_CACHE_TTL_MS) {
318
+ rolesCache = [...discoverAgents().values()].sort((a, b) => a.name.localeCompare(b.name));
319
+ rolesCacheAt = now;
320
+ }
321
+ return rolesCache;
322
+ };
323
+
225
324
  const requestRender = () => {
226
325
  if (!closed) tui.requestRender();
227
326
  };
228
327
  const startLiveRefresh = () => {
229
328
  if (renderTimer) return;
230
- renderTimer = setInterval(requestRender, PI_SPINNER_INTERVAL_MS);
329
+ renderTimer = setInterval(() => {
330
+ // Redraw only while something can animate (spinner/activity/stall).
331
+ if (livingTeammates().some(isWorking)) requestRender();
332
+ }, PI_SPINNER_INTERVAL_MS);
231
333
  renderTimer.unref?.();
232
334
  };
233
335
  const stopLiveRefresh = () => {
@@ -237,9 +339,18 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
237
339
  };
238
340
  startLiveRefresh();
239
341
 
240
- // btw-style callbacks (same accent/muted/dim/border/success/error language as pi-btw-fradser).
241
342
  const style = createPiThemeStyle(theme);
242
343
 
344
+ const currentRows = (): ConsoleRow[] =>
345
+ page === "roster"
346
+ // The console is the management surface: idle and stopped stay
347
+ // visible, and persistent agent roles are listed after the session.
348
+ ? [
349
+ ...listTeammates().map((t) => ({ key: t.name, kind: "teammate" as const })),
350
+ ...getRoles().map((def) => ({ key: def.name, kind: "role" as const })),
351
+ ]
352
+ : listTasks().map((task) => ({ key: task.id, kind: "task" as const }));
353
+
243
354
  const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
244
355
  const wrapped = wrapConsoleDetail(full, width);
245
356
  const viewport = maxConsoleBody(tui.terminal.rows);
@@ -250,44 +361,130 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
250
361
  };
251
362
  };
252
363
 
364
+ const headerLine = (): string => {
365
+ const alive = livingTeammates();
366
+ const tasks = listTasks();
367
+ return `team ${alive.length} alive · ${alive.filter(isWorking).length} working · ${getRoles().length} roles · board ${tasks.filter((task) => task.status === "pending").length}p/${tasks.filter((task) => task.status === "claimed").length}c/${tasks.filter((task) => task.status === "completed").length}d · ${page}`;
368
+ };
369
+
370
+ interface ContentLine {
371
+ text: string;
372
+ /** Selectable row index, or -1 for section chrome. */
373
+ select: number;
374
+ }
375
+
376
+ const teammateRowText = (key: string, selected: boolean): string => {
377
+ const teammate = getTeammate(key)!;
378
+ const marker = selected ? style.accent("❯ ") : " ";
379
+ const name = theme.bold(theme.fg(colorFor(teammate.name), `@${teammate.name}`));
380
+ if (isWorking(teammate)) {
381
+ const prefix = `${marker}${name} `;
382
+ const width = tui.terminal.columns;
383
+ const available = Math.max(0, width - prefix.length - PI_SPINNER_FRAMES[spinnerFrame].length - 2);
384
+ const status = theme.fg("warning", formatTeammateLabel(PI_SPINNER_FRAMES[spinnerFrame], runningTeammateActivity(teammate) + stallSuffix(teammate), available));
385
+ return `${prefix}${status}`;
386
+ }
387
+ if (teammate.status === "idle") {
388
+ return `${marker}${name} ${style.dim(`○ idle${teammate.currentTaskId ? ` · ${teammate.currentTaskId}` : ""}`)}`;
389
+ }
390
+ return `${marker}${name} ${theme.fg("warning", "■ stopped")}`;
391
+ };
392
+
393
+ const roleRowText = (key: string, selected: boolean): string => {
394
+ const def = getRoles().find((candidate) => candidate.name === key)!;
395
+ const marker = selected ? style.accent("❯ ") : " ";
396
+ const name = theme.bold(theme.fg(colorFor(key), `@${key}`));
397
+ const live = livingTeammates().filter((teammate) => teammate.agent === key && teammate.status !== "stopped").length;
398
+ const liveTag = live > 0 ? style.success(`[${live} live]`) : style.dim("[0 live]");
399
+ return `${marker}${name} ${style.dim(def.scope)} ${style.dim(displaySource(def.source))} ${liveTag}`;
400
+ };
401
+
402
+ const taskRowText = (key: string, selected: boolean): string => {
403
+ const task = listTasks().find((candidate) => candidate.id === key)!;
404
+ const marker = selected ? style.accent("❯ ") : " ";
405
+ const label = theme.fg(colorFor(key), `[${key}]`);
406
+ const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
407
+ const statusText = task.status === "completed"
408
+ ? style.success("✓")
409
+ : task.status === "claimed"
410
+ ? theme.fg("warning", "◐ claimed")
411
+ : style.dim("○ pending");
412
+ return `${marker}${label} ${statusText} ${theme.fg("customMessageText", task.subject)}${holder}`;
413
+ };
414
+
415
+ const buildContent = (): ContentLine[] => {
416
+ const rows = currentRows();
417
+ const lines: ContentLine[] = [];
418
+ if (page === "board") {
419
+ if (rows.length === 0) {
420
+ lines.push({ text: style.dim("The task board is empty. Create tasks with task_create."), select: -1 });
421
+ }
422
+ rows.forEach((row, index) => lines.push({ text: taskRowText(row.key, index === currentPageSelection(rows.length)), select: index }));
423
+ return lines;
424
+ }
425
+ const teammates = rows.filter((row): row is RosterRow => row.kind === "teammate");
426
+ const roles = rows.filter((row): row is RoleRow => row.kind === "role");
427
+ const selection = currentPageSelection(rows.length);
428
+ lines.push({ text: style.dim("== teammates (this session) =="), select: -1 });
429
+ if (teammates.length === 0) {
430
+ lines.push({ text: style.dim("No teammates this session. Ask the model to spawn one."), select: -1 });
431
+ }
432
+ teammates.forEach((row, index) => lines.push({ text: teammateRowText(row.key, index === selection), select: index }));
433
+ lines.push({ text: "", select: -1 });
434
+ lines.push({ text: style.dim("== agent roles (persistent definitions) =="), select: -1 });
435
+ if (roles.length === 0) {
436
+ lines.push({ text: style.dim("No agent definitions discovered."), select: -1 });
437
+ }
438
+ roles.forEach((row, index) => lines.push({ text: roleRowText(row.key, teammates.length + index === selection), select: teammates.length + index }));
439
+ return lines;
440
+ };
441
+
442
+ const listViewport = (): number => Math.max(1, tui.terminal.rows - LIST_CHROME_LINES);
443
+
253
444
  const renderList = (width: number): string[] => {
254
- const rows = buildPanelRows();
255
- if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
256
445
  const border = style.border("─".repeat(Math.max(1, width)));
257
- const lines: string[] = [
446
+ const content = buildContent();
447
+ const viewport = listViewport();
448
+ const selection = currentPageSelection(currentRows().length);
449
+ const selectedIndex = content.findIndex((line) => line.select === selection);
450
+ if (selectedIndex >= 0) {
451
+ if (selectedIndex < listOffset) listOffset = selectedIndex;
452
+ else if (selectedIndex >= listOffset + viewport) listOffset = selectedIndex - viewport + 1;
453
+ }
454
+ listOffset = clampConsoleScroll(listOffset, content.length, viewport);
455
+ const footer = confirmName
456
+ ? theme.fg("warning", `Shut down @${confirmName}? Its claimed task returns to the board · y yes / n no`)
457
+ : style.dim(page === "roster"
458
+ ? "↑↓ select · enter open · tab board · x shutdown · esc/q close"
459
+ : "↑↓ select · enter open · tab roster · esc/q close");
460
+ return [
258
461
  border,
259
- style.accent(truncateToWidth(`teammate ${listNodes().filter((n) => n.status === "running").length} working / ${listNodes().length} teammate(s)`, width)),
462
+ style.accent(truncateToWidth(headerLine(), width)),
463
+ "",
464
+ ...content.slice(listOffset, listOffset + viewport)
465
+ .map((line) => truncateToWidth(line.text, Math.max(10, width - 1))),
260
466
  "",
467
+ truncateToWidth(footer, Math.max(10, width - 1)),
468
+ border,
261
469
  ];
262
- for (let i = 0; i < rows.length; i++) {
263
- const row = rows[i];
264
- const marker = i === selected ? style.accent("❯ ") : " ";
265
- const entry = getNodeByWorkerKey(row.key);
266
- if (!entry) continue;
267
- const { node } = entry;
268
- const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
269
- const name = theme.bold(theme.fg(color, node.id));
270
- const role = style.muted(`(${node.agent})`);
271
- const status = node.status === "running"
272
- ? theme.fg("warning", runningTeammateLabel(node))
273
- : node.status === "completed"
274
- ? style.success("✓ completed")
275
- : node.status === "failed"
276
- ? style.error("✗ failed")
277
- : style.dim(`○ ${node.status}`);
278
- lines.push(`${marker}${name} ${role} ${status}`);
279
- }
280
- lines.push("", style.dim("↑↓ select · enter open · esc/q close · x cancel"), border);
281
- return lines.map((l) => truncateToWidth(l, Math.max(10, width - 1)));
282
470
  };
283
471
 
472
+ const currentPageSelection = (rowCount: number): number => {
473
+ const selection = page === "roster" ? selectedRoster : selectedBoard;
474
+ return rowCount === 0 ? 0 : Math.min(selection, rowCount - 1);
475
+ };
476
+
477
+ const detailTitle = (): string =>
478
+ detailKind === "task" ? `[${detailKey}]` : detailKind === "role" ? `@${detailKey} · role` : `@${detailKey}`;
479
+
284
480
  const renderDetail = (width: number): string[] => {
285
481
  const border = style.border("─".repeat(Math.max(1, width)));
286
- const detail = windowLines(buildNodeDetail(detailKey), width);
482
+ const source = detailSource();
483
+ const detail = windowLines(source, width);
287
484
  const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
288
485
  const lines = [
289
486
  border,
290
- style.accent(truncateToWidth(`agent-teams ${detailKey}`, width)),
487
+ style.accent(truncateToWidth(`agent-teams ${detailTitle()}`, width)),
291
488
  "",
292
489
  ...detail.lines.map((line) => ` ${line}`),
293
490
  "",
@@ -297,90 +494,126 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
297
494
  return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
298
495
  };
299
496
 
497
+ const detailSource = (): string[] => {
498
+ if (detailKind === "task") return buildTaskDetail(detailKey);
499
+ if (detailKind === "role") return buildRoleDetail(detailKey);
500
+ return buildTeammateDetail(detailKey);
501
+ };
502
+
300
503
  return {
301
- render: (width) =>
302
- mode === "list" ? renderList(width) : renderDetail(width),
504
+ render: (width) => (mode === "list" ? renderList(width) : renderDetail(width)),
303
505
  handleInput: (data: string) => {
304
- // Detail mode: Esc returns to the list, q closes.
305
506
  if (mode !== "list") {
306
- if (matchesKey(data, Key.escape)) {
307
- mode = "list";
308
- offset = 0;
309
- return;
310
- }
311
- if (data === "q" || data === "Q") {
312
- closed = true;
313
- stopLiveRefresh();
314
- done();
315
- return;
316
- }
317
- const detail = wrapConsoleDetail(buildNodeDetail(detailKey), tui.terminal.columns);
318
- const viewport = maxConsoleBody(tui.terminal.rows);
319
- // Mouse-wheel scrolling (SGR wheel events, same as pi-btw-fradser).
320
- const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
321
- if (sgrWheel) {
322
- const button = Number.parseInt(sgrWheel[1], 10);
323
- if ((button & 64) !== 0) {
324
- const direction = button & 3;
325
- if (direction === 0) offset = scrollConsoleDetail(offset, -3, detail.length, viewport);
326
- else if (direction === 1) offset = scrollConsoleDetail(offset, 3, detail.length, viewport);
327
- }
328
- return;
507
+ handleDetailInput(data);
508
+ return;
509
+ }
510
+ if (confirmName) {
511
+ if (data === "y" || data === "Y") {
512
+ const name = confirmName;
513
+ confirmName = undefined;
514
+ void shutdownFromConsole(ctx, name);
515
+ } else if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
516
+ confirmName = undefined;
517
+ } else if (data === "q" || data === "Q") {
518
+ closeConsole(done);
329
519
  }
330
- if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, detail.length, viewport);
331
- else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, detail.length, viewport);
332
- else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), detail.length, viewport);
333
- else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), detail.length, viewport);
334
- else if (matchesKey(data, Key.home)) offset = 0;
335
- else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, detail.length, viewport);
336
520
  return;
337
521
  }
338
-
339
- // List mode — the console owns input, so ↑/↓/Enter are safe here.
340
- const rows = buildPanelRows();
341
- if (matchesKey(data, Key.down)) selected = Math.min(selected + 1, rows.length - 1);
342
- if (matchesKey(data, Key.up)) selected = Math.max(selected - 1, 0);
522
+ const rows = currentRows();
523
+ if (matchesKey(data, Key.tab)) {
524
+ page = page === "roster" ? "board" : "roster";
525
+ listOffset = 0;
526
+ return;
527
+ }
528
+ if (matchesKey(data, Key.down)) bumpSelection(1, rows.length);
529
+ if (matchesKey(data, Key.up)) bumpSelection(-1, rows.length);
343
530
  if (matchesKey(data, Key.enter)) {
344
- const row = rows[Math.min(selected, rows.length - 1)];
345
- if (row?.key) {
531
+ const selection = currentPageSelection(rows.length);
532
+ const row = rows[selection];
533
+ if (row) {
346
534
  mode = "detail";
347
- detailKey = row.key;
348
535
  offset = 0;
536
+ detailKind = row.kind;
537
+ detailKey = row.key;
349
538
  }
350
539
  return;
351
540
  }
352
- if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
353
- closed = true;
354
- stopLiveRefresh();
355
- done();
356
- return;
357
- }
358
541
  if (data === "x" || data === "X") {
359
- // Same node-cancel path as teammate_cancel nodeId: SIGTERM→SIGKILL,
360
- // dependents cancelled, the rest of the run continues.
361
- const row = rows[Math.min(selected, rows.length - 1)];
362
- const entry = row?.key ? getNodeByWorkerKey(row.key) : undefined;
363
- if (entry && (entry.node.status === "running" || entry.node.status === "pending")) {
364
- const runId = entry.run.id;
365
- const nodeId = entry.node.id;
366
- void cancelNodeAndTerminate(runId, nodeId, { ui: ctx.ui })
367
- .then((result) => {
368
- if (result.ok) ctx.ui.notify(`Cancelled node [${runId}/${nodeId}] — the rest of the run continues.`, "info");
369
- else ctx.ui.notify(result.error, "error");
370
- })
371
- .catch(() => false);
542
+ const selection = currentPageSelection(rows.length);
543
+ const row = rows[selection];
544
+ if (page === "roster" && row?.kind === "teammate" && getTeammate(row.key)?.status !== "stopped") {
545
+ confirmName = row.key;
372
546
  }
373
547
  return;
374
548
  }
549
+ const wheel = wheelDelta(data);
550
+ if (wheel !== undefined) {
551
+ scrollList(wheel * 3);
552
+ return;
553
+ }
554
+ if (matchesKey(data, Key.pageUp)) scrollList(-listViewport());
555
+ else if (matchesKey(data, Key.pageDown)) scrollList(listViewport());
556
+ else if (matchesKey(data, Key.home)) listOffset = 0;
557
+ else if (matchesKey(data, Key.end)) listOffset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, buildContent().length, listViewport());
558
+ else if (matchesKey(data, Key.escape) || data === "q" || data === "Q") closeConsole(done);
375
559
  },
376
- invalidate: () => {
377
- requestRender();
378
- },
560
+ invalidate: () => requestRender(),
379
561
  dispose: () => {
380
562
  closed = true;
381
563
  stopLiveRefresh();
382
564
  },
383
565
  };
566
+
567
+ function closeConsole(doneFn: () => void): void {
568
+ closed = true;
569
+ stopLiveRefresh();
570
+ doneFn();
571
+ }
572
+
573
+ function bumpSelection(delta: number, rowCount: number): void {
574
+ if (rowCount === 0) return;
575
+ if (page === "roster") selectedRoster = clampIndex(selectedRoster + delta, rowCount);
576
+ else selectedBoard = clampIndex(selectedBoard + delta, rowCount);
577
+ }
578
+
579
+ function scrollList(delta: number): void {
580
+ listOffset = clampConsoleScroll(listOffset + delta, buildContent().length, listViewport());
581
+ }
582
+
583
+ function handleDetailInput(data: string): void {
584
+ const source = detailSource();
585
+ const viewport = maxConsoleBody(tui.terminal.rows);
586
+ if (matchesKey(data, Key.escape)) {
587
+ mode = "list";
588
+ offset = 0;
589
+ return;
590
+ }
591
+ if (data === "q" || data === "Q") {
592
+ closeConsole(done);
593
+ return;
594
+ }
595
+ const total = wrapConsoleDetail(source, tui.terminal.columns).length;
596
+ const wheel = wheelDelta(data);
597
+ if (wheel !== undefined) {
598
+ offset = scrollConsoleDetail(offset, wheel * 3, total, viewport);
599
+ return;
600
+ }
601
+ if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, total, viewport);
602
+ else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, total, viewport);
603
+ else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), total, viewport);
604
+ else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), total, viewport);
605
+ else if (matchesKey(data, Key.home)) offset = 0;
606
+ else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, total, viewport);
607
+ }
384
608
  });
385
609
  }
386
610
 
611
+ async function shutdownFromConsole(ctx: { ui: ExtensionUIContext }, name: string): Promise<void> {
612
+ const result = await shutdownTeammate(name);
613
+ if (result.ok) ctx.ui.notify(result.body, "info");
614
+ else ctx.ui.notify(result.error, "error");
615
+ }
616
+
617
+ function clampIndex(index: number, rowCount: number): number {
618
+ return Math.max(0, Math.min(rowCount - 1, index));
619
+ }