pi-crew 0.1.9 → 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.9",
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
 
@@ -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
+ }
@@ -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 { isActiveRunStatus } 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,53 +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
- const running = agents.find((agent) => agent.status === "running");
66
- if (running) return running.taskId;
67
- const queued = agents.find((agent) => agent.status === "queued");
68
- if (queued) return queued.taskId;
69
- return run.status;
101
+ function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
102
+ try { return readCrewAgents(run); } catch { return []; }
70
103
  }
71
104
 
72
- export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
105
+ function activeWidgetRuns(cwd: string): WidgetRun[] {
73
106
  const runs = listRuns(cwd).slice(0, 20);
74
- const activeRuns = runs.filter((run) => isActiveRunStatus(run.status));
75
- const recentRuns = runs.filter((run) => !isActiveRunStatus(run.status)).slice(0, 3);
76
- const shownRuns = [...activeRuns, ...recentRuns];
77
- if (!shownRuns.length) return [];
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 [];
78
119
  const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
79
- const lines: string[] = [];
80
- const activeCount = activeRuns.length;
81
- lines.push(`${activeCount ? "●" : ""} pi-crew · active=${activeCount} recent=${recentRuns.length} · /team-dashboard`);
82
- for (const run of shownRuns) {
83
- let agents: CrewAgentRecord[] = [];
84
- try { agents = readCrewAgents(run); } catch { agents = []; }
85
- const counts = new Map<string, number>();
86
- for (const agent of agents) counts.set(agent.status, (counts.get(agent.status) ?? 0) + 1);
87
- const countText = [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(" ") || run.status;
88
- lines.push(`${glyph(run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${runStep(run, agents)} · ${countText}`);
89
- for (const agent of agents.filter((item) => item.status === "running" || item.status === "queued").slice(0, 2)) {
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)) {
90
129
  const stats = agentStats(agent);
91
130
  lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
92
131
  }
132
+ if (activeAgents.length > 3) lines.push(` … +${activeAgents.length - 3} more agents`);
93
133
  if (lines.length >= maxLines) break;
94
134
  }
95
135
  return lines.slice(0, maxLines);
96
136
  }
97
137
 
98
- 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 {
99
164
  if (!ctx.hasUI) return;
100
165
  state.frame += 1;
101
- const lines = buildCrewWidgetLines(ctx.cwd, state.frame);
166
+ const maxLines = config?.widgetMaxLines ?? 8;
167
+ const lines = buildCrewWidgetLines(ctx.cwd, state.frame, maxLines);
102
168
  ctx.ui.setStatus("pi-crew", lines.length ? lines[0] : undefined);
103
- 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" });
104
174
  }
105
175
 
106
- 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 {
107
177
  if (state.interval) clearInterval(state.interval);
108
178
  state.interval = undefined;
109
179
  if (ctx?.hasUI) {
110
180
  ctx.ui.setStatus("pi-crew", undefined);
111
- ctx.ui.setWidget("pi-crew", undefined, { placement: "aboveEditor" });
181
+ ctx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
112
182
  }
113
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
+ }
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import type { TeamRunManifest } from "../state/types.ts";
3
3
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
4
4
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
5
- import { isActiveRunStatus } from "../runtime/process-status.ts";
5
+ import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
6
6
 
7
7
  interface DashboardComponent {
8
8
  invalidate(): void;
@@ -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,9 +51,17 @@ 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
- if (status === "failed") return "✗";
64
+ if (status === "failed" || status === "stale") return "✗";
55
65
  if (status === "cancelled") return "!";
56
66
  if (status === "running") return "▶";
57
67
  if (status === "blocked") return "■";
@@ -104,19 +114,24 @@ function readAgentPreview(run: TeamRunManifest, maxLines = 5): string[] {
104
114
  }
105
115
  }
106
116
 
