pi-crew 0.1.10 → 0.1.11

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/README.md CHANGED
@@ -32,7 +32,7 @@ Current highlights:
32
32
  - detached async/background runner
33
33
  - stale async PID detection
34
34
  - active run summary and async completion notifications in Pi sessions
35
- - safe scaffold execution by default; real child Pi workers are opt-in
35
+ - real child Pi worker execution by default, with explicit scaffold/dry-run opt-out
36
36
  - child Pi JSON output parsing for final text, usage, and event counts
37
37
  - retryable model fallback attempts per task
38
38
  - aggregate usage totals in status/summary
@@ -89,19 +89,21 @@ npm run ci
89
89
 
90
90
  ## Runtime safety model
91
91
 
92
- By default, `run` uses safe scaffold mode. It creates run state, task prompts, events, and placeholder result artifacts without launching child Pi workers.
92
+ By default, `run` launches each crew task as a separate child Pi process. This matches the subagent model from `pi-subagents`: the parent session orchestrates while worker sessions execute independently and stream durable output back to run state.
93
93
 
94
- Real child Pi workers only run when explicitly enabled by either:
94
+ Use scaffold/dry-run mode only when you explicitly want prompts/artifacts without launching workers:
95
95
 
96
- ```bash
97
- PI_TEAMS_EXECUTE_WORKERS=1 pi
96
+ ```json
97
+ {
98
+ "runtime": { "mode": "scaffold" }
99
+ }
98
100
  ```
99
101
 
100
- or config:
102
+ or disable workers globally:
101
103
 
102
104
  ```json
103
105
  {
104
- "executeWorkers": true
106
+ "executeWorkers": false
105
107
  }
106
108
  ```
107
109
 
@@ -143,7 +145,7 @@ Supported config:
143
145
  ```json
144
146
  {
145
147
  "asyncByDefault": false,
146
- "executeWorkers": false,
148
+ "executeWorkers": true,
147
149
  "notifierIntervalMs": 5000,
148
150
  "requireCleanWorktreeLeader": true,
149
151
  "autonomous": {
@@ -163,6 +165,11 @@ Supported config:
163
165
  "maxRunMinutes": 60,
164
166
  "maxRetriesPerTask": 1,
165
167
  "heartbeatStaleMs": 60000
168
+ },
169
+ "ui": {
170
+ "widgetPlacement": "aboveEditor",
171
+ "widgetMaxLines": 8,
172
+ "powerbar": true
166
173
  }
167
174
  }
168
175
  ```
@@ -619,7 +626,8 @@ Optional child Pi smoke check is explicit only:
619
626
  ## Environment variables
620
627
 
621
628
  ```text
622
- PI_TEAMS_EXECUTE_WORKERS=1 enable real child Pi worker execution
629
+ PI_CREW_EXECUTE_WORKERS=0 disable child workers and use scaffold/dry-run mode
630
+ PI_TEAMS_EXECUTE_WORKERS=0 legacy disable flag
623
631
  PI_TEAMS_MOCK_CHILD_PI=success test/mock child worker success
624
632
  PI_TEAMS_MOCK_CHILD_PI=json-success
625
633
  PI_TEAMS_MOCK_CHILD_PI=retryable-failure
@@ -32,8 +32,8 @@ Implemented now:
32
32
  - durable run manifests and task files
33
33
  - workflow validation
34
34
  - foreground workflow scheduler
35
- - safe scaffold task execution
36
- - optional real child Pi execution via `PI_TEAMS_EXECUTE_WORKERS=1`
35
+ - child Pi task execution by default
36
+ - explicit scaffold/dry-run task execution via `runtime.mode=scaffold` or `executeWorkers=false`
37
37
  - safety-first create/update/delete for agents, teams, and workflows
38
38
  - backups for update/delete mutations
39
39
  - dry-run support for management mutations
package/docs/usage.md CHANGED
@@ -19,7 +19,7 @@ Supported fields:
19
19
  ```json
20
20
  {
21
21
  "asyncByDefault": false,
22
- "executeWorkers": false,
22
+ "executeWorkers": true,
23
23
  "notifierIntervalMs": 5000,
24
24
  "requireCleanWorktreeLeader": true,
25
25
  "autonomous": {
@@ -28,6 +28,11 @@ Supported fields:
28
28
  "injectPolicy": true,
29
29
  "preferAsyncForLongTasks": false,
30
30
  "allowWorktreeSuggestion": true
31
+ },
32
+ "ui": {
33
+ "widgetPlacement": "aboveEditor",
34
+ "widgetMaxLines": 8,
35
+ "powerbar": true
31
36
  }
32
37
  }
33
38
  ```
@@ -47,9 +52,9 @@ Then open Pi and run:
47
52
  /team-autonomy status
48
53
  ```
49
54
 
50
- ## Safe run
55
+ ## Default run: real worker execution
51
56
 
52
- By default, `pi-crew` does not launch child workers. It creates a durable run, prompts, placeholder results, events, and artifacts.
57
+ By default, `pi-crew` launches each task as a separate child Pi worker process. The parent Pi session orchestrates; workers execute independently and stream output to durable run state.
53
58
 
54
59
  ```json
55
60
  {
@@ -59,16 +64,21 @@ By default, `pi-crew` does not launch child workers. It creates a durable run, p
59
64
  }
