pi-crew 0.1.15 → 0.1.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.16
4
+
5
+ - Added right-side `/team-dashboard` placement with model, token, and tool detail rows for subagents.
6
+ - Added UI config for dashboard placement/width and model/token/tool visibility.
7
+ - Foreground child-process runs now continue without blocking the interactive chat and remain tied to session shutdown.
8
+ - Child-process observability now drops noisy `message_update`/encrypted thinking deltas and stores compact events to prevent massive JSONL/output logs from freezing sessions.
9
+ - Cancel now syncs agent records and writes a foreground interrupt request so queued/running agents stop appearing stale.
10
+
3
11
  ## 0.1.15
4
12
 
5
13
  - Child-process model selection now uses Pi-configured/available models and auto-discovers provider/model entries from Pi settings/models config.
package/README.md CHANGED
@@ -169,11 +169,26 @@ Supported config:
169
169
  "ui": {
170
170
  "widgetPlacement": "aboveEditor",
171
171
  "widgetMaxLines": 8,
172
- "powerbar": true
172
+ "powerbar": true,
173
+ "dashboardPlacement": "right",
174
+ "dashboardWidth": 52,
175
+ "showModel": true,
176
+ "showTokens": true,
177
+ "showTools": true
173
178
  }
174
179
  }
175
180
  ```
176
181
 
182
+ Safety notes:
183
+
184
+ - Foreground child-process runs continue in the Pi extension process and return control to chat immediately, so large workflows do not block the interactive session. They are interrupted on session shutdown. Use `async: true` only for intentionally detached runs that may survive the current session.
185
+
186
+ UI notes:
187
+
188
+ - `widgetPlacement`/`widgetMaxLines` keep the persistent active-run widget compact.
189
+ - `dashboardPlacement: "right"` opens `/team-dashboard` as a right-side overlay panel instead of a centered modal.
190
+ - `showModel`, `showTokens`, and `showTools` show worker model attempts, token usage, and tool activity in dashboard agent rows.
191
+
177
192
  Show config:
178
193
 
179
194
  ```text
package/docs/usage.md CHANGED
@@ -32,7 +32,12 @@ Supported fields:
32
32
  "ui": {
33
33
  "widgetPlacement": "aboveEditor",
34
34
  "widgetMaxLines": 8,
35
- "powerbar": true
35
+ "powerbar": true,
36
+ "dashboardPlacement": "right",
37
+ "dashboardWidth": 52,
38
+ "showModel": true,
39
+ "showTokens": true,
40
+ "showTools": true
36
41
  }
37
42
  }
38
43
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
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
@@ -86,7 +86,12 @@
86
86
  "properties": {
87
87
  "widgetPlacement": { "type": "string", "enum": ["aboveEditor", "belowEditor"] },
88
88
  "widgetMaxLines": { "type": "integer", "minimum": 1, "maximum": 50 },
89
- "powerbar": { "type": "boolean" }
89
+ "powerbar": { "type": "boolean" },
90
+ "dashboardPlacement": { "type": "string", "enum": ["center", "right"], "description": "Place /team-dashboard as a centered overlay or right-side panel." },
91
+ "dashboardWidth": { "type": "integer", "minimum": 32, "maximum": 120 },
92
+ "showModel": { "type": "boolean", "description": "Show worker model attempts in dashboard agent rows." },
93
+ "showTokens": { "type": "boolean", "description": "Show token usage in dashboard agent rows." },
94
+ "showTools": { "type": "boolean", "description": "Show tool activity in dashboard agent rows." }
90
95
  }
91
96
  }
92
97
  }
@@ -51,6 +51,11 @@ export interface CrewUiConfig {
51
51
  widgetPlacement?: "aboveEditor" | "belowEditor";
52
52
  widgetMaxLines?: number;
53
53
  powerbar?: boolean;
54
+ dashboardPlacement?: "center" | "right";
55
+ dashboardWidth?: number;
56
+ showModel?: boolean;
57
+ showTokens?: boolean;
58
+ showTools?: boolean;
54
59
  }
55
60
 
56
61
  export interface AgentOverrideConfig {
@@ -309,6 +314,11 @@ function parseUiConfig(value: unknown): CrewUiConfig | undefined {
309
314
  widgetPlacement: obj.widgetPlacement === "aboveEditor" || obj.widgetPlacement === "belowEditor" ? obj.widgetPlacement : undefined,
310
315
  widgetMaxLines: parsePositiveInteger(obj.widgetMaxLines, 50),
311
316
  powerbar: typeof obj.powerbar === "boolean" ? obj.powerbar : undefined,
317
+ dashboardPlacement: obj.dashboardPlacement === "center" || obj.dashboardPlacement === "right" ? obj.dashboardPlacement : undefined,
318
+ dashboardWidth: parsePositiveInteger(obj.dashboardWidth, 120),
319
+ showModel: typeof obj.showModel === "boolean" ? obj.showModel : undefined,
320
+ showTokens: typeof obj.showTokens === "boolean" ? obj.showTokens : undefined,
321
+ showTools: typeof obj.showTools === "boolean" ? obj.showTools : undefined,
312
322
  };
313
323
  return Object.values(ui).some((entry) => entry !== undefined) ? ui : undefined;
314
324
  }