117
+ function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
118
+ try { return readCrewAgents(run); } catch { return []; }
119
+ }
120
+
107
121
  function runLabel(run: TeamRunManifest, selected: boolean): string {
108
- let agents: CrewAgentRecord[] = [];
109
- try { agents = readCrewAgents(run); } catch { agents = []; }
122
+ const agents = agentsFor(run);
123
+ const stale = isLikelyOrphanedActiveRun(run, agents);
110
124
  const running = agents.find((agent) => agent.status === "running");
111
125
  const queued = agents.find((agent) => agent.status === "queued");
112
- const step = running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
126
+ const step = stale ? "orphaned queued run" : running ? `step ${running.taskId}` : queued ? `queued ${queued.taskId}` : `agents ${agents.length}`;
127
+ const status = stale ? "stale" : run.status;
113
128
  const marker = selected ? "›" : " ";
114
- return `${marker} ${statusIcon(run.status)} ${run.runId.slice(-8)} ${run.status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
129
+ return `${marker} ${statusIcon(status)} ${run.runId.slice(-8)} ${status} | ${run.team}/${run.workflow ?? "none"} | ${step} | ${run.goal}`;
115
130
  }
116
131
 
117
132
  function groupedRuns(runs: TeamRunManifest[]): Array<{ label: string; run?: TeamRunManifest }> {
118
- const active = runs.filter((run) => isActiveRunStatus(run.status));
119
- const recent = runs.filter((run) => !isActiveRunStatus(run.status));
133
+ const active = runs.filter((run) => isDisplayActiveRun(run, agentsFor(run)));
134
+ const recent = runs.filter((run) => !isDisplayActiveRun(run, agentsFor(run)));
120
135
  const rows: Array<{ label: string; run?: TeamRunManifest }> = [];
121
136
  if (active.length) rows.push({ label: "Active" }, ...active.map((run) => ({ label: run.runId, run })));
122
137
  if (recent.length) rows.push({ label: "Recent" }, ...recent.map((run) => ({ label: run.runId, run })));
@@ -138,23 +153,28 @@ export class RunDashboard implements DashboardComponent {
138
153
  private showFullProgress = false;
139
154
  private readonly runs: TeamRunManifest[];
140
155
  private readonly done: (selection: RunDashboardSelection | undefined) => void;
156
+ private readonly theme: DashboardTheme;
141
157
 
142
- constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void) {
158
+ constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void, theme: unknown = {}) {
143
159
  this.runs = runs;
144
160
  this.done = done;
161
+ this.theme = theme as DashboardTheme;
145
162
  }
146
163
 
147
164
  invalidate(): void {}
148
165
 
149
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);
150
169
  const innerWidth = Math.max(20, width - 4);
151
170
  const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
171
+ const border = (text: string) => fg("border", text);
152
172
  const lines = [
153
- `╭${"─".repeat(borderWidth)}╮`,
154
- `│ ${padVisible(truncate("pi-crew dashboard", innerWidth - 1), innerWidth - 1)}│`,
155
- `│ ${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)}│`,
156
176
  `│ ${padVisible(truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1), innerWidth - 1)}│`,
157
- `├${"─".repeat(borderWidth)}┤`,
177
+ border(`├${"─".repeat(borderWidth)}┤`),
158
178
  ];
159
179
  if (this.runs.length === 0) {
160
180
  lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
@@ -163,15 +183,17 @@ export class RunDashboard implements DashboardComponent {
163
183
  const runRows = rows.filter((row) => row.run);
164
184
  for (const row of rows) {
165
185
  if (!row.run) {
166
- lines.push(`│ ${padVisible(truncate(row.label, innerWidth - 1), innerWidth - 1)}│`);
186
+ lines.push(`│ ${padVisible(truncate(fg("accent", row.label), innerWidth - 1), innerWidth - 1)}│`);
167
187
  continue;
168
188
  }
169
189
  const index = runRows.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
170
- lines.push(`│ ${padVisible(truncate(runLabel(row.run, index === this.selected), innerWidth - 1), innerWidth - 1)}│`);
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)}│`);
171
193
  }
172
194
  const selectedRun = selectedRunFromGrouped(this.runs, this.selected);
173
195
  if (selectedRun) {
174
- lines.push(`├${"─".repeat(borderWidth)}┤`);
196
+ lines.push(border(`├${"─".repeat(borderWidth)}┤`));
175
197
  const details = [
176
198
  `Selected: ${selectedRun.runId}`,
177
199
  `Status: ${selectedRun.status} | Team: ${selectedRun.team} | Workflow: ${selectedRun.workflow ?? "none"}`,
@@ -186,7 +208,7 @@ export class RunDashboard implements DashboardComponent {
186
208
  }
187
209
  }
188
210
  }
189
- lines.push(`╰${"─".repeat(borderWidth)}╯`);
211
+ lines.push(border(`╰${"─".repeat(borderWidth)}╯`));
190
212
  return lines.map((line) => truncate(line, width));
191
213
  }
192
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
+