60
65
  ```
61
66
 
62
- ## Real worker execution
67
+ ## Scaffold / dry run
63
68
 
64
- Start Pi with:
69
+ Use scaffold mode only when you want durable prompts/artifacts without launching child workers.
65
70
 
66
- ```bash
67
- PI_TEAMS_EXECUTE_WORKERS=1 pi
71
+ ```json
72
+ {
73
+ "action": "run",
74
+ "team": "default",
75
+ "goal": "Plan only",
76
+ "config": {
77
+ "runtime": { "mode": "scaffold" }
78
+ }
79
+ }
68
80
  ```
69
81
 
70
- Then run normally. Each task can spawn a child Pi worker.
71
-
72
82
  ## Async run
73
83
 
74
84
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
package/schema.json CHANGED
@@ -78,6 +78,16 @@
78
78
  "enabled": { "type": "boolean" },
79
79
  "needsAttentionAfterMs": { "type": "integer", "minimum": 1 }
80
80
  }
81
+ },
82
+ "ui": {
83
+ "type": "object",
84
+ "additionalProperties": false,
85
+ "description": "Pi UI settings for the crew widget, dashboard, and optional powerbar segments.",
86
+ "properties": {
87
+ "widgetPlacement": { "type": "string", "enum": ["aboveEditor", "belowEditor"] },
88
+ "widgetMaxLines": { "type": "integer", "minimum": 1, "maximum": 50 },
89
+ "powerbar": { "type": "boolean" }
90
+ }
81
91
  }
82
92
  }
83
93
  }
@@ -47,6 +47,12 @@ export interface CrewWorktreeConfig {
47
47
  linkNodeModules?: boolean;
48
48
  }
49
49
 
50
+ export interface CrewUiConfig {
51
+ widgetPlacement?: "aboveEditor" | "belowEditor";
52
+ widgetMaxLines?: number;
53
+ powerbar?: boolean;
54
+ }
55
+
50
56
  export interface AgentOverrideConfig {
51
57
  disabled?: boolean;
52
58
  model?: string | false;
@@ -71,6 +77,7 @@ export interface PiTeamsConfig {
71
77
  control?: CrewControlConfig;
72
78
  worktree?: CrewWorktreeConfig;
73
79
  agents?: CrewAgentsConfig;
80
+ ui?: CrewUiConfig;
74
81
  }
75
82
 
76
83
  export interface LoadedPiTeamsConfig {
@@ -136,6 +143,12 @@ function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfi
136
143
  ...withoutUndefined((override.worktree ?? {}) as Record<string, unknown>),
137
144
  };
138
145
  }
146
+ if (base.ui || override.ui) {
147
+ merged.ui = {
148
+ ...(base.ui ?? {}),
149
+ ...withoutUndefined((override.ui ?? {}) as Record<string, unknown>),
150
+ };
151
+ }
139
152
  if (base.agents || override.agents) {
140
153
  merged.agents = {
141
154
  ...(base.agents ?? {}),
@@ -289,6 +302,17 @@ function parseAgentOverride(value: unknown): AgentOverrideConfig | undefined {
289
302
  return Object.values(override).some((entry) => entry !== undefined) ? override : undefined;
290
303
  }
291
304
 
305
+ function parseUiConfig(value: unknown): CrewUiConfig | undefined {
306
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
307
+ const obj = value as Record<string, unknown>;
308
+ const ui: CrewUiConfig = {
309
+ widgetPlacement: obj.widgetPlacement === "aboveEditor" || obj.widgetPlacement === "belowEditor" ? obj.widgetPlacement : undefined,
310
+ widgetMaxLines: parsePositiveInteger(obj.widgetMaxLines, 50),
311
+ powerbar: typeof obj.powerbar === "boolean" ? obj.powerbar : undefined,
312
+ };
313
+ return Object.values(ui).some((entry) => entry !== undefined) ? ui : undefined;
314
+ }
315
+
292
316
  function parseAgentsConfig(value: unknown): CrewAgentsConfig | undefined {
293
317
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
294
318
  const obj = value as Record<string, unknown>;
@@ -320,6 +344,7 @@ function parseConfig(raw: unknown): PiTeamsConfig {
320
344
  control: parseControlConfig(obj.control),
321
345
  worktree: parseWorktreeConfig(obj.worktree),
322
346
  agents: parseAgentsConfig(obj.agents),
347
+ ui: parseUiConfig(obj.ui),
323
348
  };
324
349
  }
325
350
 
@@ -40,6 +40,6 @@ export function piTeamsHelp(): string {
40
40
  "- /team-validate",
41
41
  "- /team-help",
42
42
  "",
43
- "Real child workers are disabled by default. Enable with PI_TEAMS_EXECUTE_WORKERS=1 or config executeWorkers=true.",
43
+ "Real child workers are enabled by default. Use runtime.mode=scaffold or executeWorkers=false only for dry runs.",
44
44
  ].join("\n");
45
45
  }
@@ -11,6 +11,7 @@ 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
13
  import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
14
+ import { clearPiCrewPowerbar, registerPiCrewPowerbarSegments, updatePiCrewPowerbar } from "../ui/powerbar-publisher.ts";
14
15
  import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
15
16
  import { loadRunManifestById } from "../state/state-store.ts";
16
17
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
@@ -109,14 +110,22 @@ export function registerPiTeams(pi: ExtensionAPI): void {
109
110
  currentCtx = ctx;
110
111
  notifyActiveRuns(ctx);
111
112
  const loadedConfig = loadConfig(ctx.cwd);
113
+ registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
112
114
  startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? 5000);
113
- updateCrewWidget(ctx, widgetState);
114
- widgetState.interval = setInterval(() => { if (currentCtx) updateCrewWidget(currentCtx, widgetState); }, 1000);
115
+ updateCrewWidget(ctx, widgetState, loadedConfig.config.ui);
116
+ updatePiCrewPowerbar(pi.events, ctx.cwd, loadedConfig.config.ui);
117
+ widgetState.interval = setInterval(() => {
118
+ if (!currentCtx) return;
119
+ const config = loadConfig(currentCtx.cwd).config.ui;
120
+ updateCrewWidget(currentCtx, widgetState, config);
121
+ updatePiCrewPowerbar(pi.events, currentCtx.cwd, config);
122
+ }, 1000);
115
123
  widgetState.interval.unref?.();
116
124
  });