@@ -112,6 +112,25 @@ export function registerPiTeams(pi: ExtensionAPI): void {
112
112
  let cleanedUp = false;
113
113
  const widgetState: CrewWidgetState = { frame: 0 };
114
114
  const foregroundControllers = new Set<AbortController>();
115
+ const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>): void => {
116
+ const controller = new AbortController();
117
+ foregroundControllers.add(controller);
118
+ setImmediate(() => {
119
+ void runner(controller.signal)
120
+ .catch((error) => {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ ctx.ui.notify(`pi-crew foreground run failed: ${message}`, "error");
123
+ })
124
+ .finally(() => {
125
+ foregroundControllers.delete(controller);
126
+ if (currentCtx) {
127
+ const config = loadConfig(currentCtx.cwd).config.ui;
128
+ updateCrewWidget(currentCtx, widgetState, config);
129
+ updatePiCrewPowerbar(pi.events, currentCtx.cwd, config);
130
+ }
131
+ });
132
+ });
133
+ };
115
134
  registerAutonomousPolicy(pi);
116
135
  rpcHandle = registerPiCrewRpc((pi as unknown as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events, () => currentCtx);
117
136
  const cleanupRuntime = (): void => {
@@ -163,7 +182,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
163
182
  const abort = (): void => controller.abort();
164
183
  signal?.addEventListener("abort", abort, { once: true });
165
184
  try {
166
- const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal });
185
+ const output = await handleTeamTool(params as TeamToolParamsValue, { ...ctx, signal: controller.signal, startForegroundRun: (runner) => startForegroundRun(ctx, runner) });
167
186
  const config = loadConfig(ctx.cwd).config.ui;
168
187
  updateCrewWidget(ctx, widgetState, config);
169
188
  updatePiCrewPowerbar(pi.events, ctx.cwd, config);
@@ -188,7 +207,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
188
207
  pi.registerCommand("team-run", {
189
208
  description: "Manually start a pi-crew run (agent may also use the team tool autonomously)",
190
209
  handler: async (args: string, ctx: ExtensionCommandContext) => {
191
- const result = await handleTeamTool(parseRunArgs(args), ctx);
210
+ const result = await handleTeamTool(parseRunArgs(args), { ...ctx, startForegroundRun: (runner) => startForegroundRun(ctx as ExtensionContext, runner) });
192
211
  await notifyCommandResult(ctx, commandText(result));
193
212
  },
194
213
  });
@@ -363,9 +382,12 @@ export function registerPiTeams(pi: ExtensionAPI): void {
363
382
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
364
383
  for (;;) {
365
384
  const runs = listRuns(ctx.cwd).slice(0, 50);
366
- const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme), {
385
+ const uiConfig = loadConfig(ctx.cwd).config.ui;
386
+ const rightPanel = uiConfig?.dashboardPlacement !== "center";
387
+ const width = rightPanel ? Math.min(120, Math.max(32, uiConfig?.dashboardWidth ?? 52)) : "90%";
388
+ const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme, { showModel: uiConfig?.showModel, showTokens: uiConfig?.showTokens, showTools: uiConfig?.showTools }), {
367
389
  overlay: true,
368
- overlayOptions: { width: "90%", maxHeight: "80%", anchor: "center" },
390
+ overlayOptions: { width, minWidth: rightPanel ? 32 : undefined, maxHeight: "90%", anchor: rightPanel ? "right-center" : "center", offsetX: rightPanel ? -1 : 0, margin: rightPanel ? { top: 1, right: 1, bottom: 1, left: 0 } : 2 },
369
391
  });
370
392
  if (!selection) return;
371
393
  if (selection.action === "reload") continue;
@@ -11,18 +11,34 @@ function readManifest(filePath: string): TeamRunManifest | undefined {
11
11
  }
12
12
  }
13
13
 
