pi-crew 0.1.8 → 0.1.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -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
- return await handleTeamTool(params as TeamToolParamsValue, ctx);
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
- if (agent.progress?.durationMs !== undefined) parts.push(`durationMs=${agent.progress.durationMs}`);
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} [${agent.status}] ${agent.role}->${agent.agent} runtime=${agent.runtime} ${activityText(agent)}${outputWarning(agent)}${agent.error ? ` error=${agent.error}` : ""}`;
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 status: ${manifest.status}`,
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",
@@ -1,9 +1,14 @@
1
+ import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
2
+ import type { TeamRunManifest } from "../state/types.ts";
3
+
1
4
  export interface ProcessLiveness {
2
5
  pid?: number;
3
6
  alive: boolean;
4
7
  detail: string;
5
8
  }
6
9
 
10
+ const ORPHANED_ACTIVE_RUN_MS = 10 * 60 * 1000;
11
+
7
12
  export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
8
13
  if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
9
14
  return { pid, alive: false, detail: "no pid recorded" };
@@ -23,3 +28,16 @@ export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
23
28
  export function isActiveRunStatus(status: string): boolean {
24
29
  return status === "queued" || status === "planning" || status === "running";
25
30
  }
31
+
32
+ export function isLikelyOrphanedActiveRun(run: TeamRunManifest, agents: CrewAgentRecord[] = [], now = Date.now(), staleMs = ORPHANED_ACTIVE_RUN_MS): boolean {
33
+ if (!isActiveRunStatus(run.status)) return false;
34
+ if (run.async?.pid !== undefined) return false;
35
+ const updatedAt = new Date(run.updatedAt).getTime();
36
+ if (!Number.isFinite(updatedAt) || now - updatedAt < staleMs) return false;
37
+ if (agents.length === 0) return run.summary === "Creating workflow prompts and placeholder results.";
38
+ return agents.every((agent) => agent.status === "queued" && !agent.completedAt && !agent.progress);
39
+ }
40
+
41
+ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord[] = [], now = Date.now()): boolean {
42
+ return isActiveRunStatus(run.status) && !isLikelyOrphanedActiveRun(run, agents, now);
43
+ }
@@ -0,0 +1,119 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { listRuns } from "../extension/run-index.ts";
3
+ import { isDisplayActiveRun, isLikelyOrphanedActiveRun } 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
+ if (isLikelyOrphanedActiveRun(run, agents)) return "stale";
66
+ const running = agents.find((agent) => agent.status === "running");
67
+ if (running) return running.taskId;
68
+ const queued = agents.find((agent) => agent.status === "queued");
69
+ if (queued) return queued.taskId;
70
+ return run.status;
71
+ }
72
+
73
+ function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
74
+ try { return readCrewAgents(run); } catch { return []; }
75
+ }
76
+
77
+ export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
78
+ const runs = listRuns(cwd).slice(0, 20);
79
+ const runAgents = new Map(runs.map((run) => [run.runId, agentsFor(run)]));
80
+ const activeRuns = runs.filter((run) => isDisplayActiveRun(run, runAgents.get(run.runId) ?? []));
81
+ const recentRuns = runs.filter((run) => !isDisplayActiveRun(run, runAgents.get(run.runId) ?? [])).slice(0, 3);
82
+ const shownRuns = [...activeRuns, ...recentRuns];
83
+ if (!shownRuns.length) return [];
84
+ const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
85
+ const lines: string[] = [];
86
+ const activeCount = activeRuns.length;
87
+ lines.push(`${activeCount ? "●" : "○"} pi-crew · active=${activeCount} recent=${recentRuns.length} · /team-dashboard`);
88
+ for (const run of shownRuns) {
89
+ const agents = runAgents.get(run.runId) ?? [];
90
+ const stale = isLikelyOrphanedActiveRun(run, agents);
91
+ const counts = new Map<string, number>();
92
+ for (const agent of agents) counts.set(agent.status, (counts.get(agent.status) ?? 0) + 1);
93
+ const countText = stale ? "stale queued run" : [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(" ") || run.status;
94
+ lines.push(`${glyph(stale ? "failed" : run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${runStep(run, agents)} · ${countText}`);
95
+ for (const agent of (stale ? [] : agents.filter((item) => item.status === "running" || item.status === "queued").slice(0, 2))) {
96
+ const stats = agentStats(agent);
97
+ lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
98
+ }
99
+ if (lines.length >= maxLines) break;
100
+ }
101
+ return lines.slice(0, maxLines);
102
+ }
103
+
104
+ export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState): void {
105
+ if (!ctx.hasUI) return;
106
+ state.frame += 1;
107
+ const lines = buildCrewWidgetLines(ctx.cwd, state.frame);
108
+ ctx.ui.setStatus("pi-crew", lines.length ? lines[0] : undefined);
109
+ ctx.ui.setWidget("pi-crew", lines.length ? lines : undefined, { placement: "aboveEditor" });
110
+ }
111
+
112
+ export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState): void {
113
+ if (state.interval) clearInterval(state.interval);
114
+ state.interval = undefined;
115
+ if (ctx?.hasUI) {
116
+ ctx.ui.setStatus("pi-crew", undefined);
117
+ ctx.ui.setWidget("pi-crew", undefined, { placement: "aboveEditor" });
118
+ }
119
+ }
@@ -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 { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
5
6
 
6
7
  interface DashboardComponent {
7
8
  invalidate(): void;
@@ -50,7 +51,7 @@ function padVisible(value: string, width: number): string {
50
51
 
51
52
  function statusIcon(status: string): string {
52
53
  if (status === "completed") return "✓";
53
- if (status === "failed") return "✗";
54
+ if (status === "failed" || status === "stale") return "✗";
54
55
  if (status === "cancelled") return "!";
55
56
  if (status === "running") return "▶";
56
57
  if (status === "blocked") return "■";
@@ -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,34 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5): string[] {
92
104
  }
93
105
  }
94
106
 
107
+ function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
108
+ try { return readCrewAgents(run); } catch { return []; }
109
+ }
110
+
111
+ function runLabel(run: TeamRunManifest, selected: boolean): string {
112
+ const agents = agentsFor(run);
113
+ const stale = isLikelyOrphanedActiveRun(run, agents);
114
+ const running = agents.find((agent) => agent.status === "running");
115
+ const queued = agents.find((agent) => agent.status === "queued");
116
+ const step = stale ? "orphaned queued run" : running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
117
+ const status = stale ? "stale" : run.status;
118
+ const marker = selected ? "›" : " ";
119
+ return `${marker} ${statusIcon(status)} ${run.runId.slice(-8)} ${status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
120
+ }
121
+
122
+ function groupedRuns(runs: TeamRunManifest[]): Array<{ label: string; run?: TeamRunManifest }> {
123
+ const active = runs.filter((run) => isDisplayActiveRun(run, agentsFor(run)));
124
+ const recent = runs.filter((run) => !isDisplayActiveRun(run, agentsFor(run)));
125
+ const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
126
+ if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
127
+ if (recent.length) rows.push({ label: "Recent" }, ...recent.map((run) => ({ label: run.runId, run })));
128
+ return rows;
129
+ }
130
+
131
+ function selectedRunFromGrouped(runs: TeamRunManifest[], selected: number): TeamRunManifest | undefined {
132
+ return groupedRuns(runs).filter((row) => row.run)[selected]?.run;
133
+ }
134
+
95
135
  function countByStatus(runs: TeamRunManifest[]): string {
96
136
  const counts = new Map<string, number>();
97
137
  for (const run of runs) counts.set(run.status, (counts.get(run.status) ?? 0) + 1);
@@ -124,13 +164,17 @@ export class RunDashboard implements DashboardComponent {
124
164
  if (this.runs.length === 0) {
125
165
  lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
126
166
  } else {
127
- for (let i = 0; i < Math.min(this.runs.length, 10); i++) {
128
- const run = this.runs[i]!;
129
- const marker = i === this.selected ? "›" : " ";
130
- const text = `${marker} ${statusIcon(run.status)} ${run.runId} ${run.status} ${run.team}/${run.workflow ?? "none"} ${run.goal}`;
131
- lines.push(`│ ${padVisible(truncate(text, innerWidth - 1), innerWidth - 1)}│`);
167
+ const rows = groupedRuns(this.runs).slice(0, 16);
168
+ const runRows = rows.filter((row) => row.run);
169
+ for (const row of rows) {
170
+ if (!row.run) {
171
+ lines.push(`│ ${padVisible(truncate(row.label, innerWidth - 1), innerWidth - 1)}│`);
172
+ continue;
173
+ }
174
+ const index = runRows.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
175
+ lines.push(`│ ${padVisible(truncate(runLabel(row.run, index === this.selected), innerWidth - 1), innerWidth - 1)}│`);
132
176
  }
133
- const selectedRun = this.runs[this.selected];
177
+ const selectedRun = selectedRunFromGrouped(this.runs, this.selected);
134
178
  if (selectedRun) {
135
179
  lines.push(`├${"─".repeat(borderWidth)}┤`);
136
180
  const details = [
@@ -157,47 +201,47 @@ export class RunDashboard implements DashboardComponent {
157
201
  return;
158
202
  }
159
203
  if (data === "\r" || data === "\n" || data === "s") {
160
- const runId = this.runs[this.selected]?.runId;
204
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
161
205
  this.done(runId ? { runId, action: "status" } : undefined);
162
206
  return;
163
207
  }
164
208
  if (data === "u") {
165
- const runId = this.runs[this.selected]?.runId;
209
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
166
210
  this.done(runId ? { runId, action: "summary" } : undefined);
167
211
  return;
168
212
  }
169
213
  if (data === "a") {
170
- const runId = this.runs[this.selected]?.runId;
214
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
171
215
  this.done(runId ? { runId, action: "artifacts" } : undefined);
172
216
  return;
173
217
  }
174
218
  if (data === "i") {
175
- const runId = this.runs[this.selected]?.runId;
219
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
176
220
  this.done(runId ? { runId, action: "api" } : undefined);
177
221
  return;
178
222
  }
179
223
  if (data === "d") {
180
- const runId = this.runs[this.selected]?.runId;
224
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
181
225
  this.done(runId ? { runId, action: "agents" } : undefined);
182
226
  return;
183
227
  }
184
228
  if (data === "e") {
185
- const runId = this.runs[this.selected]?.runId;
229
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
186
230
  this.done(runId ? { runId, action: "agent-events" } : undefined);
187
231
  return;
188
232
  }
189
233
  if (data === "o") {
190
- const runId = this.runs[this.selected]?.runId;
234
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
191
235
  this.done(runId ? { runId, action: "agent-output" } : undefined);
192
236
  return;
193
237
  }
194
238
  if (data === "v") {
195
- const runId = this.runs[this.selected]?.runId;
239
+ const runId = selectedRunFromGrouped(this.runs, this.selected)?.runId;
196
240
  this.done(runId ? { runId, action: "agent-transcript" } : undefined);
197
241
  return;
198
242
  }
199
243
  if (data === "r") {
200
- this.done({ runId: this.runs[this.selected]?.runId ?? "", action: "reload" });
244
+ this.done({ runId: selectedRunFromGrouped(this.runs, this.selected)?.runId ?? "", action: "reload" });
201
245
  return;
202
246
  }
203
247
  if (data === "p") {
@@ -209,7 +253,8 @@ export class RunDashboard implements DashboardComponent {
209
253
  return;
210
254
  }
211
255
  if (data === "j" || data === "\u001b[B") {
212
- this.selected = Math.min(Math.max(0, this.runs.length - 1), this.selected + 1);
256
+ const selectableCount = groupedRuns(this.runs).filter((row) => row.run).length;
257
+ this.selected = Math.min(Math.max(0, selectableCount - 1), this.selected + 1);
213
258
  }
214
259
  }
215
260
  }
@@ -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
+