117
125
  pi.on("session_shutdown", () => {
118
126
  stopAsyncRunNotifier(notifierState);
119
- stopCrewWidget(currentCtx, widgetState);
127
+ stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
128
+ clearPiCrewPowerbar(pi.events);
120
129
  currentCtx = undefined;
121
130
  rpcHandle?.unsubscribe();
122
131
  rpcHandle = undefined;
@@ -130,7 +139,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
130
139
  parameters: TeamToolParams as never,
131
140
  async execute(_id, params, _signal, _onUpdate, ctx) {
132
141
  const output = await handleTeamTool(params as TeamToolParamsValue, ctx);
133
- updateCrewWidget(ctx, widgetState);
142
+ const config = loadConfig(ctx.cwd).config.ui;
143
+ updateCrewWidget(ctx, widgetState, config);
144
+ updatePiCrewPowerbar(pi.events, ctx.cwd, config);
134
145
  return output;
135
146
  },
136
147
  };
@@ -323,7 +334,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
323
334
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
324
335
  for (;;) {
325
336
  const runs = listRuns(ctx.cwd).slice(0, 50);
326
- const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, _theme, _keybindings, done) => new RunDashboard(runs, done), {
337
+ const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme), {
327
338
  overlay: true,
328
339
  overlayOptions: { width: "90%", maxHeight: "80%", anchor: "center" },
329
340
  });
@@ -318,10 +318,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
318
318
  "",
319
319
  `Runtime: ${runtime.kind}${runtime.fallback ? ` (fallback from ${runtime.requestedMode})` : ""}${runtime.reason ? ` - ${runtime.reason}` : ""}`,
320
320
  runtime.kind === "child-process"
321
- ? "Child Pi worker execution was enabled."
321
+ ? "Child Pi worker execution is enabled by default; each task is launched as a separate Pi process. Set runtime.mode=scaffold or executeWorkers=false only for dry runs."
322
322
  : runtime.kind === "live-session"
323
323
  ? "Experimental live-session worker execution was enabled."
324
- : "Safe scaffold mode: child Pi workers were not launched. Set PI_CREW_EXECUTE_WORKERS=1, PI_TEAMS_EXECUTE_WORKERS=1, or runtime.mode=child-process to enable real worker execution.",
324
+ : "Safe scaffold mode: child Pi workers were not launched because runtime.mode=scaffold or executeWorkers=false was configured.",
325
325
  ].join("\n");