14
- function collectRuns(root: string): TeamRunManifest[] {
14
+ function collectRuns(root: string, maxEntries?: number): TeamRunManifest[] {
15
15
  const runsRoot = path.join(root, "state", "runs");
16
16
  if (!fs.existsSync(runsRoot)) return [];
17
- return fs.readdirSync(runsRoot)
17
+ const entries = fs.readdirSync(runsRoot).sort((a, b) => b.localeCompare(a));
18
+ const selected = maxEntries !== undefined ? entries.slice(0, Math.max(0, maxEntries)) : entries;
19
+ return selected
18
20
  .map((entry) => readManifest(path.join(runsRoot, entry, "manifest.json")))
19
21
  .filter((manifest): manifest is TeamRunManifest => manifest !== undefined);
20
22
  }
21
23
 
22
- export function listRuns(cwd: string): TeamRunManifest[] {
23
- const projectRuns = collectRuns(path.join(projectPiRoot(cwd), "teams"));
24
- const userRuns = collectRuns(path.join(userPiRoot(), "extensions", "pi-crew", "runs"));
24
+ function mergeRuns(userRuns: TeamRunManifest[], projectRuns: TeamRunManifest[], max?: number): TeamRunManifest[] {
25
25
  const byId = new Map<string, TeamRunManifest>();
26
26
  for (const run of [...userRuns, ...projectRuns]) byId.set(run.runId, run);
27
- return [...byId.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
27
+ const sorted = [...byId.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
28
+ return max !== undefined ? sorted.slice(0, Math.max(0, max)) : sorted;
29
+ }
30
+
31
+ export function listRuns(cwd: string): TeamRunManifest[] {
32
+ return mergeRuns(
33
+ collectRuns(path.join(userPiRoot(), "extensions", "pi-crew", "runs")),
34
+ collectRuns(path.join(projectPiRoot(cwd), "teams")),
35
+ );
36
+ }
37
+
38
+ export function listRecentRuns(cwd: string, max = 20): TeamRunManifest[] {
39
+ return mergeRuns(
40
+ collectRuns(path.join(userPiRoot(), "extensions", "pi-crew", "runs"), max),
41
+ collectRuns(path.join(projectPiRoot(cwd), "teams"), max),
42
+ max,
43
+ );
28
44
  }
@@ -35,7 +35,7 @@ import { formatValidationReport, validateResources } from "./validate-resources.
35
35
  import { formatRecommendation, recommendTeam } from "./team-recommendation.ts";
36
36
  import { toolResult, type PiTeamsToolResult } from "./tool-result.ts";
37
37
  import { touchWorkerHeartbeat } from "../runtime/worker-heartbeat.ts";
38
- import { agentEventsPath, agentOutputPath, readCrewAgentEvents, readCrewAgentEventsCursor, readCrewAgentStatus, readCrewAgents } from "../runtime/crew-agent-records.ts";
38
+ import { agentEventsPath, agentOutputPath, readCrewAgentEvents, readCrewAgentEventsCursor, readCrewAgentStatus, readCrewAgents, recordFromTask, saveCrewAgents } from "../runtime/crew-agent-records.ts";
39
39
  import { resolveCrewRuntime } from "../runtime/runtime-resolver.ts";
40
40
  import { probeLiveSessionRuntime } from "../runtime/live-session-runtime.ts";
41
41
  import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../runtime/agent-control.ts";
@@ -57,6 +57,7 @@ type TeamContext = Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext
57
57
  sessionManager?: { getBranch?: () => unknown[] };
58
58
  events?: { emit?: (event: string, data: unknown) => void };
59
59
  signal?: AbortSignal;
60
+ startForegroundRun?: (runner: (signal?: AbortSignal) => Promise<void>) => void;
60
61
  };
61
62
 
62
63
  function result(text: string, details: TeamToolDetails, isError = false): PiTeamsToolResult {
@@ -307,6 +308,24 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
307
308
  const runtime = await resolveCrewRuntime(effectiveRunConfig(loadedConfig.config, params.config));
308
309
  const executeWorkers = runtime.kind === "child-process";
309
310
  const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
311
+ if (executeWorkers && ctx.startForegroundRun) {
312
+ ctx.startForegroundRun(async (signal) => {
313
+ await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: executedConfig.limits, runtime, runtimeConfig: executedConfig.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, modelOverride: params.model, signal });
314
+ });
315
+ const text = [
316
+ `Started foreground pi-crew run ${updatedManifest.runId}.`,
317
+ `Team: ${team.name}`,
318
+ `Workflow: ${workflow.name}`,
319
+ "Status: running",
320
+ `Tasks: ${tasks.length}`,
321
+ `Runtime: ${runtime.kind}`,
322
+ `State: ${updatedManifest.stateRoot}`,
323
+ `Artifacts: ${updatedManifest.artifactsRoot}`,
324
+ "",
325
+ "The run continues in this Pi session without blocking the chat. It will be interrupted on session shutdown. Use /team-dashboard or /team-status to watch it.",
326
+ ].join("\n");
327
+ return result(text, { action: "run", status: "ok", runId: updatedManifest.runId, artifactsRoot: updatedManifest.artifactsRoot });
328
+ }
310
329
  const executed = await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: executedConfig.limits, runtime, runtimeConfig: executedConfig.runtime, parentContext: buildParentContext(ctx), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, modelOverride: params.model, signal: ctx.signal });
311
330
  const text = [
312
331
  `Created pi-crew run ${executed.manifest.runId}.`,
@@ -409,6 +428,8 @@ export function handleCancel(params: TeamToolParamsValue, ctx: TeamContext): PiT
409
428
  }
410
429
  const tasks = loaded.tasks.map((task) => task.status === "queued" || task.status === "running" ? { ...task, status: "cancelled" as const, finishedAt: new Date().toISOString(), error: "Run cancelled by user request." } : task);
411
430
  saveRunTasks(loaded.manifest, tasks);
431
+ try { saveCrewAgents(loaded.manifest, tasks.map((task) => recordFromTask(loaded.manifest, task, "child-process"))); } catch {}
432
+ try { writeForegroundInterruptRequest(loaded.manifest, "Run cancelled by user request."); } catch {}
412
433
  const updated = updateRunStatus(loaded.manifest, "cancelled", "Run cancelled by user request. Already-finished worker processes are not retroactively changed.");
413
434
  return result(`Cancelled run ${updated.runId}.`, { action: "cancel", status: "ok", runId: updated.runId, artifactsRoot: updated.artifactsRoot });
414
435
  });
@@ -8,8 +8,17 @@ import { getPiSpawnCommand } from "./pi-spawn.ts";
8
8
  const POST_EXIT_STDIO_GUARD_MS = 3000;
9
9
  const FINAL_DRAIN_MS = 5000;
10
10
  const HARD_KILL_MS = 3000;
11
+ const MAX_CAPTURE_BYTES = 256 * 1024;
11
12
  const activeChildProcesses = new Map<number, ChildProcess>();
