@fradser/pi-agent-teams 0.5.2 → 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/follow-up-queue.md +17 -0
- package/.memory/node-terminal-followups.md +18 -0
- package/.memory/team-centric-design.md +21 -0
- package/README.md +99 -62
- 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/console-viewport.ts +2 -1
- package/src/follow-up-queue.ts +237 -0
- package/src/guidance.ts +81 -57
- package/src/index.ts +119 -38
- package/src/spawner.ts +217 -393
- package/src/state.ts +253 -568
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +946 -0
- package/src/tools.ts +181 -118
- package/src/types.ts +157 -155
- package/src/ui.ts +327 -268
- package/src/worker.ts +172 -31
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/src/run-machine.ts +0 -573
- package/src/terminal.ts +0 -34
package/src/ui.ts
CHANGED
|
@@ -1,241 +1,259 @@
|
|
|
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
|
-
import type { ExtensionUIContext
|
|
6
|
-
import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ExtensionUIContext } 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
|
-
|
|
24
|
-
|
|
25
|
-
const TEAM_COLORS = ["accent", "success", "warning", "error", "toolTitle", "mdLink"] as const;
|
|
26
|
-
const PANEL_IDLE_COLLAPSE_MS = 30_000;
|
|
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";
|
|
27
23
|
|
|
28
|
-
|
|
24
|
+
const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
|
|
29
25
|
let spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
30
26
|
let spinnerFrame = 0;
|
|
31
|
-
let panelLastActivity = 0;
|
|
32
|
-
let panelCollapseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
-
let widgetRegistered = false;
|
|
34
27
|
|
|
35
|
-
/** Stable per-node color (independent of row order). */
|
|
36
28
|
function hashName(name: string): number {
|
|
37
29
|
let h = 0;
|
|
38
|
-
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;
|
|
39
31
|
return Math.abs(h);
|
|
40
32
|
}
|
|
41
33
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Console/widget rows contain node status and leader-report access. */
|
|
47
|
-
function buildPanelRows(): PanelRow[] {
|
|
48
|
-
return listNodes().map((node) => ({ key: node.workerKey }));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function runningTeammateLabel(node: Node): string {
|
|
52
|
-
const frame = PI_SPINNER_FRAMES[spinnerFrame];
|
|
53
|
-
const tool = node.spawn?.activeTool;
|
|
54
|
-
const thinking = (node.spawn?.liveThinking ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
55
|
-
const live = (node.spawn?.liveText ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
56
|
-
// Like the memory package's activity line: show what the worker is doing
|
|
57
|
-
// right now — current tool when one is executing, else its live reasoning,
|
|
58
|
-
// else the latest assistant text.
|
|
59
|
-
const detail = tool
|
|
60
|
-
? ` ${tool}`
|
|
61
|
-
: thinking
|
|
62
|
-
? ` ${truncateToWidth(thinking, 48)}`
|
|
63
|
-
: live
|
|
64
|
-
? ` ${truncateToWidth(live, 48)}`
|
|
65
|
-
: " working...";
|
|
66
|
-
return `${frame}${detail}`;
|
|
34
|
+
function colorFor(name: string): (typeof TEAM_COLORS)[number] {
|
|
35
|
+
return TEAM_COLORS[hashName(name) % TEAM_COLORS.length];
|
|
67
36
|
}
|
|
68
37
|
|
|
69
38
|
function ensureSpinner(): void {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
39
|
+
const working = livingTeammates().some((t) => t.status === "working" || t.status === "starting");
|
|
40
|
+
if (working && !spinnerTimer) {
|
|
72
41
|
spinnerTimer = setInterval(() => {
|
|
73
42
|
spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
|
|
74
|
-
panelRequestRender?.();
|
|
75
43
|
}, PI_SPINNER_INTERVAL_MS);
|
|
76
44
|
spinnerTimer.unref?.();
|
|
77
|
-
} else if (!
|
|
45
|
+
} else if (!working && spinnerTimer) {
|
|
78
46
|
clearInterval(spinnerTimer);
|
|
79
47
|
spinnerTimer = undefined;
|
|
80
48
|
}
|
|
81
49
|
}
|
|
82
50
|
|
|
83
|
-
function isPanelCollapsed(): boolean {
|
|
84
|
-
return Date.now() - panelLastActivity > PANEL_IDLE_COLLAPSE_MS;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function scheduleIdleCollapse(): void {
|
|
88
|
-
if (panelCollapseTimer) clearTimeout(panelCollapseTimer);
|
|
89
|
-
panelCollapseTimer = setTimeout(() => panelRequestRender?.(), PANEL_IDLE_COLLAPSE_MS);
|
|
90
|
-
panelCollapseTimer.unref?.();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
51
|
export function stopUiTimers(): void {
|
|
94
52
|
if (spinnerTimer) {
|
|
95
53
|
clearInterval(spinnerTimer);
|
|
96
54
|
spinnerTimer = undefined;
|
|
97
55
|
}
|
|
98
|
-
if (panelCollapseTimer) {
|
|
99
|
-
clearTimeout(panelCollapseTimer);
|
|
100
|
-
panelCollapseTimer = undefined;
|
|
101
|
-
}
|
|
102
56
|
}
|
|
103
57
|
|
|
104
58
|
export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
|
|
105
|
-
if (
|
|
59
|
+
if (!ctx?.ui?.setWidget) return;
|
|
106
60
|
if (ctx.mode && ctx.mode !== "tui") return;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
render: (width) => panelRows(theme, width),
|
|
113
|
-
invalidate: () => {},
|
|
114
|
-
dispose: () => {
|
|
115
|
-
stopUiTimers();
|
|
116
|
-
panelRequestRender = undefined;
|
|
117
|
-
widgetRegistered = false;
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
});
|
|
121
|
-
widgetRegistered = true;
|
|
122
|
-
} catch {
|
|
123
|
-
// Best effort if widget registration is unavailable in the current mode.
|
|
61
|
+
|
|
62
|
+
if (livingTeammates().filter(isWorking).length === 0) {
|
|
63
|
+
ctx.ui.setWidget("teammate", undefined);
|
|
64
|
+
return;
|
|
124
65
|
}
|
|
66
|
+
|
|
67
|
+
ctx.ui.setWidget("teammate", (tui, theme) => {
|
|
68
|
+
const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
|
|
69
|
+
timer.unref?.();
|
|
70
|
+
const style = createPiThemeStyle(theme);
|
|
71
|
+
return {
|
|
72
|
+
placement: "belowEditor",
|
|
73
|
+
render: (width: number) => {
|
|
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 [];
|
|
78
|
+
const lines: string[] = [];
|
|
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
|
+
));
|
|
87
|
+
}
|
|
88
|
+
return lines;
|
|
89
|
+
},
|
|
90
|
+
invalidate: () => {},
|
|
91
|
+
dispose: () => clearInterval(timer),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
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
|
+
: "";
|
|
125
107
|
}
|
|
126
108
|
|
|
127
109
|
export function refreshTeamUI(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
|
|
128
110
|
ensureTeamWidget(ctx);
|
|
129
|
-
panelLastActivity = Date.now();
|
|
130
|
-
scheduleIdleCollapse();
|
|
131
111
|
ensureLivePoll();
|
|
132
112
|
ensureSpinner();
|
|
133
|
-
panelRequestRender?.();
|
|
134
113
|
}
|
|
135
114
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const bold = (s: string): string => theme.bold(s);
|
|
140
|
-
const fit = (line: string): string =>
|
|
141
|
-
typeof width === "number" && width > 0 ? truncateToWidth(line, Math.max(10, width - 1)) : line;
|
|
142
|
-
const running = listNodes().filter((node) => node.status === "running");
|
|
143
|
-
// Stay out of the way until a teammate is actually working.
|
|
144
|
-
if (running.length === 0) return [];
|
|
145
|
-
if (isPanelCollapsed()) {
|
|
146
|
-
return [` ${fit(fg("dim", `${running.length} teammate${running.length === 1 ? "" : "s"} working — /teammate`))}`];
|
|
147
|
-
}
|
|
148
|
-
const lines: string[] = [];
|
|
149
|
-
for (const node of running) {
|
|
150
|
-
const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
|
|
151
|
-
lines.push(` ${fit(`${bold(fg(color, node.id))} ${fg("muted", `(${node.agent})`)} ${fg("warning", runningTeammateLabel(node))}`)}`);
|
|
152
|
-
}
|
|
153
|
-
lines.push(` ${fit(fg("dim", "/teammate — open console"))}`);
|
|
154
|
-
return lines;
|
|
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]`;
|
|
155
118
|
}
|
|
156
119
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
];
|
|
186
|
-
lines.push(...(activity.length > 0 ? activity : [" Waiting for the worker's first response…"]));
|
|
187
|
-
}
|
|
188
|
-
if (spawn.stdout) {
|
|
189
|
-
lines.push(" --- Worker output ---");
|
|
190
|
-
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;
|
|
191
148
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
}
|
|
195
165
|
}
|
|
196
166
|
}
|
|
197
|
-
|
|
198
|
-
if (node.result && node.result !== spawn?.stdout) lines.push(` Result: ${node.result}`);
|
|
199
|
-
if (node.errorMessage) lines.push(` Error: ${node.errorMessage}`);
|
|
200
|
-
return lines;
|
|
167
|
+
return lines.sort((a, b) => a.timestamp - b.timestamp);
|
|
201
168
|
}
|
|
202
169
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (!entry) return ["(removed)"];
|
|
207
|
-
const { run, node } = entry;
|
|
208
|
-
const outgoing = getState().leaderMailbox.filter((message) => message.from === workerKey);
|
|
170
|
+
function indent(text: string): string[] {
|
|
171
|
+
return text.split("\n").map((line) => ` ${line}`);
|
|
172
|
+
}
|
|
209
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);
|
|
210
178
|
const lines: string[] = [
|
|
211
|
-
|
|
212
|
-
"",
|
|
213
|
-
"== node ==",
|
|
214
|
-
...buildNodeSection(node),
|
|
179
|
+
`@${teammate.name} (${teammate.agent}) [${teammate.status}]`,
|
|
215
180
|
"",
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const time = new Date(m.timestamp).toLocaleString();
|
|
221
|
-
return [
|
|
222
|
-
`→ [${m.id}] ${m.subject} | ${time}`,
|
|
223
|
-
m.runId ? ` run ${m.runId}` : "",
|
|
224
|
-
m.body,
|
|
225
|
-
"",
|
|
226
|
-
];
|
|
227
|
-
})),
|
|
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()}`,
|
|
228
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
|
+
}
|
|
229
209
|
return lines;
|
|
230
210
|
}
|
|
231
211
|
|
|
232
|
-
|
|
233
|
-
|
|
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. */
|
|
234
250
|
export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void> {
|
|
235
251
|
return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
252
|
+
let page: ConsolePage = "roster";
|
|
236
253
|
let mode: "list" | "detail" = "list";
|
|
237
|
-
let
|
|
238
|
-
let
|
|
254
|
+
let selectedRoster = 0;
|
|
255
|
+
let selectedBoard = 0;
|
|
256
|
+
let detailTitle = "";
|
|
239
257
|
let offset = 0;
|
|
240
258
|
let closed = false;
|
|
241
259
|
let renderTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -254,9 +272,14 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
|
|
|
254
272
|
};
|
|
255
273
|
startLiveRefresh();
|
|
256
274
|
|
|
257
|
-
// btw-style callbacks (same accent/muted/dim/border/success/error language as pi-btw-fradser).
|
|
258
275
|
const style = createPiThemeStyle(theme);
|
|
259
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
|
+
|
|
260
283
|
const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
|
|
261
284
|
const wrapped = wrapConsoleDetail(full, width);
|
|
262
285
|
const viewport = maxConsoleBody(tui.terminal.rows);
|
|
@@ -267,44 +290,66 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
|
|
|
267
290
|
};
|
|
268
291
|
};
|
|
269
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
|
+
|
|
270
299
|
const renderList = (width: number): string[] => {
|
|
271
|
-
const rows =
|
|
272
|
-
if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
|
|
300
|
+
const rows = currentRows();
|
|
273
301
|
const border = style.border("─".repeat(Math.max(1, width)));
|
|
274
|
-
const lines
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
for (let i = 0; i < rows.length; i++) {
|
|
280
|
-
const row = rows[i];
|
|
281
|
-
const marker = i === selected ? style.accent("❯ ") : " ";
|
|
282
|
-
const entry = getNodeByWorkerKey(row.key);
|
|
283
|
-
if (!entry) continue;
|
|
284
|
-
const { node } = entry;
|
|
285
|
-
const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
|
|
286
|
-
const name = theme.bold(theme.fg(color, node.id));
|
|
287
|
-
const role = style.muted(`(${node.agent})`);
|
|
288
|
-
const status = node.status === "running"
|
|
289
|
-
? theme.fg("warning", runningTeammateLabel(node))
|
|
290
|
-
: node.status === "completed"
|
|
291
|
-
? style.success("✓ completed")
|
|
292
|
-
: node.status === "failed"
|
|
293
|
-
? style.error("✗ failed")
|
|
294
|
-
: style.dim(`○ ${node.status}`);
|
|
295
|
-
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."));
|
|
296
307
|
}
|
|
297
|
-
|
|
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);
|
|
298
337
|
return lines.map((l) => truncateToWidth(l, Math.max(10, width - 1)));
|
|
299
338
|
};
|
|
300
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
|
+
|
|
301
345
|
const renderDetail = (width: number): string[] => {
|
|
302
346
|
const border = style.border("─".repeat(Math.max(1, width)));
|
|
303
|
-
const
|
|
347
|
+
const source = detailSource();
|
|
348
|
+
const detail = windowLines(source, width);
|
|
304
349
|
const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
|
|
305
350
|
const lines = [
|
|
306
351
|
border,
|
|
307
|
-
style.accent(truncateToWidth(`agent-teams ${
|
|
352
|
+
style.accent(truncateToWidth(`agent-teams ${detailTitle}`, width)),
|
|
308
353
|
"",
|
|
309
354
|
...detail.lines.map((line) => ` ${line}`),
|
|
310
355
|
"",
|
|
@@ -314,90 +359,104 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
|
|
|
314
359
|
return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
|
|
315
360
|
};
|
|
316
361
|
|
|
362
|
+
const detailSource = (): string[] =>
|
|
363
|
+
detailTitle.startsWith("[")
|
|
364
|
+
? buildTaskDetail(detailTitle.slice(1, detailTitle.indexOf("]")))
|
|
365
|
+
: buildTeammateDetail(detailTitle.replace(/^@/, ""));
|
|
366
|
+
|
|
317
367
|
return {
|
|
318
|
-
render: (width) =>
|
|
319
|
-
mode === "list" ? renderList(width) : renderDetail(width),
|
|
368
|
+
render: (width) => (mode === "list" ? renderList(width) : renderDetail(width)),
|
|
320
369
|
handleInput: (data: string) => {
|
|
321
|
-
// Detail mode: Esc returns to the list, q closes.
|
|
322
370
|
if (mode !== "list") {
|
|
323
|
-
|
|
324
|
-
mode = "list";
|
|
325
|
-
offset = 0;
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
if (data === "q" || data === "Q") {
|
|
329
|
-
closed = true;
|
|
330
|
-
stopLiveRefresh();
|
|
331
|
-
done();
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
const detail = wrapConsoleDetail(buildNodeDetail(detailKey), tui.terminal.columns);
|
|
335
|
-
const viewport = maxConsoleBody(tui.terminal.rows);
|
|
336
|
-
// Mouse-wheel scrolling (SGR wheel events, same as pi-btw-fradser).
|
|
337
|
-
const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
|
|
338
|
-
if (sgrWheel) {
|
|
339
|
-
const button = Number.parseInt(sgrWheel[1], 10);
|
|
340
|
-
if ((button & 64) !== 0) {
|
|
341
|
-
const direction = button & 3;
|
|
342
|
-
if (direction === 0) offset = scrollConsoleDetail(offset, -3, detail.length, viewport);
|
|
343
|
-
else if (direction === 1) offset = scrollConsoleDetail(offset, 3, detail.length, viewport);
|
|
344
|
-
}
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, detail.length, viewport);
|
|
348
|
-
else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, detail.length, viewport);
|
|
349
|
-
else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), detail.length, viewport);
|
|
350
|
-
else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), detail.length, viewport);
|
|
351
|
-
else if (matchesKey(data, Key.home)) offset = 0;
|
|
352
|
-
else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, detail.length, viewport);
|
|
371
|
+
handleDetailInput(data);
|
|
353
372
|
return;
|
|
354
373
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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);
|
|
360
381
|
if (matchesKey(data, Key.enter)) {
|
|
361
|
-
const
|
|
362
|
-
|
|
382
|
+
const selection = currentPageSelection(rows.length);
|
|
383
|
+
const row = rows[selection];
|
|
384
|
+
if (row) {
|
|
363
385
|
mode = "detail";
|
|
364
|
-
detailKey = row.key;
|
|
365
386
|
offset = 0;
|
|
387
|
+
detailTitle = row.kind === "teammate" ? `@${row.key}` : `[${row.key}]`;
|
|
366
388
|
}
|
|
367
389
|
return;
|
|
368
390
|
}
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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);
|
|
373
395
|
return;
|
|
374
396
|
}
|
|
375
|
-
if (data === "
|
|
376
|
-
|
|
377
|
-
// dependents cancelled, the rest of the run continues.
|
|
378
|
-
const row = rows[Math.min(selected, rows.length - 1)];
|
|
379
|
-
const entry = row?.key ? getNodeByWorkerKey(row.key) : undefined;
|
|
380
|
-
if (entry && (entry.node.status === "running" || entry.node.status === "pending")) {
|
|
381
|
-
const runId = entry.run.id;
|
|
382
|
-
const nodeId = entry.node.id;
|
|
383
|
-
void cancelNodeAndTerminate(runId, nodeId, { ui: ctx.ui })
|
|
384
|
-
.then((result) => {
|
|
385
|
-
if (result.ok) ctx.ui.notify(`Cancelled node [${runId}/${nodeId}] — the rest of the run continues.`, "info");
|
|
386
|
-
else ctx.ui.notify(result.error, "error");
|
|
387
|
-
})
|
|
388
|
-
.catch(() => false);
|
|
389
|
-
}
|
|
397
|
+
if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
|
|
398
|
+
closeConsole(done);
|
|
390
399
|
return;
|
|
391
400
|
}
|
|
392
401
|
},
|
|
393
|
-
invalidate: () =>
|
|
394
|
-
requestRender();
|
|
395
|
-
},
|
|
402
|
+
invalidate: () => requestRender(),
|
|
396
403
|
dispose: () => {
|
|
397
404
|
closed = true;
|
|
398
405
|
stopLiveRefresh();
|
|
399
406
|
},
|
|
400
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
|
+
}
|
|
401
451
|
});
|
|
402
452
|
}
|
|
403
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
|
+
}
|