pi-crew 0.1.8 → 0.1.9
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/package.json +1 -1
- package/src/extension/help.ts +2 -0
- package/src/extension/register.ts +70 -1
- package/src/runtime/agent-observability.ts +29 -4
- package/src/ui/crew-widget.ts +113 -0
- package/src/ui/run-dashboard.ts +56 -16
- package/src/ui/transcript-viewer.ts +204 -0
package/package.json
CHANGED
package/src/extension/help.ts
CHANGED
|
@@ -18,6 +18,8 @@ export function piTeamsHelp(): string {
|
|
|
18
18
|
"- /team-worktrees <runId>",
|
|
19
19
|
"- /team-api <runId> <operation> [taskId=<taskId>] [body=<message>]",
|
|
20
20
|
"- /team-dashboard",
|
|
21
|
+
"- /team-transcript <runId> [taskId]",
|
|
22
|
+
"- /team-result <runId> [taskId]",
|
|
21
23
|
"- /team-manager",
|
|
22
24
|
"",
|
|
23
25
|
"Maintenance:",
|
|
@@ -10,6 +10,10 @@ import { handleTeamTool, type TeamToolDetails } from "./team-tool.ts";
|
|
|
10
10
|
import { listRuns } from "./run-index.ts";
|
|
11
11
|
import { RunDashboard, type RunDashboardSelection } from "../ui/run-dashboard.ts";
|
|
12
12
|
import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.ts";
|
|
13
|
+
import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
|
|
14
|
+
import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
|
|
15
|
+
import { loadRunManifestById } from "../state/state-store.ts";
|
|
16
|
+
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
13
17
|
|
|
14
18
|
function parseRunArgs(args: string): TeamToolParamsValue {
|
|
15
19
|
const tokens = args.match(/"[^"]*"|'[^']*'|\S+/g)?.map((token) => token.replace(/^['"]|['"]$/g, "")) ?? [];
|
|
@@ -45,6 +49,36 @@ function parseScalar(raw: string): unknown {
|
|
|
45
49
|
return raw;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
async function selectAgentTask(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<{ runId: string; taskId?: string } | undefined> {
|
|
53
|
+
if (!runId) return undefined;
|
|
54
|
+
if (taskId) return { runId, taskId };
|
|
55
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
56
|
+
if (!loaded) return { runId };
|
|
57
|
+
const agents = readCrewAgents(loaded.manifest);
|
|
58
|
+
if (ctx.hasUI && agents.length > 1) {
|
|
59
|
+
const choice = await ctx.ui.select("Select pi-crew agent", agents.map((agent) => `${agent.taskId} ${agent.role}→${agent.agent} [${agent.status}]`));
|
|
60
|
+
return { runId, taskId: choice?.split(" ")[0] };
|
|
61
|
+
}
|
|
62
|
+
return { runId, taskId: agents[0]?.taskId };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function openTranscriptViewer(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<boolean> {
|
|
66
|
+
const selected = await selectAgentTask(ctx, runId, taskId);
|
|
67
|
+
if (!selected) return false;
|
|
68
|
+
// eslint-disable-next-line no-param-reassign
|
|
69
|
+
runId = selected.runId;
|
|
70
|
+
// eslint-disable-next-line no-param-reassign
|
|
71
|
+
taskId = selected.taskId;
|
|
72
|
+
if (!runId || !ctx.hasUI) return false;
|
|
73
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
74
|
+
if (!loaded) return false;
|
|
75
|
+
await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTranscriptViewer(loaded.manifest, theme, done, taskId), {
|
|
76
|
+
overlay: true,
|
|
77
|
+
overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" },
|
|
78
|
+
});
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
48
82
|
function pushUnset(config: Record<string, unknown>, key: string): void {
|
|
49
83
|
const current = Array.isArray(config.unset) ? config.unset : [];
|
|
50
84
|
current.push(key);
|
|
@@ -67,6 +101,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
67
101
|
const notifierState: AsyncNotifierState = { seenFinishedRunIds: new Set() };
|
|
68
102
|
let currentCtx: ExtensionContext | undefined;
|
|
69
103
|
let rpcHandle: PiCrewRpcHandle | undefined;
|
|
104
|
+
const widgetState: CrewWidgetState = { frame: 0 };
|
|
70
105
|
registerAutonomousPolicy(pi);
|
|
71
106
|
rpcHandle = registerPiCrewRpc((pi as unknown as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events, () => currentCtx);
|
|
72
107
|
|
|
@@ -75,9 +110,13 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
75
110
|
notifyActiveRuns(ctx);
|
|
76
111
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
77
112
|
startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? 5000);
|
|
113
|
+
updateCrewWidget(ctx, widgetState);
|
|
114
|
+
widgetState.interval = setInterval(() => { if (currentCtx) updateCrewWidget(currentCtx, widgetState); }, 1000);
|
|
115
|
+
widgetState.interval.unref?.();
|
|
78
116
|
});
|
|
79
117
|
pi.on("session_shutdown", () => {
|
|
80
118
|
stopAsyncRunNotifier(notifierState);
|
|
119
|
+
stopCrewWidget(currentCtx, widgetState);
|
|
81
120
|
currentCtx = undefined;
|
|
82
121
|
rpcHandle?.unsubscribe();
|
|
83
122
|
rpcHandle = undefined;
|
|
@@ -90,7 +129,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
90
129
|
promptSnippet: "Use the team tool proactively for coordinated multi-agent work. If unsure, call { action: 'recommend', goal } first, then run or plan with the suggested team/workflow.",
|
|
91
130
|
parameters: TeamToolParams as never,
|
|
92
131
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
93
|
-
|
|
132
|
+
const output = await handleTeamTool(params as TeamToolParamsValue, ctx);
|
|
133
|
+
updateCrewWidget(ctx, widgetState);
|
|
134
|
+
return output;
|
|
94
135
|
},
|
|
95
136
|
};
|
|
96
137
|
|
|
@@ -250,6 +291,33 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
250
291
|
handler: handleTeamManagerCommand,
|
|
251
292
|
});
|
|
252
293
|
|
|
294
|
+
pi.registerCommand("team-result", {
|
|
295
|
+
description: "Open a pi-crew agent result viewer: <runId> [taskId]",
|
|
296
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
297
|
+
const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
|
|
298
|
+
const selected = await selectAgentTask(ctx, runId, rawTaskId);
|
|
299
|
+
const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined;
|
|
300
|
+
if (ctx.hasUI && loaded) {
|
|
301
|
+
const agent = readCrewAgents(loaded.manifest).find((item) => item.taskId === selected?.taskId || item.id === selected?.taskId) ?? readCrewAgents(loaded.manifest)[0];
|
|
302
|
+
const text = agent?.resultArtifactPath ? commandText(await handleTeamTool({ action: "api", runId: selected!.runId, config: { operation: "read-agent-output", agentId: agent.taskId, maxBytes: 64_000 } }, ctx)) : "(no result)";
|
|
303
|
+
await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => new DurableTextViewer("pi-crew result", `${selected!.runId}:${agent?.taskId ?? "unknown"}`, text.split(/\r?\n/), theme, done), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-output", agentId: rawTaskId, maxBytes: 64_000 } }, ctx);
|
|
307
|
+
await notifyCommandResult(ctx, commandText(result));
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
pi.registerCommand("team-transcript", {
|
|
312
|
+
description: "Open a pi-crew transcript viewer: <runId> [taskId]",
|
|
313
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
314
|
+
const [runId, taskId] = args.trim().split(/\s+/).filter(Boolean);
|
|
315
|
+
if (await openTranscriptViewer(ctx, runId, taskId)) return;
|
|
316
|
+
const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-transcript", agentId: taskId } }, ctx);
|
|
317
|
+
await notifyCommandResult(ctx, commandText(result));
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
253
321
|
pi.registerCommand("team-dashboard", {
|
|
254
322
|
description: "Open a pi-crew run dashboard overlay",
|
|
255
323
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
@@ -261,6 +329,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
261
329
|
});
|
|
262
330
|
if (!selection) return;
|
|
263
331
|
if (selection.action === "reload") continue;
|
|
332
|
+
if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
|
|
264
333
|
const result = selection.action === "api"
|
|
265
334
|
? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, ctx)
|
|
266
335
|
: selection.action === "agents"
|
|
@@ -3,6 +3,16 @@ import type { TeamRunManifest } from "../state/types.ts";
|
|
|
3
3
|
import { agentOutputPath, readCrewAgents } from "./crew-agent-records.ts";
|
|
4
4
|
import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
|
|
5
5
|
|
|
6
|
+
const TOOL_LABELS: Record<string, string> = {
|
|
7
|
+
read: "reading",
|
|
8
|
+
bash: "running command",
|
|
9
|
+
edit: "editing",
|
|
10
|
+
write: "writing",
|
|
11
|
+
grep: "searching",
|
|
12
|
+
find: "finding files",
|
|
13
|
+
ls: "listing",
|
|
14
|
+
};
|
|
15
|
+
|
|
6
16
|
export interface TextTailResult {
|
|
7
17
|
path: string;
|
|
8
18
|
text: string;
|
|
@@ -24,14 +34,29 @@ export function readTextTail(filePath: string, maxBytes = 64_000): TextTailResul
|
|
|
24
34
|
}
|
|
25
35
|
}
|
|
26
36
|
|
|
37
|
+
function compactDuration(ms: number | undefined): string | undefined {
|
|
38
|
+
if (ms === undefined || !Number.isFinite(ms)) return undefined;
|
|
39
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
40
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
41
|
+
return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ageBetween(start: string | undefined, end: string | undefined): string | undefined {
|
|
45
|
+
if (!start) return undefined;
|
|
46
|
+
const stop = end ? new Date(end).getTime() : Date.now();
|
|
47
|
+
const ms = Math.max(0, stop - new Date(start).getTime());
|
|
48
|
+
return compactDuration(ms);
|
|
49
|
+
}
|
|
50
|
+
|
|
27
51
|
function activityText(agent: CrewAgentRecord): string {
|
|
28
52
|
const parts: string[] = [];
|
|
29
53
|
if (agent.progress?.activityState) parts.push(agent.progress.activityState);
|
|
30
|
-
if (agent.progress?.currentTool) parts.push(`tool=${agent.progress.currentTool}`);
|
|
54
|
+
if (agent.progress?.currentTool) parts.push(TOOL_LABELS[agent.progress.currentTool] ?? `tool=${agent.progress.currentTool}`);
|
|
31
55
|
if (agent.toolUses !== undefined) parts.push(`tools=${agent.toolUses}`);
|
|
32
56
|
if (agent.progress?.tokens !== undefined) parts.push(`tokens=${agent.progress.tokens}`);
|
|
33
57
|
if (agent.progress?.turns !== undefined) parts.push(`turns=${agent.progress.turns}`);
|
|
34
|
-
|
|
58
|
+
const duration = compactDuration(agent.progress?.durationMs) ?? ageBetween(agent.startedAt, agent.completedAt);
|
|
59
|
+
if (duration) parts.push(duration);
|
|
35
60
|
if (agent.progress?.failedTool) parts.push(`failedTool=${agent.progress.failedTool}`);
|
|
36
61
|
if (agent.progress?.recentOutput?.length) parts.push(`last=${agent.progress.recentOutput.at(-1)}`);
|
|
37
62
|
return parts.join(" ") || "idle";
|
|
@@ -56,7 +81,7 @@ function outputWarning(agent: CrewAgentRecord): string {
|
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
function agentLine(agent: CrewAgentRecord): string {
|
|
59
|
-
return `- ${statusGlyph(agent.status)} ${agent.taskId}
|
|
84
|
+
return `- ${statusGlyph(agent.status)} ${agent.taskId} ${agent.role} → ${agent.agent} · ${agent.status} · ${agent.runtime} · ${activityText(agent)}${outputWarning(agent)}${agent.error ? ` · error=${agent.error}` : ""}`;
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
export function buildAgentDashboard(manifest: TeamRunManifest): { text: string; groups: Record<string, CrewAgentRecord[]> } {
|
|
@@ -68,7 +93,7 @@ export function buildAgentDashboard(manifest: TeamRunManifest): { text: string;
|
|
|
68
93
|
};
|
|
69
94
|
const lines = [
|
|
70
95
|
`Crew agents for ${manifest.runId}`,
|
|
71
|
-
`Run
|
|
96
|
+
`Run: ${manifest.status} · ${manifest.team}/${manifest.workflow ?? "none"} · agents=${agents.length}`,
|
|
72
97
|
`Counts: running=${groups.running.length}, queued=${groups.queued.length}, recent=${groups.recent.length}`,
|
|
73
98
|
"",
|
|
74
99
|
"## Running",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { listRuns } from "../extension/run-index.ts";
|
|
3
|
+
import { isActiveRunStatus } from "../runtime/process-status.ts";
|
|
4
|
+
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
5
|
+
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
6
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
7
|
+
|
|
8
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
|
+
const TOOL_LABELS: Record<string, string> = {
|
|
10
|
+
read: "reading",
|
|
11
|
+
bash: "running command",
|
|
12
|
+
edit: "editing",
|
|
13
|
+
write: "writing",
|
|
14
|
+
grep: "searching",
|
|
15
|
+
find: "finding files",
|
|
16
|
+
ls: "listing",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface CrewWidgetState {
|
|
20
|
+
frame: number;
|
|
21
|
+
interval?: ReturnType<typeof setInterval>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function elapsed(iso: string | undefined, now = Date.now()): string | undefined {
|
|
25
|
+
if (!iso) return undefined;
|
|
26
|
+
const ms = Math.max(0, now - new Date(iso).getTime());
|
|
27
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
28
|
+
if (ms < 1000) return "now";
|
|
29
|
+
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
30
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
|
|
31
|
+
return `${Math.floor(ms / 3_600_000)}h`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function glyph(status: string, runningGlyph: string): string {
|
|
35
|
+
if (status === "running") return runningGlyph;
|
|
36
|
+
if (status === "queued") return "◦";
|
|
37
|
+
if (status === "completed") return "✓";
|
|
38
|
+
if (status === "failed") return "✗";
|
|
39
|
+
if (status === "cancelled" || status === "stopped") return "■";
|
|
40
|
+
return "·";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function agentActivity(agent: CrewAgentRecord): string {
|
|
44
|
+
if (agent.progress?.currentTool) return `${TOOL_LABELS[agent.progress.currentTool] ?? agent.progress.currentTool}…`;
|
|
45
|
+
const recent = agent.progress?.recentOutput?.at(-1);
|
|
46
|
+
if (recent) return recent.replace(/\s+/g, " ").trim();
|
|
47
|
+
if (agent.progress?.activityState === "needs_attention") return "needs attention";
|
|
48
|
+
if (agent.status === "queued") return "queued…";
|
|
49
|
+
if (agent.status === "running") return "thinking…";
|
|
50
|
+
if (agent.status === "failed") return agent.error ?? "failed";
|
|
51
|
+
return "done";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function agentStats(agent: CrewAgentRecord): string {
|
|
55
|
+
const parts: string[] = [];
|
|
56
|
+
if (agent.toolUses) parts.push(`${agent.toolUses} tools`);
|
|
57
|
+
if (agent.progress?.tokens) parts.push(`${agent.progress.tokens} tok`);
|
|
58
|
+
if (agent.progress?.turns) parts.push(`⟳${agent.progress.turns}`);
|
|
59
|
+
const age = elapsed(agent.completedAt ?? agent.startedAt);
|
|
60
|
+
if (age) parts.push(agent.completedAt ? age : `${age} ago`);
|
|
61
|
+
return parts.join(" · ");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runStep(run: TeamRunManifest, agents: CrewAgentRecord[]): string {
|
|
65
|
+
const running = agents.find((agent) => agent.status === "running");
|
|
66
|
+
if (running) return running.taskId;
|
|
67
|
+
const queued = agents.find((agent) => agent.status === "queued");
|
|
68
|
+
if (queued) return queued.taskId;
|
|
69
|
+
return run.status;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
|
|
73
|
+
const runs = listRuns(cwd).slice(0, 20);
|
|
74
|
+
const activeRuns = runs.filter((run) => isActiveRunStatus(run.status));
|
|
75
|
+
const recentRuns = runs.filter((run) => !isActiveRunStatus(run.status)).slice(0, 3);
|
|
76
|
+
const shownRuns = [...activeRuns, ...recentRuns];
|
|
77
|
+
if (!shownRuns.length) return [];
|
|
78
|
+
const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
|
|
79
|
+
const lines: string[] = [];
|
|
80
|
+
const activeCount = activeRuns.length;
|
|
81
|
+
lines.push(`${activeCount ? "●" : "○"} pi-crew · active=${activeCount} recent=${recentRuns.length} · /team-dashboard`);
|
|
82
|
+
for (const run of shownRuns) {
|
|
83
|
+
let agents: CrewAgentRecord[] = [];
|
|
84
|
+
try { agents = readCrewAgents(run); } catch { agents = []; }
|
|
85
|
+
const counts = new Map<string, number>();
|
|
86
|
+
for (const agent of agents) counts.set(agent.status, (counts.get(agent.status) ?? 0) + 1);
|
|
87
|
+
const countText = [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(" ") || run.status;
|
|
88
|
+
lines.push(`${glyph(run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${runStep(run, agents)} · ${countText}`);
|
|
89
|
+
for (const agent of agents.filter((item) => item.status === "running" || item.status === "queued").slice(0, 2)) {
|
|
90
|
+
const stats = agentStats(agent);
|
|
91
|
+
lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
|
|
92
|
+
}
|
|
93
|
+
if (lines.length >= maxLines) break;
|
|
94
|
+
}
|
|
95
|
+
return lines.slice(0, maxLines);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState): void {
|
|
99
|
+
if (!ctx.hasUI) return;
|
|
100
|
+
state.frame += 1;
|
|
101
|
+
const lines = buildCrewWidgetLines(ctx.cwd, state.frame);
|
|
102
|
+
ctx.ui.setStatus("pi-crew", lines.length ? lines[0] : undefined);
|
|
103
|
+
ctx.ui.setWidget("pi-crew", lines.length ? lines : undefined, { placement: "aboveEditor" });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState): void {
|
|
107
|
+
if (state.interval) clearInterval(state.interval);
|
|
108
|
+
state.interval = undefined;
|
|
109
|
+
if (ctx?.hasUI) {
|
|
110
|
+
ctx.ui.setStatus("pi-crew", undefined);
|
|
111
|
+
ctx.ui.setWidget("pi-crew", undefined, { placement: "aboveEditor" });
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
3
3
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
4
4
|
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
5
|
+
import { isActiveRunStatus } from "../runtime/process-status.ts";
|
|
5
6
|
|
|
6
7
|
interface DashboardComponent {
|
|
7
8
|
invalidate(): void;
|
|
@@ -68,6 +69,16 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5): string[] {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function formatAge(iso: string | undefined): string | undefined {
|
|
73
|
+
if (!iso) return undefined;
|
|
74
|
+
const ms = Math.max(0, Date.now() - new Date(iso).getTime());
|
|
75
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
76
|
+
if (ms < 1000) return "now";
|
|
77
|
+
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
|
|
78
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`;
|
|
79
|
+
return `${Math.floor(ms / 3_600_000)}h`;
|
|
80
|
+
}
|
|
81
|
+
|
|
71
82
|
function agentPreviewLine(agent: CrewAgentRecord): string {
|
|
72
83
|
const stats = [
|
|
73
84
|
agent.progress?.activityState,
|
|
@@ -76,6 +87,7 @@ function agentPreviewLine(agent: CrewAgentRecord): string {
|
|
|
76
87
|
agent.progress?.tokens !== undefined ? `${agent.progress.tokens} tok` : undefined,
|
|
77
88
|
agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
|
|
78
89
|
agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
|
|
90
|
+
agent.startedAt ? `age=${formatAge(agent.completedAt ?? agent.startedAt)}` : undefined,
|
|
79
91
|
].filter((part): part is string => Boolean(part));
|
|
80
92
|
const recent = agent.progress?.recentOutput?.at(-1);
|
|
81
93
|
return `Agent: ${statusIcon(agent.status)} ${agent.taskId} ${agent.role}->${agent.agent}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`;
|
|
@@ -92,6 +104,29 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5): string[] {
|
|
|
92
104
|
}
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
function runLabel(run: TeamRunManifest, selected: boolean): string {
|
|
108
|
+
let agents: CrewAgentRecord[] = [];
|
|
109
|
+
try { agents = readCrewAgents(run); } catch { agents = []; }
|
|
110
|
+
const running = agents.find((agent) => agent.status === "running");
|
|
111
|
+
const queued = agents.find((agent) => agent.status === "queued");
|
|
112
|
+
const step = running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
|
|
113
|
+
const marker = selected ? "›" : " ";
|
|
114
|
+
return `${marker} ${statusIcon(run.status)} ${run.runId.slice(-8)} ${run.status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function groupedRuns(runs: TeamRunManifest[]): Array<{ label: string; run?: TeamRunManifest }> {
|
|
118
|
+
const active = runs.filter((run) => isActiveRunStatus(run.status));
|
|
119
|
+
const recent = runs.filter((run) => !isActiveRunStatus(run.status));
|
|
120
|
+
const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
|
|
121
|
+
if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
|
|
122
|
+
if (recent.length) rows.push({ label: "Recent" }, ...recent.map((run) => ({ label: run.runId, run })));
|
|
123
|
+
return rows;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function selectedRunFromGrouped(runs: TeamRunManifest[], selected: number): TeamRunManifest | undefined {
|
|
127
|
+
return groupedRuns(runs).filter((row) => row.run)[selected]?.run;
|
|
128
|
+
}
|
|
129
|
+
|
|
95
130
|
function countByStatus(runs: TeamRunManifest[]): string {
|
|
96
131
|
const counts = new Map<string, number>();
|
|
97
132
|
for (const run of runs) counts.set(run.status, (counts.get(run.status) ?? 0) + 1);
|
|
@@ -124,13 +159,17 @@ export class RunDashboard implements DashboardComponent {
|
|
|
124
159
|
if (this.runs.length === 0) {
|
|
125
160
|
lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
|
|
126
161
|
} else {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
const rows = groupedRuns(this.runs).slice(0, 16);
|
|
163
|
+
const runRows = rows.filter((row) => row.run);
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
if (!row.run) {
|
|
166
|
+
lines.push(`│ ${padVisible(truncate(row.label, innerWidth - 1), innerWidth - 1)}│`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const index = runRows.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
|
|
170
|
+
lines.push(`│ ${padVisible(truncate(runLabel(row.run, index === this.selected), innerWidth - 1), innerWidth - 1)}│`);
|
|
132
171
|
}
|
|
133
|
-
const selectedRun = this.runs
|
|
172
|
+
const selectedRun = selectedRunFromGrouped(this.runs, this.selected);
|
|
134
173
|
if (selectedRun) {
|
|
135
174
|
lines.push(`├${"─".repeat(borderWidth)}┤`);
|
|
136
175
|
const details = [
|
|
@@ -157,47 +196,47 @@ export class RunDashboard implements DashboardComponent {
|
|
|
157
196
|
return;
|
|
158
197
|
}
|
|
159
198
|
if (data === "\r" || data === "\n" || data === "s") {
|
|
160
|
-
const runId = this.runs
|
|
199
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
161
200
|
this.done(runId ? { runId, action: "status" } : undefined);
|
|
162
201
|
return;
|
|
163
202
|
}
|
|
164
203
|
if (data === "u") {
|
|
165
|
-
const runId = this.runs
|
|
204
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
166
205
|
this.done(runId ? { runId, action: "summary" } : undefined);
|
|
167
206
|
return;
|
|
168
207
|
}
|
|
169
208
|
if (data === "a") {
|
|
170
|
-
const runId = this.runs
|
|
209
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
171
210
|
this.done(runId ? { runId, action: "artifacts" } : undefined);
|
|
172
211
|
return;
|
|
173
212
|
}
|
|
174
213
|
if (data === "i") {
|
|
175
|
-
const runId = this.runs
|
|
214
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
176
215
|
this.done(runId ? { runId, action: "api" } : undefined);
|
|
177
216
|
return;
|
|
178
217
|
}
|
|
179
218
|
if (data === "d") {
|
|
180
|
-
const runId = this.runs
|
|
219
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
181
220
|
this.done(runId ? { runId, action: "agents" } : undefined);
|
|
182
221
|
return;
|
|
183
222
|
}
|
|
184
223
|
if (data === "e") {
|
|
185
|
-
const runId = this.runs
|
|
224
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
186
225
|
this.done(runId ? { runId, action: "agent-events" } : undefined);
|
|
187
226
|
return;
|
|
188
227
|
}
|
|
189
228
|
if (data === "o") {
|
|
190
|
-
const runId = this.runs
|
|
229
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
191
230
|
this.done(runId ? { runId, action: "agent-output" } : undefined);
|
|
192
231
|
return;
|
|
193
232
|
}
|
|
194
233
|
if (data === "v") {
|
|
195
|
-
const runId = this.runs
|
|
234
|
+
const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
|
|
196
235
|
this.done(runId ? { runId, action: "agent-transcript" } : undefined);
|
|
197
236
|
return;
|
|
198
237
|
}
|
|
199
238
|
if (data === "r") {
|
|
200
|
-
this.done({ runId: this.runs
|
|
239
|
+
this.done({ runId: selectedRunFromGrouped(this.runs, this.selected)?.runId ?? "", action: "reload" });
|
|
201
240
|
return;
|
|
202
241
|
}
|
|
203
242
|
if (data === "p") {
|
|
@@ -209,7 +248,8 @@ export class RunDashboard implements DashboardComponent {
|
|
|
209
248
|
return;
|
|
210
249
|
}
|
|
211
250
|
if (data === "j" || data === "\u001b[B") {
|
|
212
|
-
|
|
251
|
+
const selectableCount = groupedRuns(this.runs).filter((row) => row.run).length;
|
|
252
|
+
this.selected = Math.min(Math.max(0, selectableCount - 1), this.selected + 1);
|
|
213
253
|
}
|
|
214
254
|
}
|
|
215
255
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
type Component = { invalidate(): void; render(width: number): string[]; handleInput(data: string): void };
|
|
3
|
+
type TranscriptTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
4
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
5
|
+
import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
6
|
+
|
|
7
|
+
function visibleWidth(text: string): number {
|
|
8
|
+
return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function truncate(text: string, width: number): string {
|
|
12
|
+
if (width <= 0) return "";
|
|
13
|
+
if (visibleWidth(text) <= width) return text;
|
|
14
|
+
return width <= 1 ? "…" : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function wrap(text: string, width: number): string[] {
|
|
18
|
+
const source = text.split(/\r?\n/);
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
for (const raw of source) {
|
|
21
|
+
const line = raw || " ";
|
|
22
|
+
if (line.length <= width) {
|
|
23
|
+
lines.push(line);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
for (let index = 0; index < line.length; index += width) lines.push(line.slice(index, index + width));
|
|
27
|
+
}
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
32
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function textFromContent(content: unknown): string {
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
if (!Array.isArray(content)) return "";
|
|
38
|
+
return content.map((part) => {
|
|
39
|
+
const obj = asRecord(part);
|
|
40
|
+
if (!obj) return "";
|
|
41
|
+
if (typeof obj.text === "string") return obj.text;
|
|
42
|
+
if (typeof obj.content === "string") return obj.content;
|
|
43
|
+
if (typeof obj.name === "string") return `[tool:${obj.name}]`;
|
|
44
|
+
return "";
|
|
45
|
+
}).filter(Boolean).join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatTranscriptEvent(event: unknown): string[] {
|
|
49
|
+
const obj = asRecord(event);
|
|
50
|
+
if (!obj) return [String(event)];
|
|
51
|
+
const type = typeof obj.type === "string" ? obj.type : undefined;
|
|
52
|
+
const toolName = typeof obj.toolName === "string" ? obj.toolName : typeof obj.name === "string" ? obj.name : undefined;
|
|
53
|
+
if (type && /tool/i.test(type)) {
|
|
54
|
+
const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : typeof obj.result === "string" ? obj.result : "");
|
|
55
|
+
return [`[tool${toolName ? `:${toolName}` : ""} ${type}]: ${text.trim() || "(no output)"}`];
|
|
56
|
+
}
|
|
57
|
+
const message = asRecord(obj.message);
|
|
58
|
+
if (message) {
|
|
59
|
+
const role = typeof message.role === "string" ? message.role : "message";
|
|
60
|
+
const text = textFromContent(message.content);
|
|
61
|
+
if (text.trim()) return [`[${role}]: ${text.trim()}`];
|
|
62
|
+
}
|
|
63
|
+
if (type) {
|
|
64
|
+
const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : "");
|
|
65
|
+
return text.trim() ? [`[${type}]: ${text.trim()}`] : [`[${type}]`];
|
|
66
|
+
}
|
|
67
|
+
return [JSON.stringify(event)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatTranscriptText(text: string): string[] {
|
|
71
|
+
const lines: string[] = [];
|
|
72
|
+
for (const raw of text.split(/\r?\n/).filter(Boolean)) {
|
|
73
|
+
try {
|
|
74
|
+
lines.push(...formatTranscriptEvent(JSON.parse(raw)));
|
|
75
|
+
} catch {
|
|
76
|
+
lines.push(raw);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return lines.length ? lines : ["(no transcript content)"];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readRunTranscript(manifest: TeamRunManifest, taskId?: string): { title: string; path: string; lines: string[] } {
|
|
83
|
+
const agents = readCrewAgents(manifest);
|
|
84
|
+
const agent = taskId ? agents.find((item) => item.taskId === taskId || item.id === taskId) : agents.find((item) => item.transcriptPath) ?? agents[0];
|
|
85
|
+
const selectedTaskId = agent?.taskId ?? taskId ?? "unknown";
|
|
86
|
+
const transcriptPath = agent?.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(manifest, selectedTaskId);
|
|
87
|
+
const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
|
|
88
|
+
return { title: `${manifest.runId}:${selectedTaskId}`, path: transcriptPath, lines: formatTranscriptText(text) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class DurableTextViewer implements Component {
|
|
92
|
+
private scroll = 0;
|
|
93
|
+
private lastHeight = 10;
|
|
94
|
+
private title: string;
|
|
95
|
+
private subtitle: string;
|
|
96
|
+
private lines: string[];
|
|
97
|
+
private theme: unknown;
|
|
98
|
+
private done: (result: undefined) => void;
|
|
99
|
+
|
|
100
|
+
constructor(title: string, subtitle: string, lines: string[], theme: unknown, done: (result: undefined) => void) {
|
|
101
|
+
this.title = title;
|
|
102
|
+
this.subtitle = subtitle;
|
|
103
|
+
this.lines = lines.length ? lines : ["(empty)"];
|
|
104
|
+
this.theme = theme;
|
|
105
|
+
this.done = done;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
invalidate(): void {}
|
|
109
|
+
|
|
110
|
+
handleInput(data: string): void {
|
|
111
|
+
if (data === "q" || data === "\u001b") {
|
|
112
|
+
this.done(undefined);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const maxScroll = Math.max(0, this.lines.length - this.lastHeight);
|
|
116
|
+
if (data === "k" || data === "\u001b[A") this.scroll = Math.max(0, this.scroll - 1);
|
|
117
|
+
else if (data === "j" || data === "\u001b[B") this.scroll = Math.min(maxScroll, this.scroll + 1);
|
|
118
|
+
else if (data === "g") this.scroll = 0;
|
|
119
|
+
else if (data === "G") this.scroll = maxScroll;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
render(width: number): string[] {
|
|
123
|
+
const th = this.theme as TranscriptTheme;
|
|
124
|
+
const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
|
|
125
|
+
const bold = th.bold?.bind(th) ?? ((text: string) => text);
|
|
126
|
+
const inner = Math.max(20, width - 4);
|
|
127
|
+
this.lastHeight = 16;
|
|
128
|
+
const body = this.lines.flatMap((line) => wrap(line, inner));
|
|
129
|
+
const maxScroll = Math.max(0, body.length - this.lastHeight);
|
|
130
|
+
this.scroll = Math.min(this.scroll, maxScroll);
|
|
131
|
+
const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
|
|
132
|
+
const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
|
|
133
|
+
const row = (text: string) => `${fg("border", "│")} ${truncate(pad(text), inner)} ${fg("border", "│")}`;
|
|
134
|
+
return [
|
|
135
|
+
fg("border", `╭${"─".repeat(inner + 2)}╮`),
|
|
136
|
+
row(`${bold(this.title)} ${fg("dim", this.subtitle)}`),
|
|
137
|
+
row(fg("dim", "j/k scroll · g/G top/bottom · q close")),
|
|
138
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
139
|
+
...visible.map(row),
|
|
140
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
141
|
+
row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}%`)),
|
|
142
|
+
fg("border", `╰${"─".repeat(inner + 2)}╯`),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class DurableTranscriptViewer implements Component {
|
|
148
|
+
private scroll = 0;
|
|
149
|
+
private lastHeight = 10;
|
|
150
|
+
private manifest: TeamRunManifest;
|
|
151
|
+
private theme: unknown;
|
|
152
|
+
private done: (result: undefined) => void;
|
|
153
|
+
private taskId?: string;
|
|
154
|
+
|
|
155
|
+
constructor(manifest: TeamRunManifest, theme: unknown, done: (result: undefined) => void, taskId?: string) {
|
|
156
|
+
this.manifest = manifest;
|
|
157
|
+
this.theme = theme;
|
|
158
|
+
this.done = done;
|
|
159
|
+
this.taskId = taskId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
invalidate(): void {}
|
|
163
|
+
|
|
164
|
+
handleInput(data: string): void {
|
|
165
|
+
if (data === "q" || data === "\u001b") {
|
|
166
|
+
this.done(undefined);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const content = readRunTranscript(this.manifest, this.taskId).lines;
|
|
170
|
+
const maxScroll = Math.max(0, content.length - this.lastHeight);
|
|
171
|
+
if (data === "k" || data === "\u001b[A") this.scroll = Math.max(0, this.scroll - 1);
|
|
172
|
+
else if (data === "j" || data === "\u001b[B") this.scroll = Math.min(maxScroll, this.scroll + 1);
|
|
173
|
+
else if (data === "g") this.scroll = 0;
|
|
174
|
+
else if (data === "G") this.scroll = maxScroll;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
render(width: number): string[] {
|
|
178
|
+
const th = this.theme as TranscriptTheme;
|
|
179
|
+
const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
|
|
180
|
+
const bold = th.bold?.bind(th) ?? ((text: string) => text);
|
|
181
|
+
const inner = Math.max(20, width - 4);
|
|
182
|
+
const data = readRunTranscript(this.manifest, this.taskId);
|
|
183
|
+
const body = data.lines.flatMap((line) => wrap(line, inner));
|
|
184
|
+
this.lastHeight = 16;
|
|
185
|
+
const maxScroll = Math.max(0, body.length - this.lastHeight);
|
|
186
|
+
this.scroll = Math.min(this.scroll, maxScroll);
|
|
187
|
+
const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
|
|
188
|
+
const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
|
|
189
|
+
const row = (text: string) => `${fg("border", "│")} ${truncate(pad(text), inner)} ${fg("border", "│")}`;
|
|
190
|
+
const lines = [
|
|
191
|
+
fg("border", `╭${"─".repeat(inner + 2)}╮`),
|
|
192
|
+
row(`${bold("pi-crew transcript")} ${fg("dim", data.title)}`),
|
|
193
|
+
row(fg("dim", data.path)),
|
|
194
|
+
row(fg("dim", "j/k scroll · g/G top/bottom · q close")),
|
|
195
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
196
|
+
...visible.map(row),
|
|
197
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
198
|
+
row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}%`)),
|
|
199
|
+
fg("border", `╰${"─".repeat(inner + 2)}╯`),
|
|
200
|
+
];
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|