12
13
 
14
+ function appendBoundedTail(current: string, chunk: string, maxBytes = MAX_CAPTURE_BYTES): string {
15
+ const combined = current + chunk;
16
+ if (Buffer.byteLength(combined, "utf-8") <= maxBytes) return combined;
17
+ let tail = combined.slice(Math.max(0, combined.length - maxBytes));
18
+ while (Buffer.byteLength(tail, "utf-8") > maxBytes) tail = tail.slice(1024);
19
+ return `[pi-crew captured output truncated to last ${Math.round(maxBytes / 1024)} KiB]\n${tail}`;
20
+ }
21
+
13
22
  function killProcessTree(pid: number | undefined): void {
14
23
  if (!pid || !Number.isInteger(pid) || pid <= 0) return;
15
24
  try {
@@ -59,6 +68,63 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
59
68
  fs.appendFileSync(input.transcriptPath, `${line}\n`, "utf-8");
60
69
  }
61
70
 
71
+ function compactContentPart(part: unknown): unknown | undefined {
72
+ const record = asRecord(part);
73
+ if (!record) return undefined;
74
+ if (record.type === "text") return { type: "text", text: typeof record.text === "string" ? record.text : "" };
75
+ if (record.type === "toolCall") return { type: "toolCall", name: record.name, input: record.input };
76
+ if (record.type === "toolResult") return { type: "toolResult", name: record.name, content: record.content };
77
+ return undefined;
78
+ }
79
+
80
+ function compactChildPiEvent(event: unknown): unknown | undefined {
81
+ const record = asRecord(event);
82
+ if (!record) return undefined;
83
+ if (record.type === "message_update") return undefined;
84
+ if (record.type === "tool_execution_start" || record.type === "tool_execution_end") {
85
+ return { type: record.type, toolName: record.toolName, args: record.args };
86
+ }
87
+ if (record.type === "tool_result_end" || record.type === "message_end" || record.type === "message") {
88
+ const message = asRecord(record.message);
89
+ const content = Array.isArray(message?.content) ? message.content.map(compactContentPart).filter((part) => part !== undefined) : undefined;
90
+ return {
91
+ type: record.type,
92
+ ...(typeof record.text === "string" ? { text: record.text } : {}),
93
+ ...(message ? { message: { role: message.role, ...(content ? { content } : {}), usage: message.usage, model: message.model, errorMessage: message.errorMessage, stopReason: message.stopReason } } : {}),
94
+ usage: record.usage,
95
+ model: record.model,
96
+ provider: record.provider,
97
+ stopReason: record.stopReason,
98
+ };
99
+ }
100
+ return record.type ? { type: record.type } : undefined;
101
+ }
102
+
103
+ function displayTextFromCompactEvent(event: unknown): string | undefined {
104
+ const record = asRecord(event);
105
+ if (!record) return undefined;
106
+ if (record.type === "tool_execution_start") {
107
+ return typeof record.toolName === "string" ? `tool: ${record.toolName}` : "tool started";
108
+ }
109
+ const message = asRecord(record.message);
110
+ const content = Array.isArray(message?.content) ? message.content : [];
111
+ const text = content.flatMap((part) => {
112
+ const item = asRecord(part);
113
+ return item?.type === "text" && typeof item.text === "string" ? [item.text] : [];
114
+ }).join("\n").trim();
115
+ return text || (typeof record.text === "string" ? record.text : undefined);
116
+ }
117
+
118
+ function compactChildPiLine(line: string): { persistedLine: string; event?: unknown; displayLine?: string; json: boolean } {
119
+ try {
120
+ const parsed = JSON.parse(line);
121
+ const compact = compactChildPiEvent(parsed);
122
+ return { json: true, event: compact, persistedLine: compact ? JSON.stringify(compact) : "", displayLine: displayTextFromCompactEvent(compact) };
123
+ } catch {
124
+ return { json: false, persistedLine: line, displayLine: line };
125
+ }
126
+ }
127
+
62
128
  export class ChildPiLineObserver {
63
129
  private buffer = "";
64
130
  private readonly input: ChildPiRunInput;
@@ -83,13 +149,10 @@ export class ChildPiLineObserver {
83
149
 
84
150
  private emitLine(line: string): void {
85
151
  if (!line.trim()) return;
86
- appendTranscript(this.input, line);
87
- this.input.onStdoutLine?.(line);
88
- try {
89
- this.input.onJsonEvent?.(JSON.parse(line));
90
- } catch {
91
- // Raw stdout is allowed.
92
- }
152
+ const compact = compactChildPiLine(line);
153
+ if (compact.event !== undefined) this.input.onJsonEvent?.(compact.event);
154
+ if (compact.persistedLine) appendTranscript(this.input, compact.persistedLine);
155
+ if (compact.displayLine?.trim()) this.input.onStdoutLine?.(compact.displayLine);
93
156
  }
94
157
  }
95
158
 
@@ -156,6 +219,10 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
156
219
  let forcedFinalDrain = false;
157
220
  const lineObserver = new ChildPiLineObserver({
158
221
  ...input,
222
+ onStdoutLine: (line) => {
223
+ stdout = appendBoundedTail(stdout, `${line}\n`);
224
+ input.onStdoutLine?.(line);
225
+ },
159
226
  onJsonEvent: (event) => {
160
227
  input.onJsonEvent?.(event);
161
228
  if (!isFinalAssistantEvent(event) || childExited || settled || finalDrainTimer) return;
@@ -202,12 +269,10 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
202
269
 
203
270
  input.signal?.addEventListener("abort", abort, { once: true });
204
271
  child.stdout?.on("data", (chunk: Buffer) => {
205
- const text = chunk.toString("utf-8");
206
- stdout += text;
207
- lineObserver.observe(text);
272
+ lineObserver.observe(chunk.toString("utf-8"));
208
273
  });
209
274
  child.stderr?.on("data", (chunk: Buffer) => {
210
- stderr += chunk.toString("utf-8");
275
+ stderr = appendBoundedTail(stderr, chunk.toString("utf-8"));
211
276
  });
212
277
  child.on("error", (error) => {
213
278
  settle({ exitCode: null, stdout, stderr, error: error.message });
@@ -52,8 +52,13 @@ export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: st
52
52
  return readJsonFile<CrewAgentRecord>(agentStatusPath(manifest, taskId));
53
53
  }
54
54
 
55
+ const agentEventSeqCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
56
+
55
57
  function nextAgentEventSeq(filePath: string): number {
56
58
  if (!fs.existsSync(filePath)) return 1;
59
+ const stat = fs.statSync(filePath);
60
+ const cached = agentEventSeqCache.get(filePath);
61
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.seq + 1;
57
62
  let max = 0;
58
63
  for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
59
64
  if (!line.trim()) continue;
@@ -65,13 +70,19 @@ function nextAgentEventSeq(filePath: string): number {
65
70
  max += 1;
66
71
  }
67
72
  }
73
+ agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: max });
68
74
  return max + 1;
69
75
  }
70
76
 
71
77
  export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string, event: unknown): void {
72
78
  fs.mkdirSync(agentStateDir(manifest, taskId), { recursive: true });
73
79
  const filePath = agentEventsPath(manifest, taskId);
74
- fs.appendFileSync(filePath, `${JSON.stringify({ seq: nextAgentEventSeq(filePath), time: new Date().toISOString(), event })}\n`, "utf-8");
80
+ const seq = nextAgentEventSeq(filePath);
81
+ fs.appendFileSync(filePath, `${JSON.stringify({ seq, time: new Date().toISOString(), event })}\n`, "utf-8");
82
+ try {
83
+ const stat = fs.statSync(filePath);
84
+ agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
85
+ } catch {}
75
86
  }
76
87
 
77
88
  export interface CrewAgentEventCursorOptions {
@@ -185,6 +185,24 @@ function applyUsageToProgress(progress: CrewAgentProgress | undefined, usage: Us
185
185
  };
186
186
  }
187
187
 
188
+ function shouldFlushProgressEvent(event: unknown): boolean {
189
+ const type = asRecord(event)?.type;
190
+ return type === "tool_execution_start" || type === "tool_execution_end" || type === "message_end" || type === "tool_result_end";
191
+ }
192
+
193
+ function progressEventSummary(task: TeamTaskState, event: unknown): Record<string, unknown> {
194
+ const type = asRecord(event)?.type;
195
+ return {
196
+ eventType: typeof type === "string" ? type : "event",
197
+ currentTool: task.agentProgress?.currentTool,
198
+ toolCount: task.agentProgress?.toolCount,
199
+ tokens: task.agentProgress?.tokens,
200
+ turns: task.agentProgress?.turns,
201
+ activityState: task.agentProgress?.activityState,
202
+ lastActivityAt: task.agentProgress?.lastActivityAt,
203
+ };
204
+ }
205
+
188
206
  function applyAgentProgressEvent(progress: CrewAgentProgress, event: unknown, startedAt: string | undefined): CrewAgentProgress {
189
207
  const obj = asRecord(event);
190
208
  const now = new Date().toISOString();
@@ -275,6 +293,19 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
275
293
  let finalStderr = "";
276
294
  modelAttempts = [];
277
295
  const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
296
+ let lastAgentRecordPersistedAt = 0;
297
+ let lastRunProgressPersistedAt = 0;
298
+ const persistChildProgress = (event: unknown, force = false): void => {
299
+ const now = Date.now();
300
+ if (force || shouldFlushProgressEvent(event) || now - lastAgentRecordPersistedAt >= 500) {
301
+ upsertCrewAgent(manifest, recordFromTask(manifest, task, "child-process"));
302
+ lastAgentRecordPersistedAt = now;
303
+ }
304
+ if (force || shouldFlushProgressEvent(event) || now - lastRunProgressPersistedAt >= 1000) {
305
+ appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: progressEventSummary(task, event) });
306
+ lastRunProgressPersistedAt = now;
307
+ }
308
+ };
278
309
  for (let i = 0; i < attemptModels.length; i++) {
279
310
  const model = attemptModels[i];
280
311
  const attemptStartedAt = new Date();
@@ -291,8 +322,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
291
322
  appendCrewAgentEvent(manifest, task.id, event);
292
323
  task = { ...task, agentProgress: applyAgentProgressEvent(task.agentProgress ?? emptyCrewAgentProgress(), event, task.startedAt) };
293
324
  tasks = updateTask(tasks, task);
294
- upsertCrewAgent(manifest, recordFromTask(manifest, task, "child-process"));
295
- appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: { event } });
325
+ persistChildProgress(event);
296
326
  },
