@fradser/pi-agent-teams 0.5.3 → 0.6.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/.memory/MEMORY.md +3 -1
- package/.memory/node-terminal-followups.md +3 -2
- package/.memory/team-centric-design.md +21 -0
- package/README.md +99 -63
- package/agents/observer.md +1 -1
- package/agents/reviewer.md +4 -2
- package/agents/specialist.md +1 -1
- package/agents/worker.md +3 -3
- package/package.json +3 -3
- package/src/activity.ts +86 -0
- package/src/agents.ts +58 -21
- package/src/follow-up-queue.ts +93 -26
- package/src/guidance.ts +81 -60
- package/src/index.ts +92 -28
- package/src/spawner.ts +194 -350
- package/src/state.ts +252 -565
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +946 -0
- package/src/tools.ts +177 -173
- package/src/types.ts +153 -189
- package/src/ui.ts +306 -230
- package/src/worker.ts +172 -34
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/src/run-machine.ts +0 -606
- package/src/terminal.ts +0 -34
package/src/ui.ts
CHANGED
|
@@ -1,69 +1,48 @@
|
|
|
1
|
-
/** Passive
|
|
1
|
+
/** Passive team widget and interactive full-screen console (roster + board). */
|
|
2
2
|
|
|
3
3
|
import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
4
4
|
import { createPiThemeStyle, PI_SPINNER_FRAMES, PI_SPINNER_INTERVAL_MS } from "@fradser/pi-kit";
|
|
5
5
|
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
6
|
import {
|
|
8
7
|
clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
|
|
9
|
-
} from "./console-viewport";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
8
|
+
} from "./console-viewport.ts";
|
|
9
|
+
import { fitTeammateRow, formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
|
|
10
|
+
import { getState, getTeammate, listTasks, listTeammates, livingTeammates } from "./state.ts";
|
|
11
|
+
import {
|
|
12
|
+
ensureLivePoll,
|
|
13
|
+
formatSilenceDuration,
|
|
14
|
+
runtimeDirPath,
|
|
15
|
+
shutdownTeammate,
|
|
16
|
+
STALL_NOTICE_MS,
|
|
17
|
+
stallSilenceMs,
|
|
18
|
+
} from "./team-machine.ts";
|
|
19
|
+
import type { Teammate } from "./types.ts";
|
|
20
|
+
import { inboxPath } from "./statefile.ts";
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
24
23
|
|
|
25
24
|
const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
|
|
26
25
|
let spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
27
26
|
let spinnerFrame = 0;
|
|
28
27
|
|
|
29
|
-
/** Stable per-node color (independent of row order). */
|
|
30
28
|
function hashName(name: string): number {
|
|
31
29
|
let h = 0;
|
|
32
|
-
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)
|
|
30
|
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(0) + i * 7) | 0;
|
|
33
31
|
return Math.abs(h);
|
|
34
32
|
}
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
|
|
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}`;
|
|
34
|
+
function colorFor(name: string): (typeof TEAM_COLORS)[number] {
|
|
35
|
+
return TEAM_COLORS[hashName(name) % TEAM_COLORS.length];
|
|
57
36
|
}
|
|
58
37
|
|
|
59
38
|
function ensureSpinner(): void {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
39
|
+
const working = livingTeammates().some((t) => t.status === "working" || t.status === "starting");
|
|
40
|
+
if (working && !spinnerTimer) {
|
|
62
41
|
spinnerTimer = setInterval(() => {
|
|
63
42
|
spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
|
|
64
43
|
}, PI_SPINNER_INTERVAL_MS);
|
|
65
44
|
spinnerTimer.unref?.();
|
|
66
|
-
} else if (!
|
|
45
|
+
} else if (!working && spinnerTimer) {
|
|
67
46
|
clearInterval(spinnerTimer);
|
|
68
47
|
spinnerTimer = undefined;
|
|
69
48
|
}
|
|
@@ -80,8 +59,7 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
|
|
|
80
59
|
if (!ctx?.ui?.setWidget) return;
|
|
81
60
|
if (ctx.mode && ctx.mode !== "tui") return;
|
|
82
61
|
|
|
83
|
-
|
|
84
|
-
if (running.length === 0) {
|
|
62
|
+
if (livingTeammates().filter(isWorking).length === 0) {
|
|
85
63
|
ctx.ui.setWidget("teammate", undefined);
|
|
86
64
|
return;
|
|
87
65
|
}
|
|
@@ -90,37 +68,22 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
|
|
|
90
68
|
const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
|
|
91
69
|
timer.unref?.();
|
|
92
70
|
const style = createPiThemeStyle(theme);
|
|
93
|
-
const assignedColors = new Map<string, (typeof TEAM_COLORS)[number]>();
|
|
94
71
|
return {
|
|
95
72
|
placement: "belowEditor",
|
|
96
73
|
render: (width: number) => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
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
|
-
}
|
|
74
|
+
// Only WORKING teammates appear above the input box; idle and
|
|
75
|
+
// stopped teammates stay in the /teammate console instead.
|
|
76
|
+
const working = livingTeammates().filter(isWorking);
|
|
77
|
+
if (working.length === 0) return [];
|
|
112
78
|
const lines: string[] = [];
|
|
113
|
-
for (const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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)));
|
|
79
|
+
for (const teammate of working) {
|
|
80
|
+
lines.push(fitTeammateRow(
|
|
81
|
+
PI_SPINNER_FRAMES[spinnerFrame],
|
|
82
|
+
style.fg(colorFor(teammate.name), teammate.name),
|
|
83
|
+
runningTeammateActivity(teammate) + stallSuffix(teammate),
|
|
84
|
+
width,
|
|
85
|
+
(activity) => theme.bold(style.fg("accent", activity)),
|
|
86
|
+
));
|
|
124
87
|
}
|
|
125
88
|
return lines;
|
|
126
89
|
},
|
|
@@ -130,95 +93,167 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
|
|
|
130
93
|
});
|
|
131
94
|
}
|
|
132
95
|
|
|
96
|
+
function isWorking(teammate: { status: string }): boolean {
|
|
97
|
+
return teammate.status === "working" || teammate.status === "starting";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Silence marker appended once a working teammate passes the stall notice threshold. */
|
|
101
|
+
function stallSuffix(teammate: Teammate): string {
|
|
102
|
+
if (!isWorking(teammate)) return "";
|
|
103
|
+
const silence = stallSilenceMs(teammate);
|
|
104
|
+
return silence !== undefined && STALL_NOTICE_MS > 0 && silence >= STALL_NOTICE_MS
|
|
105
|
+
? ` · stalled for ${formatSilenceDuration(silence)}`
|
|
106
|
+
: "";
|
|
107
|
+
}
|
|
108
|
+
|
|
133
109
|
export function refreshTeamUI(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
|
|
134
110
|
ensureTeamWidget(ctx);
|
|
135
111
|
ensureLivePoll();
|
|
136
112
|
ensureSpinner();
|
|
137
113
|
}
|
|
138
114
|
|
|
115
|
+
function cap(text: string | undefined, maxBytes = 2000): string {
|
|
116
|
+
if (!text) return "";
|
|
117
|
+
return text.length <= maxBytes ? text : `${text.slice(0, maxBytes)}\n…[truncated ${text.length - maxBytes} chars]`;
|
|
118
|
+
}
|
|
139
119
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
];
|
|
169
|
-
lines.push(...(activity.length > 0 ? activity : [" Waiting for the worker's first response…"]));
|
|
170
|
-
}
|
|
171
|
-
if (spawn.stdout) {
|
|
172
|
-
lines.push(" --- Worker output ---");
|
|
173
|
-
lines.push(...spawn.stdout.split("\n"));
|
|
120
|
+
// ── Detail builders ───────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
interface PeerMailLine {
|
|
123
|
+
direction: "sent" | "received";
|
|
124
|
+
counterpart: string;
|
|
125
|
+
subject: string;
|
|
126
|
+
body: string;
|
|
127
|
+
timestamp: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readPeerMail(teammateName: string): PeerMailLine[] {
|
|
131
|
+
const runtimeDir = runtimeDirPath();
|
|
132
|
+
if (!runtimeDir) return [];
|
|
133
|
+
const mailDirPath = path.dirname(inboxPath(path.join(runtimeDir, "state.json"), teammateName));
|
|
134
|
+
let entries: string[] = [];
|
|
135
|
+
try {
|
|
136
|
+
entries = fs.readdirSync(mailDirPath).filter((name) => name.startsWith("inbox-") && name.endsWith(".jsonl"));
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
const lines: PeerMailLine[] = [];
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
const recipient = decodeURIComponent(entry.slice("inbox-".length, -".jsonl".length));
|
|
143
|
+
let raw = "";
|
|
144
|
+
try {
|
|
145
|
+
raw = fs.readFileSync(path.join(mailDirPath, entry), "utf-8");
|
|
146
|
+
} catch {
|
|
147
|
+
continue;
|
|
174
148
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
149
|
+
for (const line of raw.split("\n")) {
|
|
150
|
+
if (!line.trim()) continue;
|
|
151
|
+
try {
|
|
152
|
+
const message = JSON.parse(line) as { id?: string; from?: string; subject?: string; body?: string; timestamp?: number };
|
|
153
|
+
if (!message.id || !message.from || !message.subject) continue;
|
|
154
|
+
if (message.from !== teammateName && recipient !== teammateName) continue;
|
|
155
|
+
lines.push({
|
|
156
|
+
direction: message.from === teammateName ? "sent" : "received",
|
|
157
|
+
counterpart: message.from === teammateName ? recipient : message.from,
|
|
158
|
+
subject: message.subject,
|
|
159
|
+
body: message.body ?? "",
|
|
160
|
+
timestamp: message.timestamp ?? 0,
|
|
161
|
+
});
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
178
165
|
}
|
|
179
166
|
}
|
|
180
|
-
|
|
181
|
-
if (node.result && node.result !== spawn?.stdout) lines.push(` Result: ${node.result}`);
|
|
182
|
-
if (node.errorMessage) lines.push(` Error: ${node.errorMessage}`);
|
|
183
|
-
return lines;
|
|
167
|
+
return lines.sort((a, b) => a.timestamp - b.timestamp);
|
|
184
168
|
}
|
|
185
169
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (!entry) return ["(removed)"];
|
|
190
|
-
const { run, node } = entry;
|
|
191
|
-
const outgoing = getState().leaderMailbox.filter((message) => message.from === workerKey);
|
|
170
|
+
function indent(text: string): string[] {
|
|
171
|
+
return text.split("\n").map((line) => ` ${line}`);
|
|
172
|
+
}
|
|
192
173
|
|
|
174
|
+
function buildTeammateDetail(name: string): string[] {
|
|
175
|
+
const teammate = getTeammate(name);
|
|
176
|
+
if (!teammate) return ["(teammate removed from the roster)"];
|
|
177
|
+
const reports = getState().leaderMailbox.filter((message) => message.from === name);
|
|
193
178
|
const lines: string[] = [
|
|
194
|
-
|
|
195
|
-
"",
|
|
196
|
-
"== node ==",
|
|
197
|
-
...buildNodeSection(node),
|
|
179
|
+
`@${teammate.name} (${teammate.agent}) [${teammate.status}]`,
|
|
198
180
|
"",
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
})),
|
|
181
|
+
"== teammate ==",
|
|
182
|
+
` Status: ${teammate.status}${teammate.currentTaskId ? ` | Task: ${teammate.currentTaskId}` : ""}`,
|
|
183
|
+
` Spawn: ${teammate.pid > 0 ? `pid ${teammate.pid}` : "pid unknown"} | Isolation: ${teammate.isolation}`,
|
|
184
|
+
` Created: ${new Date(teammate.createdAt).toLocaleString()}`,
|
|
211
185
|
];
|
|
186
|
+
if (teammate.stoppedAt) lines.push(` Stopped: ${new Date(teammate.stoppedAt).toLocaleString()}`);
|
|
187
|
+
if (isWorking(teammate) && teammate.lastOutputAt !== undefined) {
|
|
188
|
+
const silence = stallSilenceMs(teammate);
|
|
189
|
+
if (silence !== undefined) {
|
|
190
|
+
lines.push(` Last output: ${new Date(teammate.lastOutputAt).toLocaleString()} (${formatSilenceDuration(silence)} ago${stallSuffix(teammate) ? " — stalled" : ""})`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (teammate.usage) lines.push(` Usage: ${teammate.usage.totalTokens} tokens | $${teammate.usage.cost.toFixed(4)}`);
|
|
194
|
+
if (teammate.error) lines.push(` Error: ${teammate.error}`);
|
|
195
|
+
|
|
196
|
+
lines.push("", `== reports to leader (${reports.length}) ==`);
|
|
197
|
+
if (reports.length === 0) lines.push(" (none)");
|
|
198
|
+
for (const report of reports) {
|
|
199
|
+
lines.push(` -> [${report.subject}] ${new Date(report.timestamp).toLocaleString()}`, ...indent(cap(report.body)), "");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const peer = readPeerMail(name);
|
|
203
|
+
lines.push(`== peer mail (${peer.length}) ==`);
|
|
204
|
+
if (peer.length === 0) lines.push(" (none)");
|
|
205
|
+
for (const mail of peer) {
|
|
206
|
+
const arrow = mail.direction === "sent" ? `to @${mail.counterpart}` : `from @${mail.counterpart}`;
|
|
207
|
+
lines.push(` ${arrow} [${mail.subject}] ${new Date(mail.timestamp).toLocaleString()}`, ...indent(cap(mail.body)), "");
|
|
208
|
+
}
|
|
212
209
|
return lines;
|
|
213
210
|
}
|
|
214
211
|
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
function buildTaskDetail(taskId: string): string[] {
|
|
213
|
+
const tasks = listTasks();
|
|
214
|
+
const task = tasks.find((candidate) => candidate.id === taskId);
|
|
215
|
+
if (!task) return ["(task not on the board)"];
|
|
216
|
+
const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
|
|
217
|
+
return [
|
|
218
|
+
`[${task.id}] ${task.subject}`,
|
|
219
|
+
"",
|
|
220
|
+
"== task ==",
|
|
221
|
+
` Status: ${task.status}${task.claimedBy ? ` (@${task.claimedBy})` : ""}`,
|
|
222
|
+
...(task.description ? [` Description:`, ...indent(cap(task.description))] : []),
|
|
223
|
+
...(task.dependsOn.length > 0
|
|
224
|
+
? [` Depends on: ${task.dependsOn.join(", ")} (${task.dependsOn.every((dep) => byId.get(dep)?.status === "completed") ? "met" : "unmet"})`]
|
|
225
|
+
: []),
|
|
226
|
+
` Verify: ${task.verify ?? "(none)"}`,
|
|
227
|
+
...(task.result ? [" Result:", ...indent(cap(task.result))] : []),
|
|
228
|
+
...(task.errorMessage ? [" Error:", ...indent(cap(task.errorMessage))] : []),
|
|
229
|
+
` Created: ${new Date(task.createdAt).toLocaleString()}`,
|
|
230
|
+
...(task.completedAt ? [` Completed: ${new Date(task.completedAt).toLocaleString()}`] : []),
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Full-screen console ───────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
type ConsolePage = "roster" | "board";
|
|
237
|
+
|
|
238
|
+
interface RosterRow {
|
|
239
|
+
key: string;
|
|
240
|
+
kind: "teammate";
|
|
241
|
+
}
|
|
242
|
+
interface BoardRow {
|
|
243
|
+
key: string;
|
|
244
|
+
kind: "task";
|
|
245
|
+
}
|
|
246
|
+
type ConsoleRow = RosterRow | BoardRow;
|
|
247
|
+
|
|
248
|
+
/** Full-screen Team Console — owns input via ctx.ui.custom. Pages: roster /
|
|
249
|
+
* board; each row opens a scrolling detail view. */
|
|
217
250
|
export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void> {
|
|
218
251
|
return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
252
|
+
let page: ConsolePage = "roster";
|
|
219
253
|
let mode: "list" | "detail" = "list";
|
|
220
|
-
let
|
|
221
|
-
let
|
|
254
|
+
let selectedRoster = 0;
|
|
255
|
+
let selectedBoard = 0;
|
|
256
|
+
let detailTitle = "";
|
|
222
257
|
let offset = 0;
|
|
223
258
|
let closed = false;
|
|
224
259
|
let renderTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -237,9 +272,14 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
|
|
|
237
272
|
};
|
|
238
273
|
startLiveRefresh();
|
|
239
274
|
|
|
240
|
-
// btw-style callbacks (same accent/muted/dim/border/success/error language as pi-btw-fradser).
|
|
241
275
|
const style = createPiThemeStyle(theme);
|
|
242
276
|
|
|
277
|
+
const currentRows = (): ConsoleRow[] =>
|
|
278
|
+
page === "roster"
|
|
279
|
+
// The console is the full roster: idle and stopped stay visible here.
|
|
280
|
+
? listTeammates().map((t) => ({ key: t.name, kind: "teammate" as const }))
|
|
281
|
+
: listTasks().map((task) => ({ key: task.id, kind: "task" as const }));
|
|
282
|
+
|
|
243
283
|
const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
|
|
244
284
|
const wrapped = wrapConsoleDetail(full, width);
|
|
245
285
|
const viewport = maxConsoleBody(tui.terminal.rows);
|
|
@@ -250,44 +290,66 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
|
|
|
250
290
|
};
|
|
251
291
|
};
|
|
252
292
|
|
|
293
|
+
const headerLine = (): string => {
|
|
294
|
+
const alive = livingTeammates();
|
|
295
|
+
const tasks = listTasks();
|
|
296
|
+
return `team ${alive.length} alive · 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}`;
|
|
297
|
+
};
|
|
298
|
+
|
|
253
299
|
const renderList = (width: number): string[] => {
|
|
254
|
-
const rows =
|
|
255
|
-
if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
|
|
300
|
+
const rows = currentRows();
|
|
256
301
|
const border = style.border("─".repeat(Math.max(1, width)));
|
|
257
|
-
const lines
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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}`);
|
|
302
|
+
const lines = [border, style.accent(truncateToWidth(headerLine(), width)), ""];
|
|
303
|
+
if (rows.length === 0) {
|
|
304
|
+
lines.push(style.dim(page === "roster"
|
|
305
|
+
? "No living teammates. Spawn one with teammate_spawn."
|
|
306
|
+
: "The task board is empty. Create tasks with task_create."));
|
|
279
307
|
}
|
|
280
|
-
|
|
308
|
+
rows.forEach((row, index) => {
|
|
309
|
+
const marker = index === currentPageSelection(rows.length) ? style.accent("❯ ") : " ";
|
|
310
|
+
if (row.kind === "teammate") {
|
|
311
|
+
const teammate = getTeammate(row.key)!;
|
|
312
|
+
const name = theme.bold(theme.fg(colorFor(teammate.name), `@${teammate.name}`));
|
|
313
|
+
let status: string;
|
|
314
|
+
if (teammate.status === "working" || teammate.status === "starting") {
|
|
315
|
+
const prefix = `${marker}${name} `;
|
|
316
|
+
const available = Math.max(0, width - prefix.length - PI_SPINNER_FRAMES[spinnerFrame].length - 2);
|
|
317
|
+
status = theme.fg("warning", formatTeammateLabel(PI_SPINNER_FRAMES[spinnerFrame], runningTeammateActivity(teammate) + stallSuffix(teammate), available));
|
|
318
|
+
} else if (teammate.status === "idle") {
|
|
319
|
+
status = style.dim(`○ idle${teammate.currentTaskId ? ` · ${teammate.currentTaskId}` : ""}`);
|
|
320
|
+
} else {
|
|
321
|
+
status = theme.fg("warning", "■ stopped");
|
|
322
|
+
}
|
|
323
|
+
lines.push(`${marker}${name} ${status}`);
|
|
324
|
+
} else {
|
|
325
|
+
const task = listTasks().find((candidate) => candidate.id === row.key)!;
|
|
326
|
+
const label = theme.fg(colorFor(task.id), `[${task.id}]`);
|
|
327
|
+
const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
|
|
328
|
+
const statusText = task.status === "completed"
|
|
329
|
+
? style.success("✓")
|
|
330
|
+
: task.status === "claimed"
|
|
331
|
+
? theme.fg("warning", "◐ claimed")
|
|
332
|
+
: style.dim("○ pending");
|
|
333
|
+
lines.push(truncateToWidth(`${marker}${label} ${statusText} ${theme.fg("customMessageText", task.subject)}${holder}`, Math.max(10, width - 1)));
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
lines.push("", style.dim("↑↓ select · enter open · tab page · x shutdown · esc/q close"), border);
|
|
281
337
|
return lines.map((l) => truncateToWidth(l, Math.max(10, width - 1)));
|
|
282
338
|
};
|
|
283
339
|
|
|
340
|
+
const currentPageSelection = (rowCount: number): number => {
|
|
341
|
+
const selection = page === "roster" ? selectedRoster : selectedBoard;
|
|
342
|
+
return rowCount === 0 ? 0 : Math.min(selection, rowCount - 1);
|
|
343
|
+
};
|
|
344
|
+
|
|
284
345
|
const renderDetail = (width: number): string[] => {
|
|
285
346
|
const border = style.border("─".repeat(Math.max(1, width)));
|
|
286
|
-
const
|
|
347
|
+
const source = detailSource();
|
|
348
|
+
const detail = windowLines(source, width);
|
|
287
349
|
const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
|
|
288
350
|
const lines = [
|
|
289
351
|
border,
|
|
290
|
-
style.accent(truncateToWidth(`agent-teams ${
|
|
352
|
+
style.accent(truncateToWidth(`agent-teams ${detailTitle}`, width)),
|
|
291
353
|
"",
|
|
292
354
|
...detail.lines.map((line) => ` ${line}`),
|
|
293
355
|
"",
|
|
@@ -297,90 +359,104 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
|
|
|
297
359
|
return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
|
|
298
360
|
};
|
|
299
361
|
|
|
362
|
+
const detailSource = (): string[] =>
|
|
363
|
+
detailTitle.startsWith("[")
|
|
364
|
+
? buildTaskDetail(detailTitle.slice(1, detailTitle.indexOf("]")))
|
|
365
|
+
: buildTeammateDetail(detailTitle.replace(/^@/, ""));
|
|
366
|
+
|
|
300
367
|
return {
|
|
301
|
-
render: (width) =>
|
|
302
|
-
mode === "list" ? renderList(width) : renderDetail(width),
|
|
368
|
+
render: (width) => (mode === "list" ? renderList(width) : renderDetail(width)),
|
|
303
369
|
handleInput: (data: string) => {
|
|
304
|
-
// Detail mode: Esc returns to the list, q closes.
|
|
305
370
|
if (mode !== "list") {
|
|
306
|
-
|
|
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;
|
|
329
|
-
}
|
|
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);
|
|
371
|
+
handleDetailInput(data);
|
|
336
372
|
return;
|
|
337
373
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
374
|
+
const rows = currentRows();
|
|
375
|
+
if (matchesKey(data, Key.tab)) {
|
|
376
|
+
page = page === "roster" ? "board" : "roster";
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (matchesKey(data, Key.down)) bumpSelection(1, rows.length);
|
|
380
|
+
if (matchesKey(data, Key.up)) bumpSelection(-1, rows.length);
|
|
343
381
|
if (matchesKey(data, Key.enter)) {
|
|
344
|
-
const
|
|
345
|
-
|
|
382
|
+
const selection = currentPageSelection(rows.length);
|
|
383
|
+
const row = rows[selection];
|
|
384
|
+
if (row) {
|
|
346
385
|
mode = "detail";
|
|
347
|
-
detailKey = row.key;
|
|
348
386
|
offset = 0;
|
|
387
|
+
detailTitle = row.kind === "teammate" ? `@${row.key}` : `[${row.key}]`;
|
|
349
388
|
}
|
|
350
389
|
return;
|
|
351
390
|
}
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
391
|
+
if (data === "x" || data === "X") {
|
|
392
|
+
const selection = currentPageSelection(rows.length);
|
|
393
|
+
const row = rows[selection];
|
|
394
|
+
if (page === "roster" && row?.kind === "teammate") void shutdownFromConsole(ctx, row.key);
|
|
356
395
|
return;
|
|
357
396
|
}
|
|
358
|
-
if (data === "
|
|
359
|
-
|
|
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);
|
|
372
|
-
}
|
|
397
|
+
if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
|
|
398
|
+
closeConsole(done);
|
|
373
399
|
return;
|
|
374
400
|
}
|
|
375
401
|
},
|
|
376
|
-
invalidate: () =>
|
|
377
|
-
requestRender();
|
|
378
|
-
},
|
|
402
|
+
invalidate: () => requestRender(),
|
|
379
403
|
dispose: () => {
|
|
380
404
|
closed = true;
|
|
381
405
|
stopLiveRefresh();
|
|
382
406
|
},
|
|
383
407
|
};
|
|
408
|
+
|
|
409
|
+
function closeConsole(doneFn: () => void): void {
|
|
410
|
+
closed = true;
|
|
411
|
+
stopLiveRefresh();
|
|
412
|
+
doneFn();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function bumpSelection(delta: number, rowCount: number): void {
|
|
416
|
+
if (rowCount === 0) return;
|
|
417
|
+
if (page === "roster") selectedRoster = clampIndex(selectedRoster + delta, rowCount);
|
|
418
|
+
else selectedBoard = clampIndex(selectedBoard + delta, rowCount);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function handleDetailInput(data: string): void {
|
|
422
|
+
const source = detailSource();
|
|
423
|
+
const viewport = maxConsoleBody(tui.terminal.rows);
|
|
424
|
+
if (matchesKey(data, Key.escape)) {
|
|
425
|
+
mode = "list";
|
|
426
|
+
offset = 0;
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (data === "q" || data === "Q") {
|
|
430
|
+
closeConsole(done);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const total = wrapConsoleDetail(source, tui.terminal.columns).length;
|
|
434
|
+
const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
|
|
435
|
+
if (sgrWheel) {
|
|
436
|
+
const button = Number.parseInt(sgrWheel[1], 10);
|
|
437
|
+
if ((button & 64) !== 0) {
|
|
438
|
+
const direction = button & 3;
|
|
439
|
+
if (direction === 0) offset = scrollConsoleDetail(offset, -3, total, viewport);
|
|
440
|
+
else if (direction === 1) offset = scrollConsoleDetail(offset, 3, total, viewport);
|
|
441
|
+
}
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, total, viewport);
|
|
445
|
+
else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, total, viewport);
|
|
446
|
+
else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), total, viewport);
|
|
447
|
+
else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), total, viewport);
|
|
448
|
+
else if (matchesKey(data, Key.home)) offset = 0;
|
|
449
|
+
else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, total, viewport);
|
|
450
|
+
}
|
|
384
451
|
});
|
|
385
452
|
}
|
|
386
453
|
|
|
454
|
+
async function shutdownFromConsole(ctx: { ui: ExtensionUIContext }, name: string): Promise<void> {
|
|
455
|
+
const result = await shutdownTeammate(name);
|
|
456
|
+
if (result.ok) ctx.ui.notify(result.body, "info");
|
|
457
|
+
else ctx.ui.notify(result.error, "error");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function clampIndex(index: number, rowCount: number): number {
|
|
461
|
+
return Math.max(0, Math.min(rowCount - 1, index));
|
|
462
|
+
}
|