326
326
  return result(text, { action: "run", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
327
327
  }
@@ -508,6 +508,7 @@ function configPatchFromConfig(config: unknown): PiTeamsConfig {
508
508
  const runtime = configRecord(cfg.runtime);
509
509
  const limits = configRecord(cfg.limits);
510
510
  const worktree = configRecord(cfg.worktree);
511
+ const ui = configRecord(cfg.ui);
511
512
  return {
512
513
  asyncByDefault: typeof cfg.asyncByDefault === "boolean" ? cfg.asyncByDefault : undefined,
513
514
  executeWorkers: typeof cfg.executeWorkers === "boolean" ? cfg.executeWorkers : undefined,
@@ -542,6 +543,11 @@ function configPatchFromConfig(config: unknown): PiTeamsConfig {
542
543
  enabled: typeof control.enabled === "boolean" ? control.enabled : undefined,
543
544
  needsAttentionAfterMs: typeof control.needsAttentionAfterMs === "number" && Number.isInteger(control.needsAttentionAfterMs) && control.needsAttentionAfterMs > 0 ? control.needsAttentionAfterMs : undefined,
544
545
  } : undefined,
546
+ ui: Object.keys(ui).length > 0 ? {
547
+ widgetPlacement: ui.widgetPlacement === "aboveEditor" || ui.widgetPlacement === "belowEditor" ? ui.widgetPlacement : undefined,
548
+ widgetMaxLines: typeof ui.widgetMaxLines === "number" && Number.isInteger(ui.widgetMaxLines) && ui.widgetMaxLines > 0 ? ui.widgetMaxLines : undefined,
549
+ powerbar: typeof ui.powerbar === "boolean" ? ui.powerbar : undefined,
550
+ } : undefined,
545
551
  };
546
552
  }
547
553
 
@@ -48,22 +48,21 @@ export async function isLiveSessionRuntimeAvailable(timeoutMs = 1500, env: NodeJ
48
48
 
49
49
  export async function resolveCrewRuntime(config: PiTeamsConfig, env: NodeJS.ProcessEnv = process.env): Promise<CrewRuntimeCapabilities> {
50
50
  const requestedMode = config.runtime?.mode ?? "auto";
51
- const executeWorkers = config.executeWorkers === true || env.PI_CREW_EXECUTE_WORKERS === "1" || env.PI_TEAMS_EXECUTE_WORKERS === "1";
51
+ const workersDisabled = config.executeWorkers === false || env.PI_CREW_EXECUTE_WORKERS === "0" || env.PI_TEAMS_EXECUTE_WORKERS === "0";
52
52
  if (requestedMode === "scaffold") return scaffoldCaps(requestedMode);
53
- if (requestedMode === "child-process") return childCaps(requestedMode, executeWorkers ? undefined : "child-process requested but executeWorkers is not enabled; caller should refuse or fall back explicitly.");
53
+ if (workersDisabled) return scaffoldCaps(requestedMode, "Child worker execution disabled by config/env. Set runtime.mode=scaffold or executeWorkers=false only for dry runs.");
54
+ if (requestedMode === "child-process") return childCaps(requestedMode);
54
55
  if (requestedMode === "live-session" || (requestedMode === "auto" && config.runtime?.preferLiveSession === true)) {
55
56
  const live = await isLiveSessionRuntimeAvailable(1500, env);
56
57
  if (live.available) return liveCaps(requestedMode);
57
58
  if (requestedMode === "live-session" && config.runtime?.allowChildProcessFallback === false) return { ...scaffoldCaps(requestedMode), available: false, reason: live.reason };
58
- if (executeWorkers) return { ...childCaps(requestedMode), fallback: "child-process", reason: live.reason };
59
- return { ...scaffoldCaps(requestedMode), fallback: "scaffold", reason: live.reason };
59
+ return { ...childCaps(requestedMode), fallback: "child-process", reason: live.reason };
60
60
  }
61
- if (executeWorkers) return childCaps(requestedMode);
62
- return scaffoldCaps(requestedMode);
61
+ return childCaps(requestedMode);
63
62
  }
64
63
 
65
- function scaffoldCaps(requestedMode: CrewRuntimeMode): CrewRuntimeCapabilities {
66
- return { kind: "scaffold", requestedMode, available: true, steer: false, resume: false, liveToolActivity: false, transcript: false };
64
+ function scaffoldCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {
65
+ return { kind: "scaffold", requestedMode, available: true, steer: false, resume: false, liveToolActivity: false, transcript: false, ...(reason ? { reason } : {}) };
67
66
  }
68
67
 
69
68
  function childCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {
@@ -1,8 +1,9 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { CrewUiConfig } from "../config/config.ts";
2
3
  import { listRuns } from "../extension/run-index.ts";
3
- import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
4
4
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
5
5
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
6
+ import { isDisplayActiveRun } from "../runtime/process-status.ts";
6
7
  import type { TeamRunManifest } from "../state/types.ts";
7
8
 
8
9
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -15,12 +16,48 @@ const TOOL_LABELS: Record<string, string> = {
15
16
  find: "finding files",
16
17
  ls: "listing",
17
18
  };
19
+ const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
20
+
21
+ type ThemeLike = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
22
+ type WidgetComponent = { render(width: number): string[]; invalidate(): void };
18
23
 
19
24
  export interface CrewWidgetState {
20
25
  frame: number;
21
26
  interval?: ReturnType<typeof setInterval>;
22
27
  }
23
28
 
29
+ interface WidgetRun {
30
+ run: TeamRunManifest;
31
+ agents: CrewAgentRecord[];
32
+ }
33
+
34
+ function visibleWidth(value: string): number {
35
+ return value.replace(ANSI_PATTERN, "").length;
36
+ }
37
+
38
+ function truncate(value: string, width: number): string {
39
+ if (width <= 0) return "";
40
+ if (visibleWidth(value) <= width) return value;
41
+ if (width <= 1) return "…";
42
+ let output = "";
43
+ let visible = 0;
44
+ for (let index = 0; index < value.length;) {
45
+ const slice = value.slice(index);
46
+ const ansi = slice.match(/^\u001b\[[0-?]*[ -/]*[@-~]/);
47
+ if (ansi?.[0]) {
48
+ output += ansi[0];
49
+ index += ansi[0].length;
50
+ continue;
51
+ }
52
+ const char = value[index]!;
53
+ if (visible >= width - 1) break;
54
+ output += char;
55
+ visible += 1;
56
+ index += char.length;
57
+ }
58
+ return `${output}\u001b[0m…`;
59
+ }
60
+
24
61
  function elapsed(iso: string | undefined, now = Date.now()): string | undefined {
25
62
  if (!iso) return undefined;
26
63
  const ms = Math.max(0, now - new Date(iso).getTime());
@@ -45,7 +82,7 @@ function agentActivity(agent: CrewAgentRecord): string {
45
82
  const recent = agent.progress?.recentOutput?.at(-1);
46
83
  if (recent) return recent.replace(/\s+/g, " ").trim();
47
84
  if (agent.progress?.activityState === "needs_attention") return "needs attention";
48
- if (agent.status === "queued") return "queued";
85
+ if (agent.status === "queued") return "queued";
49
86
  if (agent.status === "running") return "thinking…";
50
87
  if (agent.status === "failed") return agent.error ?? "failed";
51
88
  return "done";
@@ -61,59 +98,86 @@ function agentStats(agent: CrewAgentRecord): string {
61
98
  return parts.join(" · ");
62
99
  }
63
100
 
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
101
  function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
74
102
  try { return readCrewAgents(run); } catch { return []; }
75
103
  }
76
104
 
77
- export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
105
+ function activeWidgetRuns(cwd: string): WidgetRun[] {
78
106
  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 [];
107
+ return runs.map((run) => ({ run, agents: agentsFor(run) })).filter((item) => isDisplayActiveRun(item.run, item.agents));
108
+ }
109
+
110
+ function statusSummary(runs: WidgetRun[]): string {
111
+ const runningAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "running").length;
112
+ const queuedAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "queued").length;
113
+ return `● pi-crew · runs=${runs.length} agents=${runningAgents} running${queuedAgents ? ` · ${queuedAgents} queued` : ""} · /team-dashboard`;
114
+ }
115
+
116
+ export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
117
+ const runs = activeWidgetRuns(cwd);
118
+ if (!runs.length) return [];
84
119
  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))) {
120
+ const lines: string[] = [statusSummary(runs)];
121
+ for (const { run, agents } of runs) {
122
+ const running = agents.find((agent) => agent.status === "running");
123
+ const queued = agents.find((agent) => agent.status === "queued");
124
+ const step = running?.taskId ?? queued?.taskId ?? run.status;
125
+ const completed = agents.filter((agent) => agent.status === "completed").length;
126
+ lines.push(`${glyph(run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${step} · ${completed}/${agents.length} done`);
127
+ const activeAgents = agents.filter((item) => item.status === "running" || item.status === "queued");
128
+ for (const agent of activeAgents.slice(0, 3)) {
96
129
  const stats = agentStats(agent);
97
130
  lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
98
131
  }
132
+ if (activeAgents.length > 3) lines.push(` … +${activeAgents.length - 3} more agents`);
99
133
  if (lines.length >= maxLines) break;
100
134
  }
101
135
  return lines.slice(0, maxLines);
102
136
  }
103
137
 
104
- export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState): void {
138
+ class CrewWidgetComponent implements WidgetComponent {
139
+ private cwd: string;
140
+ private frame: number;
141
+ private maxLines: number;
142
+ private theme: ThemeLike;
143
+
144
+ constructor(cwd: string, frame: number, maxLines: number, theme: ThemeLike) {
145
+ this.cwd = cwd;
146
+ this.frame = frame;
147
+ this.maxLines = maxLines;
148
+ this.theme = theme;
149
+ }
150
+ invalidate(): void {}
151
+ render(width: number): string[] {
152
+ const fg = this.theme.fg?.bind(this.theme) ?? ((_color: string, text: string) => text);
153
+ const bold = this.theme.bold?.bind(this.theme) ?? ((text: string) => text);
154
+ return buildCrewWidgetLines(this.cwd, this.frame, this.maxLines).map((line, index) => {
155
+ const colored = index === 0
156
+ ? line.replace("● pi-crew", `${fg("accent", "●")} ${bold("pi-crew")}`)
157
+ : line.replace(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏▶◦✓✗■·])/, (match, icon: string) => match.replace(icon, fg(icon === "✓" ? "success" : icon === "✗" ? "error" : icon === "◦" ? "dim" : "accent", icon)));
158
+ return truncate(colored, width);
159
+ });
160
+ }
161
+ }
162
+
163
+ export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState, config?: CrewUiConfig): void {
105
164
  if (!ctx.hasUI) return;
106
165
  state.frame += 1;
107
- const lines = buildCrewWidgetLines(ctx.cwd, state.frame);
166
+ const maxLines = config?.widgetMaxLines ?? 8;
167
+ const lines = buildCrewWidgetLines(ctx.cwd, state.frame, maxLines);
108
168
  ctx.ui.setStatus("pi-crew", lines.length ? lines[0] : undefined);
109
- ctx.ui.setWidget("pi-crew", lines.length ? lines : undefined, { placement: "aboveEditor" });
169
+ if (!lines.length) {
170
+ ctx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
171
+ return;
172
+ }
173
+ ctx.ui.setWidget("pi-crew", ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(ctx.cwd, state.frame, maxLines, theme as ThemeLike)) as never, { placement: config?.widgetPlacement ?? "aboveEditor" });
110
174
  }