297
327
  });
298
328
  startupEvidence = createStartupEvidence({ command: "pi", startedAt: attemptStartedAt, finishedAt: new Date(), promptSentAt: attemptStartedAt, promptAccepted: childResult.exitCode === 0 && !childResult.error, stderr: childResult.stderr, error: childResult.error, exitCode: childResult.exitCode });
@@ -301,6 +331,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
301
331
  finalStderr = childResult.stderr;
302
332
  parsedOutput = parsePiJsonOutput(childResult.stdout);
303
333
  error = childResult.error || (childResult.exitCode && childResult.exitCode !== 0 ? childResult.stderr || `Child Pi exited with ${childResult.exitCode}` : undefined);
334
+ persistChildProgress({ type: "attempt_finished" }, true);
304
335
  const attempt: ModelAttemptSummary = { model: model ?? "default", success: !error, exitCode, error };
305
336
  modelAttempts.push(attempt);
306
337
  logs.push(`MODEL ATTEMPT ${i + 1}: ${attempt.model}`, `success=${attempt.success}`, `exitCode=${attempt.exitCode ?? "null"}`, attempt.error ? `error=${attempt.error}` : "", "");
@@ -339,6 +370,19 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
339
370
  }
340
371
  } else if (runtimeKind === "live-session") {
341
372
  const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
373
+ let lastAgentRecordPersistedAt = 0;
374
+ let lastRunProgressPersistedAt = 0;
375
+ const persistLiveProgress = (event: unknown, force = false): void => {
376
+ const now = Date.now();
377
+ if (force || shouldFlushProgressEvent(event) || now - lastAgentRecordPersistedAt >= 500) {
378
+ upsertCrewAgent(manifest, recordFromTask(manifest, task, "live-session"));
379
+ lastAgentRecordPersistedAt = now;
380
+ }
381
+ if (force || shouldFlushProgressEvent(event) || now - lastRunProgressPersistedAt >= 1000) {
382
+ appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: progressEventSummary(task, event) });
383
+ lastRunProgressPersistedAt = now;
384
+ }
385
+ };
342
386
  const attemptStartedAt = new Date();
343
387
  const liveResult = await runLiveSessionTask({
344
388
  manifest,
@@ -357,8 +401,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
357
401
  appendCrewAgentEvent(manifest, task.id, event);
358
402
  task = { ...task, agentProgress: applyAgentProgressEvent(task.agentProgress ?? emptyCrewAgentProgress(), event, task.startedAt) };
359
403
  tasks = updateTask(tasks, task);
360
- upsertCrewAgent(manifest, recordFromTask(manifest, task, "live-session"));
361
- appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: { event } });
404
+ persistLiveProgress(event);
362
405
  },
363
406
  });
364
407
  startupEvidence = createStartupEvidence({ command: "live-session", startedAt: attemptStartedAt, finishedAt: new Date(), promptSentAt: attemptStartedAt, promptAccepted: liveResult.exitCode === 0 && !liveResult.error, stderr: liveResult.stderr, error: liveResult.error, exitCode: liveResult.exitCode });
@@ -366,6 +409,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
366
409
  error = liveResult.error || (liveResult.exitCode && liveResult.exitCode !== 0 ? liveResult.stderr || `Live session exited with ${liveResult.exitCode}` : undefined);
367
410
  parsedOutput = { finalText: liveResult.stdout, textEvents: liveResult.stdout ? [liveResult.stdout] : [], jsonEvents: liveResult.jsonEvents, usage: liveResult.usage };
368
411
  if (liveResult.usage) task = { ...task, usage: liveResult.usage, agentProgress: applyUsageToProgress(task.agentProgress, liveResult.usage) };
412
+ persistLiveProgress({ type: "attempt_finished" }, true);
369
413
  resultArtifact = writeArtifact(manifest.artifactsRoot, {
370
414
  kind: "result",
371
415
  relativePath: `results/${task.id}.txt`,
@@ -42,8 +42,13 @@ export type AppendTeamEvent = Omit<TeamEvent, "time" | "metadata"> & { metadata?
42
42
 
43
43
  const TERMINAL_EVENT_TYPES = new Set(["run.blocked", "run.completed", "run.failed", "run.cancelled", "task.completed", "task.failed", "task.cancelled", "task.skipped"]);
44
44
 
45
+ const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
46
+
45
47
  function nextSequence(eventsPath: string): number {
46
48
  if (!fs.existsSync(eventsPath)) return 1;
49
+ const stat = fs.statSync(eventsPath);
50
+ const cached = sequenceCache.get(eventsPath);
51
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.seq + 1;
47
52
  let max = 0;
48
53
  for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) {
49
54
  if (!line.trim()) continue;
@@ -54,6 +59,7 @@ function nextSequence(eventsPath: string): number {
54
59
  max += 1;
55
60
  }
56
61
  }
62
+ sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: max });
57
63
  return max + 1;
58
64
  }
59
65
 
@@ -82,6 +88,10 @@ export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEve
82
88
  fullEvent.metadata = metadata;
83
89
  }
84
90
  fs.appendFileSync(eventsPath, `${JSON.stringify(fullEvent)}\n`, "utf-8");
91
+ try {
92
+ const stat = fs.statSync(eventsPath);
93
+ sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: fullEvent.metadata?.seq ?? 0 });
94
+ } catch {}
85
95
  return fullEvent;