111
175
 
112
- export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState): void {
176
+ export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState, config?: CrewUiConfig): void {
113
177
  if (state.interval) clearInterval(state.interval);
114
178
  state.interval = undefined;
115
179
  if (ctx?.hasUI) {
116
180
  ctx.ui.setStatus("pi-crew", undefined);
117
- ctx.ui.setWidget("pi-crew", undefined, { placement: "aboveEditor" });
181
+ ctx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
118
182
  }
119
183
  }
@@ -0,0 +1,55 @@
1
+ import type { CrewUiConfig } from "../config/config.ts";
2
+ import { listRuns } from "../extension/run-index.ts";
3
+ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
4
+ import { isDisplayActiveRun } from "../runtime/process-status.ts";
5
+
6
+ type EventBus = { emit?: (event: string, data: unknown) => void } | undefined;
7
+
8
+ function safeEmit(events: EventBus, event: string, data: unknown): void {
9
+ try { events?.emit?.(event, data); } catch {}
10
+ }
11
+
12
+ export function registerPiCrewPowerbarSegments(events: EventBus, config?: CrewUiConfig): void {
13
+ if (config?.powerbar === false) return;
14
+ safeEmit(events, "powerbar:register-segment", { id: "pi-crew-active", label: "pi-crew active agents" });
15
+ safeEmit(events, "powerbar:register-segment", { id: "pi-crew-progress", label: "pi-crew run progress" });
16
+ }
17
+
18
+ export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: CrewUiConfig): void {
19
+ if (config?.powerbar === false) return;
20
+ const active = listRuns(cwd).slice(0, 20).map((run) => {
21
+ let agents = [] as ReturnType<typeof readCrewAgents>;
22
+ try { agents = readCrewAgents(run); } catch {}
23
+ return { run, agents };
24
+ }).filter((item) => isDisplayActiveRun(item.run, item.agents));
25
+ if (!active.length) {
26
+ safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
27
+ safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
28
+ return;
29
+ }
30
+ const agents = active.flatMap((item) => item.agents);
31
+ const running = agents.filter((agent) => agent.status === "running").length;
32
+ const queued = agents.filter((agent) => agent.status === "queued").length;
33
+ const completed = agents.filter((agent) => agent.status === "completed").length;
34
+ const total = Math.max(1, agents.length);
35
+ safeEmit(events, "powerbar:update", {
36
+ id: "pi-crew-active",
37
+ icon: "⚙",
38
+ text: `crew ${running || active.length}`,
39
+ suffix: queued ? `${queued}q` : undefined,
40
+ color: running ? "accent" : "warning",
41
+ });
42
+ safeEmit(events, "powerbar:update", {
43
+ id: "pi-crew-progress",
44
+ text: active[0]?.run.team ?? "crew",
45
+ bar: Math.round((completed / total) * 100),
46
+ suffix: `${completed}/${total}`,
47
+ color: completed === total ? "success" : "accent",
48
+ barSegments: 8,
49
+ });
50
+ }
51
+
52
+ export function clearPiCrewPowerbar(events: EventBus): void {
53
+ safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
54
+ safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
55
+ }
@@ -10,6 +10,8 @@ interface DashboardComponent {
10
10
  handleInput(data: string): void;
11
11
  }