86
96
  }
87
97
 
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import type { CrewUiConfig } from "../config/config.ts";
3
- import { listRuns } from "../extension/run-index.ts";
3
+ import { listRecentRuns } from "../extension/run-index.ts";
4
4
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
5
5
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
6
6
  import { isDisplayActiveRun } from "../runtime/process-status.ts";
@@ -106,8 +106,7 @@ function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
106
106
  }
107
107
 
108
108
  function activeWidgetRuns(cwd: string): WidgetRun[] {
109
- const runs = listRuns(cwd).slice(0, 20);
110
- return runs.map((run) => ({ run, agents: agentsFor(run) })).filter((item) => isDisplayActiveRun(item.run, item.agents));
109
+ return listRecentRuns(cwd, 20).map((run) => ({ run, agents: agentsFor(run) })).filter((item) => isDisplayActiveRun(item.run, item.agents));
111
110
  }
112
111
 
113
112
  function statusSummary(runs: WidgetRun[]): string {
@@ -136,8 +135,8 @@ function shortRunLabel(run: TeamRunManifest): string {
136
135
  return `${run.team}/${run.workflow ?? "none"}`;
137
136
  }
138
137
 
139
- export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
140
- const runs = activeWidgetRuns(cwd);
138
+ export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[]): string[] {
139
+ const runs = providedRuns ?? activeWidgetRuns(cwd);
141
140
  if (!runs.length) return [];
142
141
  const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
143
142
  const lines: string[] = [widgetHeader(runs, runningGlyph)];
@@ -193,9 +192,10 @@ export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "
193
192
  if (!ctx.hasUI) return;
194
193
  state.frame += 1;
195
194
  const maxLines = config?.widgetMaxLines ?? 10;
196
- const lines = buildCrewWidgetLines(ctx.cwd, state.frame, maxLines);
195
+ const runs = activeWidgetRuns(ctx.cwd);
196
+ const lines = buildCrewWidgetLines(ctx.cwd, state.frame, maxLines, runs);
197
197
  const placement = config?.widgetPlacement ?? "aboveEditor";
198
- ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(activeWidgetRuns(ctx.cwd)) : undefined);
198
+ ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
199
199
  ctx.ui.setWidget(LEGACY_WIDGET_KEY, undefined, { placement });
200
200
  if (!lines.length) {
201
201
  ctx.ui.setWidget(WIDGET_KEY, undefined, { placement });
@@ -1,5 +1,5 @@
1
1
  import type { CrewUiConfig } from "../config/config.ts";
2
- import { listRuns } from "../extension/run-index.ts";
2
+ import { listRecentRuns } from "../extension/run-index.ts";
3
3
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
4
4
  import { isDisplayActiveRun } from "../runtime/process-status.ts";
5
5
 
@@ -17,7 +17,7 @@ export function registerPiCrewPowerbarSegments(events: EventBus, config?: CrewUi
17
17
 
18
18
  export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: CrewUiConfig): void {
19
19
  if (config?.powerbar === false) return;
20
- const active = listRuns(cwd).slice(0, 20).map((run) => {
20
+ const active = listRecentRuns(cwd, 20).map((run) => {
21
21
  let agents = [] as ReturnType<typeof readCrewAgents>;
22
22
  try { agents = readCrewAgents(run); } catch {}
23
23
  return { run, agents };
@@ -1,5 +1,5 @@
1
1
  import * as fs from "node:fs";
2
- import type { TeamRunManifest } from "../state/types.ts";
2
+ import type { TeamRunManifest, TeamTaskState, UsageState } 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
5
  import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
@@ -12,6 +12,12 @@ interface DashboardComponent {
12
12
 
13
13
  type DashboardTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
14
14
 
15
+ export interface RunDashboardOptions {
16
+ showModel?: boolean;
17
+ showTokens?: boolean;
18
+ showTools?: boolean;
19
+ }
20
+
15
21
  export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "reload";
16
22
  export interface RunDashboardSelection {
17
23
  runId: string;
@@ -89,12 +95,44 @@ function formatAge(iso: string | undefined): string | undefined {
89
95
  return `${Math.floor(ms / 3_600_000)}h`;
90
96
  }
91
97
 
92
- function agentPreviewLine(agent: CrewAgentRecord): string {
98
+ function readRunTasks(run: TeamRunManifest): TeamTaskState[] {
99
+ try {
100
+ if (!fs.existsSync(run.tasksPath)) return [];
101
+ const parsed = JSON.parse(fs.readFileSync(run.tasksPath, "utf-8"));
102
+ return Array.isArray(parsed) ? parsed as TeamTaskState[] : [];
103
+ } catch { return []; }
104
+ }
105
+
106
+ function formatTokens(usage: UsageState | undefined): string | undefined {
107
+ if (!usage) return undefined;
108
+ const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
109
+ if (!total) return undefined;
110
+ const compact = total >= 1000 ? `${(total / 1000).toFixed(total >= 10_000 ? 0 : 1)}k` : `${total}`;
111
+ const parts = [`tok=${compact}`];
112
+ if (usage.input) parts.push(`in=${usage.input}`);
113
+ if (usage.output) parts.push(`out=${usage.output}`);
114
+ if (usage.cacheRead) parts.push(`cache=${usage.cacheRead}`);
115
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
116
+ return parts.join("/");
117
+ }
118
+
119
+ function taskForAgent(tasks: TeamTaskState[], agent: CrewAgentRecord): TeamTaskState | undefined {
120
+ return tasks.find((task) => task.id === agent.taskId);
121
+ }
122
+
123
+ function modelForTask(task: TeamTaskState | undefined): string | undefined {
124
+ const attempts = task?.modelAttempts;
125
+ if (!attempts?.length) return undefined;
126
+ return attempts.find((attempt) => attempt.success)?.model ?? attempts.at(-1)?.model;
127
+ }
128
+
129
+ function agentPreviewLine(agent: CrewAgentRecord, task: TeamTaskState | undefined, options: RunDashboardOptions): string {
93
130
  const stats = [
94
131
  agent.progress?.activityState,
95
- agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
96
- agent.toolUses !== undefined ? `${agent.toolUses} tools` : undefined,
97
- agent.progress?.tokens !== undefined ? `${agent.progress.tokens} tok` : undefined,
132
+ options.showModel !== false && modelForTask(task) ? `model=${modelForTask(task)}` : undefined,
133
+ options.showTokens !== false ? formatTokens(task?.usage) ?? (agent.progress?.tokens !== undefined ? `tok=${agent.progress.tokens}` : undefined) : undefined,
134
+ options.showTools !== false && agent.progress?.currentTool ? `tool=${agent.progress.currentTool}` : undefined,
135
+ options.showTools !== false && agent.toolUses !== undefined ? `${agent.toolUses} tools` : undefined,
98
136
  agent.progress?.turns !== undefined ? `${agent.progress.turns} turns` : undefined,
99
137
  agent.progress?.failedTool ? `failedTool=${agent.progress.failedTool}` : undefined,
100
138
  agent.startedAt ? `age=${formatAge(agent.completedAt ?? agent.startedAt)}` : undefined,
@@ -103,11 +141,21 @@ function agentPreviewLine(agent: CrewAgentRecord): string {
103
141
  return `Agent: ${statusIcon(agent.status)} ${agent.taskId} ${agent.role}->${agent.agent}${stats.length ? ` · ${stats.join(" · ")}` : ""}${recent ? ` ⎿ ${recent}` : ""}`;
104
142
  }
105
143
 
106
- function readAgentPreview(run: TeamRunManifest, maxLines = 5): string[] {
144
+ function readAgentPreview(run: TeamRunManifest, maxLines = 5, options: RunDashboardOptions = {}): string[] {
107
145
  try {
108
146
  const agents = readCrewAgents(run);
147
+ const tasks = readRunTasks(run);
109
148
  if (!agents.length) return ["Agents: (none)"];
110
- return ["Agents:", ...agents.slice(0, maxLines).map(agentPreviewLine), ...(agents.length > maxLines ? [`Agents: +${agents.length - maxLines} more`] : [])];
149
+ const totals = tasks.reduce((acc, task) => {
150
+ acc.input += task.usage?.input ?? 0;
151
+ acc.output += task.usage?.output ?? 0;
152
+ acc.cacheRead += task.usage?.cacheRead ?? 0;
153
+ acc.cacheWrite += task.usage?.cacheWrite ?? 0;
154
+ acc.cost += task.usage?.cost ?? 0;
155
+ return acc;
156
+ }, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
157
+ const header = formatTokens(totals) ? `Agents: ${formatTokens(totals)}` : "Agents:";
158
+ return [header, ...agents.slice(0, maxLines).map((agent) => agentPreviewLine(agent, taskForAgent(tasks, agent), options)), ...(agents.length > maxLines ? [`Agents: +${agents.length - maxLines} more`] : [])];
111
159
  } catch (error) {
112
160
  const message = error instanceof Error ? error.message : String(error);
113
161
  return [`Agents: failed to read (${message})`];
@@ -154,11 +202,13 @@ export class RunDashboard implements DashboardComponent {
154
202
  private readonly runs: TeamRunManifest[];
155
203
  private readonly done: (selection: RunDashboardSelection | undefined) => void;
156
204
  private readonly theme: DashboardTheme;
205
+ private readonly options: RunDashboardOptions;
157
206
 
158
- constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void, theme: unknown = {}) {
207
+ constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void, theme: unknown = {}, options: RunDashboardOptions = {}) {
159
208
  this.runs = runs;
160
209
  this.done = done;
161
210
  this.theme = theme as DashboardTheme;
211
+ this.options = options;
162
212
  }
163
213
 
164
214
  invalidate(): void {}
@@ -203,7 +253,7 @@ export class RunDashboard implements DashboardComponent {
203
253
  selectedRun.async ? `Async: pid=${selectedRun.async.pid ?? "unknown"} log=${selectedRun.async.logPath}` : "Async: no",
204
254
  `Goal: ${selectedRun.goal}`,
205
255
  ];
206
- for (const detail of [...details, ...readAgentPreview(selectedRun), ...readProgressPreview(selectedRun, this.showFullProgress ? 20 : 5)]) {
256
+ for (const detail of [...details, ...readAgentPreview(selectedRun, this.showFullProgress ? 20 : 8, this.options), ...readProgressPreview(selectedRun, this.showFullProgress ? 20 : 5)]) {
207
257
  lines.push(`│ ${padVisible(truncate(detail, innerWidth - 1), innerWidth - 1)}│`);
208
258
  }
209
259
  }