12
12
 
13
+ type DashboardTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
14
+
13
15
  export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "reload";
14
16
  export interface RunDashboardSelection {
15
17
  runId: string;
@@ -49,6 +51,14 @@ function padVisible(value: string, width: number): string {
49
51
  return `${value}${" ".repeat(Math.max(0, width - visibleLength(value)))}`;
50
52
  }
51
53
 
54
+ function colorForStatus(status: string): string {
55
+ if (status === "completed") return "success";
56
+ if (status === "failed" || status === "stale") return "error";
57
+ if (status === "cancelled" || status === "blocked") return "warning";
58
+ if (status === "running") return "accent";
59
+ return "dim";
60
+ }
61
+
52
62
  function statusIcon(status: string): string {
53
63
  if (status === "completed") return "✓";
54
64
  if (status === "failed" || status === "stale") return "✗";
@@ -143,23 +153,28 @@ export class RunDashboard implements DashboardComponent {
143
153
  private showFullProgress = false;
144
154
  private readonly runs: TeamRunManifest[];
145
155
  private readonly done: (selection: RunDashboardSelection | undefined) => void;
156
+ private readonly theme: DashboardTheme;
146
157
 
147
- constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void) {
158
+ constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void, theme: unknown = {}) {
148
159
  this.runs = runs;
149
160
  this.done = done;
161
+ this.theme = theme as DashboardTheme;
150
162
  }
151
163
 
152
164
  invalidate(): void {}
153
165
 
154
166
  render(width: number): string[] {
167
+ const fg = this.theme.fg?.bind(this.theme) ?? ((_color: string, text: string) => text);
168
+ const bold = this.theme.bold?.bind(this.theme) ?? ((text: string) => text);
155
169
  const innerWidth = Math.max(20, width - 4);
156
170
  const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
171
+ const border = (text: string) => fg("border", text);
157
172
  const lines = [
158
- `╭${"─".repeat(borderWidth)}╮`,
159
- `│ ${padVisible(truncate("pi-crew dashboard", innerWidth - 1), innerWidth - 1)}│`,
160
- `│ ${padVisible(truncate("↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close", innerWidth - 1), innerWidth - 1)}│`,
173
+ border(`╭${"─".repeat(borderWidth)}╮`),
174
+ `│ ${padVisible(truncate(`${fg("accent", "●")} ${bold("pi-crew dashboard")}`, innerWidth - 1), innerWidth - 1)}│`,
175
+ `│ ${padVisible(truncate(fg("dim", "↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close"), innerWidth - 1), innerWidth - 1)}│`,
161
176
  `│ ${padVisible(truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1), innerWidth - 1)}│`,
162
- `├${"─".repeat(borderWidth)}┤`,
177
+ border(`├${"─".repeat(borderWidth)}┤`),
163
178
  ];
164
179
  if (this.runs.length === 0) {
165
180
  lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
@@ -168,15 +183,17 @@ export class RunDashboard implements DashboardComponent {
168
183
  const runRows = rows.filter((row) => row.run);
169
184
  for (const row of rows) {
170
185
  if (!row.run) {
171
- lines.push(`│ ${padVisible(truncate(row.label, innerWidth - 1), innerWidth - 1)}│`);
186
+ lines.push(`│ ${padVisible(truncate(fg("accent", row.label), innerWidth - 1), innerWidth - 1)}│`);
172
187
  continue;
173
188
  }
174
189
  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)}│`);
190
+ const label = runLabel(row.run, index === this.selected);
191
+ const status = isLikelyOrphanedActiveRun(row.run, agentsFor(row.run)) ? "stale" : row.run.status;
192
+ lines.push(`│ ${padVisible(truncate(fg(colorForStatus(status), label), innerWidth - 1), innerWidth - 1)}│`);
176
193
  }
177
194
  const selectedRun = selectedRunFromGrouped(this.runs, this.selected);
178
195
  if (selectedRun) {
179
- lines.push(`├${"─".repeat(borderWidth)}┤`);
196
+ lines.push(border(`├${"─".repeat(borderWidth)}┤`));
180
197
  const details = [
181
198
  `Selected: ${selectedRun.runId}`,
182
199
  `Status: ${selectedRun.status} | Team: ${selectedRun.team} | Workflow: ${selectedRun.workflow ?? "none"}`,
@@ -191,7 +208,7 @@ export class RunDashboard implements DashboardComponent {
191
208
  }
192
209
  }
193
210
  }
194
- lines.push(`╰${"─".repeat(borderWidth)}╯`);
211
+ lines.push(border(`╰${"─".repeat(borderWidth)}╯`));
195
212
  return lines.map((line) => truncate(line, width));
196
213
  }
197
214
 
@@ -1,204 +1,219 @@
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
-
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
+ function truncateBody(text: string, max = 1000): string {
49
+ return text.length > max ? `${text.slice(0, max)}… (truncated ${text.length - max} chars)` : text;
50
+ }
51
+
52
+ export function formatTranscriptEvent(event: unknown): string[] {
53
+ const obj = asRecord(event);
54
+ if (!obj) return [String(event)];
55
+ const type = typeof obj.type === "string" ? obj.type : undefined;
56
+ const toolName = typeof obj.toolName === "string" ? obj.toolName : typeof obj.name === "string" ? obj.name : undefined;
57
+ if (type && /tool/i.test(type)) {
58
+ const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : typeof obj.result === "string" ? obj.result : "");
59
+ return [`[Tool${toolName ? `: ${toolName}` : ""}] ${type}`, truncateBody(text.trim() || "(no output)")];
60
+ }
61
+ const message = asRecord(obj.message);
62
+ if (message) {
63
+ const role = typeof message.role === "string" ? message.role : "message";
64
+ const text = textFromContent(message.content);
65
+ const label = role === "assistant" ? "Assistant" : role === "user" ? "User" : role;
66
+ if (text.trim()) return [`[${label}]:`, text.trim()];
67
+ }
68
+ if (type) {
69
+ const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : "");
70
+ return text.trim() ? [`[${type}]: ${text.trim()}`] : [`[${type}]`];
71
+ }
72
+ return [JSON.stringify(event)];
73
+ }
74
+
75
+ export function formatTranscriptText(text: string): string[] {
76
+ const lines: string[] = [];
77
+ for (const raw of text.split(/\r?\n/).filter(Boolean)) {
78
+ try {
79
+ lines.push(...formatTranscriptEvent(JSON.parse(raw)));
80
+ } catch {
81
+ lines.push(raw);
82
+ }
83
+ }
84
+ return lines.length ? lines : ["(no transcript content)"];
85
+ }
86
+
87
+ export function readRunTranscript(manifest: TeamRunManifest, taskId?: string): { title: string; path: string; lines: string[] } {
88
+ const agents = readCrewAgents(manifest);
89
+ const agent = taskId ? agents.find((item) => item.taskId === taskId || item.id === taskId) : agents.find((item) => item.transcriptPath) ?? agents[0];
90
+ const selectedTaskId = agent?.taskId ?? taskId ?? "unknown";
91
+ const transcriptPath = agent?.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(manifest, selectedTaskId);
92
+ const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
93
+ return { title: `${manifest.runId}:${selectedTaskId}`, path: transcriptPath, lines: formatTranscriptText(text) };
94
+ }
95
+
96
+ export class DurableTextViewer implements Component {
97
+ private scroll = 0;
98
+ private lastHeight = 10;
99
+ private autoScroll = true;
100
+ private title: string;
101
+ private subtitle: string;
102
+ private lines: string[];
103
+ private theme: unknown;
104
+ private done: (result: undefined) => void;
105
+
106
+ constructor(title: string, subtitle: string, lines: string[], theme: unknown, done: (result: undefined) => void) {
107
+ this.title = title;
108
+ this.subtitle = subtitle;
109
+ this.lines = lines.length ? lines : ["(empty)"];
110
+ this.theme = theme;
111
+ this.done = done;
112
+ }
113
+
114
+ invalidate(): void {}
115
+
116
+ handleInput(data: string): void {
117
+ if (data === "q" || data === "\u001b") {
118
+ this.done(undefined);
119
+ return;
120
+ }
121
+ const maxScroll = Math.max(0, this.lines.length - this.lastHeight);
122
+ if (data === "k" || data === "\u001b[A") { this.scroll = Math.max(0, this.scroll - 1); this.autoScroll = false; }
123
+ else if (data === "j" || data === "\u001b[B") { this.scroll = Math.min(maxScroll, this.scroll + 1); this.autoScroll = this.scroll >= maxScroll; }
124
+ else if (data === "\u001b[5~") { this.scroll = Math.max(0, this.scroll - this.lastHeight); this.autoScroll = false; }
125
+ else if (data === "\u001b[6~") { this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight); this.autoScroll = this.scroll >= maxScroll; }
126
+ else if (data === "g" || data === "\u001b[H") { this.scroll = 0; this.autoScroll = false; }
127
+ else if (data === "G" || data === "\u001b[F") { this.scroll = maxScroll; this.autoScroll = true; }
128
+ }
129
+
130
+ render(width: number): string[] {
131
+ const th = this.theme as TranscriptTheme;
132
+ const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
133
+ const bold = th.bold?.bind(th) ?? ((text: string) => text);
134
+ const inner = Math.max(20, width - 4);
135
+ this.lastHeight = 16;
136
+ const body = this.lines.flatMap((line) => wrap(line, inner));
137
+ const maxScroll = Math.max(0, body.length - this.lastHeight);
138
+ if (this.autoScroll) this.scroll = maxScroll;
139
+ this.scroll = Math.min(this.scroll, maxScroll);
140
+ const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
141
+ const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
142
+ const colorLine = (line: string) => line.startsWith("[User]") ? fg("accent", line) : line.startsWith("[Assistant]") ? bold(line) : line.startsWith("[Tool") ? fg("muted", line) : line.startsWith("[Result]") ? fg("dim", line) : line;
143
+ const row = (text: string) => `${fg("border", "│")} ${truncate(pad(colorLine(text)), inner)} ${fg("border", "│")}`;
144
+ return [
145
+ fg("border", `╭${"─".repeat(inner + 2)}╮`),
146
+ row(`${bold(this.title)} ${fg("dim", this.subtitle)}`),
147
+ row(fg("dim", "j/k scroll · PgUp/PgDn · g/G top/bottom · q close")),
148
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
149
+ ...visible.map(row),
150
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
151
+ row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}% · auto-scroll ${this.autoScroll ? "on" : "off"}`)),
152
+ fg("border", `╰${"─".repeat(inner + 2)}╯`),
153
+ ];
154
+ }
155
+ }
156
+
157
+ export class DurableTranscriptViewer implements Component {
158
+ private scroll = 0;
159
+ private lastHeight = 10;
160
+ private autoScroll = true;
161
+ private manifest: TeamRunManifest;
162
+ private theme: unknown;
163
+ private done: (result: undefined) => void;
164
+ private taskId?: string;
165
+
166
+ constructor(manifest: TeamRunManifest, theme: unknown, done: (result: undefined) => void, taskId?: string) {
167
+ this.manifest = manifest;
168
+ this.theme = theme;
169
+ this.done = done;
170
+ this.taskId = taskId;
171
+ }
172
+
173
+ invalidate(): void {}
174
+
175
+ handleInput(data: string): void {
176
+ if (data === "q" || data === "\u001b") {
177
+ this.done(undefined);
178
+ return;
179
+ }
180
+ const content = readRunTranscript(this.manifest, this.taskId).lines;
181
+ const maxScroll = Math.max(0, content.length - this.lastHeight);
182
+ if (data === "k" || data === "\u001b[A") { this.scroll = Math.max(0, this.scroll - 1); this.autoScroll = false; }
183
+ else if (data === "j" || data === "\u001b[B") { this.scroll = Math.min(maxScroll, this.scroll + 1); this.autoScroll = this.scroll >= maxScroll; }
184
+ else if (data === "\u001b[5~") { this.scroll = Math.max(0, this.scroll - this.lastHeight); this.autoScroll = false; }
185
+ else if (data === "\u001b[6~") { this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight); this.autoScroll = this.scroll >= maxScroll; }
186
+ else if (data === "g" || data === "\u001b[H") { this.scroll = 0; this.autoScroll = false; }
187
+ else if (data === "G" || data === "\u001b[F") { this.scroll = maxScroll; this.autoScroll = true; }
188
+ }
189
+
190
+ render(width: number): string[] {
191
+ const th = this.theme as TranscriptTheme;
192
+ const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
193
+ const bold = th.bold?.bind(th) ?? ((text: string) => text);
194
+ const inner = Math.max(20, width - 4);
195
+ const data = readRunTranscript(this.manifest, this.taskId);
196
+ const body = data.lines.flatMap((line) => wrap(line, inner));
197
+ this.lastHeight = 16;
198
+ const maxScroll = Math.max(0, body.length - this.lastHeight);
199
+ if (this.autoScroll) this.scroll = maxScroll;
200
+ this.scroll = Math.min(this.scroll, maxScroll);
201
+ const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
202
+ const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
203
+ const colorLine = (line: string) => line.startsWith("[User]") ? fg("accent", line) : line.startsWith("[Assistant]") ? bold(line) : line.startsWith("[Tool") ? fg("muted", line) : line.startsWith("[Result]") ? fg("dim", line) : line;
204
+ const row = (text: string) => `${fg("border", "│")} ${truncate(pad(colorLine(text)), inner)} ${fg("border", "│")}`;
205
+ const lines = [
206
+ fg("border", `╭${"─".repeat(inner + 2)}╮`),
207
+ row(`${bold("pi-crew transcript")} ${fg("dim", data.title)}`),
208
+ row(fg("dim", data.path)),
209
+ row(fg("dim", "j/k scroll · PgUp/PgDn · g/G top/bottom · q close")),
210
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
211
+ ...visible.map(row),
212
+ fg("border", `├${"─".repeat(inner + 2)}┤`),
213
+ row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}% · auto-scroll ${this.autoScroll ? "on" : "off"}`)),
214
+ fg("border", `╰${"─".repeat(inner + 2)}╯`),
215
+ ];
216
+ return lines;
217
+ }
218
+ }
219
+