pi-crew 0.9.37 → 0.9.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.37",
3
+ "version": "0.9.39",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -694,7 +694,7 @@ function parseIsolationPolicy(value: unknown): CrewRuntimeConfig["isolationPolic
694
694
 
695
695
  function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
696
696
  const obj = asRecord(value);
697
- if (!obj) return undefined;
697
+ if (!obj) return { inheritContext: true } as CrewRuntimeConfig;
698
698
  const runtime: CrewRuntimeConfig = {
699
699
  mode: parseWithSchema(
700
700
  Type.Union([Type.Literal("auto"), Type.Literal("scaffold"), Type.Literal("child-process"), Type.Literal("live-session")]),
@@ -704,7 +704,7 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
704
704
  allowChildProcessFallback: parseWithSchema(Type.Boolean(), obj.allowChildProcessFallback),
705
705
  maxTurns: parsePositiveInteger(obj.maxTurns, LIMIT_CEILINGS.runtimeMaxTurns),
706
706
  graceTurns: parsePositiveInteger(obj.graceTurns, LIMIT_CEILINGS.runtimeGraceTurns),
707
- inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext),
707
+ inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext) ?? true,
708
708
  promptMode: parseWithSchema(Type.Union([Type.Literal("replace"), Type.Literal("append")]), obj.promptMode),
709
709
  groupJoin: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")]), obj.groupJoin),
710
710
  groupJoinAckTimeoutMs: parsePositiveInteger(obj.groupJoinAckTimeoutMs, 86_400_000),
@@ -1,5 +1,5 @@
1
1
  import type { SpeedConfig } from "./config.ts";
2
- import { isWebTerminal } from "./font-detect.ts";
2
+ import { hasCrewFontFile, isWebTerminal } from "./font-detect.ts";
3
3
 
4
4
  /**
5
5
  * Crew figures replacing the original cat glyphs from pi-speeed / pi-chonk.
@@ -57,12 +57,19 @@ const PUA_CREW_FRAMES: readonly string[] = [
57
57
  /**
58
58
  * Return the indicator frames for the given style.
59
59
  *
60
- * - `"braille"` (default): standard Unicode braille spinner, works everywhere
61
- * - `"pua"`: runner-pose glyphs from crew-vibes.ttf, requires font install
60
+ * Default is `"braille"` — standard Unicode braille spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏)
61
+ * that renders correctly on every terminal, every platform, with zero
62
+ * font dependency. PUA runner-glyphs require crew-vibes.ttf installed AND
63
+ * a terminal that does font fallback for Private Use Area codepoints —
64
+ * Windows Terminal does NOT do this reliably, showing garbage chars even
65
+ * with the font installed.
66
+ *
67
+ * To opt into the PUA runner look, pass `style: "pua"` explicitly.
62
68
  */
63
- export function crewFrames(style: "braille" | "pua" = "pua"): readonly string[] {
64
- // Web terminals (gotty, wetty) cannot render PUA glyphs — fall back to braille
65
- if (style === "pua" && isWebTerminal()) return BRAILLE_FRAMES;
69
+ export function crewFrames(style: "braille" | "pua" = "braille"): readonly string[] {
70
+ // Even when explicitly requested, fall back to braille if the font is
71
+ // missing or we're on a web terminal (which can't render PUA at all).
72
+ if (style === "pua" && (isWebTerminal() || !hasCrewFontFile())) return BRAILLE_FRAMES;
66
73
  return style === "pua" ? PUA_CREW_FRAMES : BRAILLE_FRAMES;
67
74
  }
68
75
 
@@ -1,5 +1,24 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { requestRender, setFooter } from "../../ui/pi-ui-compat.ts";
3
+ import { logInternalError } from "../../utils/internal-error.ts";
4
+
5
+ /**
6
+ * Wrap a pi UI call in try/catch. If the call throws (e.g. terminal doesn't
7
+ * support PUA glyphs, theme helper returns undefined, or pi's API signature
8
+ * changed), log it and silently fall back to pi's default behavior — which
9
+ * is simply "do nothing", letting pi show its own built-in indicator.
10
+ *
11
+ * Crew-vibes must NEVER break the user's session. A broken spinner is not
12
+ * worth a crashed pi.
13
+ */
14
+ function safeUiCall(scope: string, fn: () => void): void {
15
+ try {
16
+ fn();
17
+ } catch (error) {
18
+ logInternalError(`crew-vibes.${scope}`, error, undefined, "warn");
19
+ }
20
+ }
21
+
3
22
  import { type CrewVibesConfig, loadConfig, PROVIDER_STATUS_ID, saveConfig } from "./config.ts";
4
23
  import { intervalForSpeed } from "./figures.ts";
5
24
  import { type CrewVibesFooterSource, createCrewVibesFooter } from "./footer.ts";
@@ -72,44 +91,48 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
72
91
  function installFooter(ctx: ExtensionContext): void {
73
92
  if (!ctx?.hasUI) return;
74
93
  if (!metersActive()) {
75
- setFooter(ctx, undefined);
94
+ safeUiCall("clear-footer", () => setFooter(ctx, undefined));
76
95
  return;
77
96
  }
78
- setFooter(ctx, (tui, theme, footerData) => createCrewVibesFooter({ tui, theme, footerData, ctx, source: footerSource }));
79
- requestRender(ctx);
97
+ safeUiCall("install-footer", () => {
98
+ setFooter(ctx, (tui, theme, footerData) => createCrewVibesFooter({ tui, theme, footerData, ctx, source: footerSource }));
99
+ requestRender(ctx);
100
+ });
80
101
  }
81
102
 
82
103
  /** Trigger a footer repaint; the footer recomputes capacity/quota on render. */
83
104
  function refreshFooter(ctx: ExtensionContext): void {
84
- if (ctx?.hasUI) requestRender(ctx);
105
+ if (ctx?.hasUI) safeUiCall("refresh-footer", () => requestRender(ctx));
85
106
  }
86
107
 
87
108
  function publishSpeedFooter(ctx: ExtensionContext, speed = footerAnimator.value()): void {
88
109
  if (!config.enabled || !config.speed.enabled || !config.speed.footer) {
89
- setSpeedStatus(ctx, config, undefined);
110
+ safeUiCall("clear-speed-status", () => setSpeedStatus(ctx, config, undefined));
90
111
  return;
91
112
  }
92
- setSpeedStatus(ctx, config, renderSpeedFooter(themeOf(ctx), config.speed, speed));
113
+ safeUiCall("publish-speed-status", () => setSpeedStatus(ctx, config, renderSpeedFooter(themeOf(ctx), config.speed, speed)));
93
114
  }
94
115
 
95
116
  function applyIndicator(ctx: ExtensionContext, speed: number | null, force = false): void {
96
117
  if (!ctx.hasUI || !ctx.ui.setWorkingIndicator) return;
97
118
  if (!config.enabled || !config.speed.enabled || !config.speed.indicator) {
98
- ctx.ui.setWorkingIndicator();
119
+ safeUiCall("reset-indicator", () => ctx.ui.setWorkingIndicator?.());
99
120
  return;
100
121
  }
101
122
  const next = intervalForSpeed(config.speed, speed);
102
123
  if (!force && Math.abs(next - currentIntervalMs) < 10) return;
103
- ctx.ui.setWorkingIndicator({
104
- frames: crewIndicatorFrames(themeOf(ctx)),
105
- intervalMs: next,
106
- });
124
+ safeUiCall("set-indicator", () =>
125
+ ctx.ui.setWorkingIndicator?.({
126
+ frames: crewIndicatorFrames(themeOf(ctx)),
127
+ intervalMs: next,
128
+ }),
129
+ );
107
130
  currentIntervalMs = next;
108
131
  }
109
132
 
110
133
  function renderWorking(ctx: ExtensionContext, speed: number | null): void {
111
134
  if (!config.enabled || !config.speed.enabled || !ctx.hasUI) return;
112
- ctx.ui.setWorkingMessage(renderWorkingMessage(themeOf(ctx), config.speed, speed));
135
+ safeUiCall("set-working-message", () => ctx.ui.setWorkingMessage?.(renderWorkingMessage(themeOf(ctx), config.speed, speed)));
113
136
  }
114
137
 
115
138
  function stopLiveTimer(): void {
@@ -315,7 +338,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
315
338
  stopLiveTimer();
316
339
  if (ctx && config.enabled && ctx.hasUI) {
317
340
  applyIndicator(ctx, speedTracker.lastTokS);
318
- ctx.ui.setWorkingMessage();
341
+ safeUiCall("clear-working-message", () => ctx.ui.setWorkingMessage?.());
319
342
  }
320
343
  });
321
344
 
@@ -130,7 +130,6 @@ function purgeStaleActiveRunIndexSyncIfLoaded(): void {
130
130
 
131
131
  import { pruneFinishedRuns, pruneUserLevelRuns } from "../extension/run-maintenance.ts";
132
132
  import { initI18n } from "../i18n.ts";
133
- import { globalProgressTracker } from "../runtime/progress-tracker.ts";
134
133
  // H3-L2 split: DeliveryCoordinator + OverflowRecoveryTracker moved to registration/lifecycle.ts.
135
134
  import { tryRegisterSessionCleanup } from "../runtime/session-resources.ts";
136
135
  import { createSessionSnapshot } from "../runtime/session-snapshot.ts";
@@ -703,6 +702,58 @@ export function registerPiTeams(pi: ExtensionAPI): void {
703
702
  };
704
703
  });
705
704
 
705
+ /**
706
+ * Cleanup session resources WITHOUT aborting foreground team runs.
707
+ * Used during session switch (reason="resume"/"new"/"fork") to let foreground
708
+ * runs complete naturally when the session context is torn down.
709
+ */
710
+ const cleanupSessionResourcesOnly = (): void => {
711
+ if (cleanedUp) return;
712
+ cleanedUp = true;
713
+ if (preloadTimer) {
714
+ clearTimeout(preloadTimer);
715
+ preloadTimer = undefined;
716
+ }
717
+ crewRunWatchers?.closeAll();
718
+ crewRunWatchers = undefined;
719
+ userCrewWatchers?.closeAll();
720
+ userCrewWatchers = undefined;
721
+ stopSessionBoundSubagents();
722
+ // P0 fix: Do NOT abort foreground team runs on session switch.
723
+ // Foreground team runs run in the same process as the session; they naturally clean up
724
+ // when the session context is torn down. Only subagents need explicit abort on switch.
725
+ // Foreground runs will be aborted by cleanupRuntime() during actual session shutdown.
726
+ crewScheduler?.stop();
727
+ stopAsyncRunNotifier(notifierState);
728
+
729
+ // P0: Purge all stale active-run-index entries on session cleanup.
730
+ purgeStaleActiveRunIndexSyncIfLoaded();
731
+
732
+ stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
733
+ clearPiCrewPowerbar(pi.events);
734
+ disposePowerbarCoalescer();
735
+ disposeObservability(observabilityState, cleanedUp);
736
+ lifecycleState.deliveryCoordinator?.dispose();
737
+ clearHooksScoped();
738
+ uninstallCrewGlobalRegistry();
739
+ lifecycleState.overflowTracker?.dispose();
740
+ lifecycleState.deliveryCoordinator = undefined;
741
+ lifecycleState.overflowTracker = undefined;
742
+ manifestCache.dispose();
743
+ runSnapshotCache.dispose?.();
744
+ clearProjectRootCache();
745
+ renderScheduler?.dispose();
746
+ renderScheduler = undefined;
747
+ autoRecoveryLast.clear();
748
+ disposeNotifications(lifecycleState);
749
+ rpcHandle?.unsubscribe();
750
+ rpcHandle = undefined;
751
+ disposeI18n();
752
+ sessionGeneration += 1;
753
+ currentCtx = undefined;
754
+ if (globalStore[runtimeCleanupStoreKey] === cleanupSessionResourcesOnly) delete globalStore[runtimeCleanupStoreKey];
755
+ };
756
+
706
757
  const cleanupRuntime = (): void => {
707
758
  if (cleanedUp) return;
708
759
  cleanedUp = true;
@@ -775,6 +826,20 @@ export function registerPiTeams(pi: ExtensionAPI): void {
775
826
  };
776
827
  globalStore[runtimeCleanupStoreKey] = cleanupRuntime;
777
828
 
829
+ // P0 fix: Check session_shutdown reason to determine whether to abort foreground runs.
830
+ // - reason="quit" or "reload": Actual shutdown — abort foreground runs.
831
+ // - reason="resume"/"new"/"fork": Session switch — let foreground runs complete.
832
+ pi.on("session_shutdown", (event) => {
833
+ const reason = typeof event === "object" && event !== null && "reason" in event ? (event as { reason: string }).reason : undefined;
834
+ if (reason === "quit" || reason === "reload") {
835
+ // Actual shutdown — abort foreground runs and cleanup everything
836
+ cleanupRuntime();
837
+ } else {
838
+ // Session switch (resume/new/fork) — cleanup resources but preserve foreground runs
839
+ cleanupSessionResourcesOnly();
840
+ }
841
+ });
842
+
778
843
  pi.on("session_start", (_event, ctx) => {
779
844
  runArtifactCleanup(ctx.cwd);
780
845
 
@@ -1386,7 +1451,6 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1386
1451
  stopAsyncRunNotifier(notifierState);
1387
1452
  stopSessionBoundSubagents();
1388
1453
  });
1389
- pi.on("session_shutdown", () => cleanupRuntime());
1390
1454
 
1391
1455
  // Phase 11a: Dynamic resource discovery — inject pi-crew skill paths.
1392
1456
  try {
@@ -1475,11 +1539,6 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1475
1539
  const record = event as Record<string, unknown>;
1476
1540
  const eventType = typeof record.type === "string" ? record.type : undefined;
1477
1541
  if (eventType) lifecycleState.overflowTracker?.feedEvent(taskId, runId, eventType);
1478
- // Phase 2: forward worker events to progress tracker for real-time
1479
- // widget display (tool calls, assistant text) on child-process runtime.
1480
- if (record && typeof record === "object") {
1481
- globalProgressTracker.handleWorkerEvent(taskId, runId, record);
1482
- }
1483
1542
  },
1484
1543
  });
1485
1544
  registerSubagentTools(pi, subagentManager, {
@@ -23,7 +23,6 @@ import { loadConfig } from "../../config/config.ts";
23
23
  import { t } from "../../i18n.ts";
24
24
  import type { BatchBarrier } from "../../runtime/batch-barrier.ts";
25
25
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
26
- import { globalProgressTracker } from "../../runtime/progress-tracker.ts";
27
26
  import { checkSubagentSpawnPermission, currentCrewRole } from "../../runtime/role-permission.ts";
28
27
  import { loadRunManifestById } from "../../state/state-store.ts";
29
28
  import {
@@ -445,23 +444,7 @@ function startAgentToolProgress(cwd: string, agentRecordId: string, onUpdate: On
445
444
  agents,
446
445
  error: record.error,
447
446
  });
448
- // Phase 2C: enrich with real-time worker progress (tool calls, partial text)
449
- // from globalProgressTracker. For foreground agents, find the task ID from
450
- // the manifest and look up streaming progress.
451
- let progressLine = text;
452
- if (tasks && tasks.length > 0) {
453
- const wp = globalProgressTracker.getWorkerProgress(tasks[0].id);
454
- if (wp) {
455
- const toolLine = wp.currentTool ? `\n ⚡ tool: ${wp.currentTool}` : "";
456
- const tokenLine =
457
- wp.tokens.input + wp.tokens.output > 0
458
- ? `\n 📊 tokens: ${(wp.tokens.input / 1000).toFixed(1)}K in, ${(wp.tokens.output / 1000).toFixed(1)}K out`
459
- : "";
460
- const textLine = wp.partialText ? `\n 💬 ${wp.partialText.slice(-150).replace(/\n/g, " ")}` : "";
461
- progressLine = `${text}${toolLine}${tokenLine}${textLine}`;
462
- }
463
- }
464
- onUpdate({ content: [{ type: "text", text: progressLine }] });
447
+ onUpdate({ content: [{ type: "text", text }] });
465
448
  } catch (error) {
466
449
  logInternalError("subagent-tools.progress", error, `agentId=${agentRecordId}`);
467
450
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
2
3
  import type { PiTeamsConfig } from "../../config/config.ts";
3
4
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
4
5
  import type { RunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
@@ -832,6 +832,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
832
832
  runtime,
833
833
  runtimeConfig: executedConfig.runtime,
834
834
  parentContext: buildParentContext(ctx),
835
+
835
836
  parentModel: ctx.model,
836
837
  modelRegistry: ctx.modelRegistry,
837
838
  modelOverride: params.model,
@@ -9,8 +9,6 @@ export interface AgentProgress {
9
9
  turns: number;
10
10
  tokens: { input: number; output: number };
11
11
  status: "idle" | "running" | "completed" | "error";
12
- /** Most recent partial assistant text (for live streaming display). */
13
- partialText?: string;
14
12
  }
15
13
 
16
14
  export class ProgressTracker {
@@ -120,145 +118,7 @@ export class ProgressTracker {
120
118
  getProgress(agentId: string): AgentProgress | undefined {
121
119
  return this.sessions.get(agentId)?.progress;
122
120
  }
123
-
124
- // ── Child-process worker event bridge ──────────────────────────────────
125
- //
126
- // For child-process runtime, events arrive as raw JSON from the child pi
127
- // process's stdout (via onJsonEvent). These methods bridge those events into
128
- // the same crewEventBus stream that live-session uses, so the widget shows
129
- // real-time tool calls and assistant text for BOTH runtimes.
130
-
131
- private workerProgress = new Map<string, AgentProgress>();
132
-
133
- /** Throttle: don't emit more than 1 progress event per 500ms per worker. */
134
- private lastEmitTs = new Map<string, number>();
135
- private static readonly EMIT_THROTTLE_MS = 500;
136
-
137
- /**
138
- * Handle a raw child-process JSON event (from onJsonEvent callback).
139
- * Processes tool_execution_start/end, agent_start/end, and assistant text.
140
- */
141
- handleWorkerEvent(taskId: string, runId: string, event: Record<string, unknown>): void {
142
- let progress = this.workerProgress.get(taskId);
143
- if (!progress) {
144
- progress = {
145
- toolCalls: 0,
146
- currentTool: null,
147
- toolStartTime: null,
148
- errors: [],
149
- turns: 0,
150
- tokens: { input: 0, output: 0 },
151
- status: "running",
152
- };
153
- this.workerProgress.set(taskId, progress);
154
- }
155
-
156
- const eventType = typeof event.type === "string" ? event.type : undefined;
157
-
158
- switch (eventType) {
159
- case "tool_execution_start": {
160
- progress.toolCalls++;
161
- progress.currentTool = typeof event.toolName === "string" ? event.toolName : "unknown";
162
- progress.toolStartTime = Date.now();
163
- this.emitThrottled(taskId, runId, progress);
164
- break;
165
- }
166
- case "tool_execution_end": {
167
- progress.currentTool = null;
168
- progress.toolStartTime = null;
169
- if (event.isError) {
170
- progress.errors.push(String(event.result ?? "Unknown error"));
171
- crewEventBus.emit({
172
- type: "agent:error",
173
- runId,
174
- agentId: taskId,
175
- payload: String(event.result ?? "Unknown error"),
176
- timestamp: Date.now(),
177
- });
178
- }
179
- this.emitThrottled(taskId, runId, progress);
180
- break;
181
- }
182
- case "turn_start":
183
- case "turn_end":
184
- progress.turns++;
185
- break;
186
- case "agent_start":
187
- progress.status = "running";
188
- break;
189
- case "agent_end":
190
- case "agent_settled":
191
- progress.status = "completed";
192
- this.emitThrottled(taskId, runId, progress);
193
- break;
194
- case "message":
195
- case "message_end": {
196
- // Extract assistant text for streaming display.
197
- const message = event.message as { role?: string; content?: unknown } | undefined;
198
- if (message?.role === "assistant") {
199
- const text = extractWorkerText(message.content);
200
- if (text) {
201
- progress.partialText = text.slice(-2000); // keep last 2K chars
202
- this.emitThrottled(taskId, runId, progress);
203
- }
204
- }
205
- // Track usage from message_end events.
206
- if (eventType === "message_end" && event.usage && typeof event.usage === "object") {
207
- const usage = event.usage as { input?: number; output?: number };
208
- if (typeof usage.input === "number") progress.tokens.input += usage.input;
209
- if (typeof usage.output === "number") progress.tokens.output += usage.output;
210
- }
211
- break;
212
- }
213
- }
214
- }
215
-
216
- /** Get the accumulated progress for a child-process worker task. */
217
- getWorkerProgress(taskId: string): AgentProgress | undefined {
218
- return this.workerProgress.get(taskId);
219
- }
220
-
221
- /** Remove a worker from tracking after completion. */
222
- untrackWorker(taskId: string): void {
223
- this.workerProgress.delete(taskId);
224
- this.lastEmitTs.delete(taskId);
225
- }
226
-
227
- /**
228
- * Emit a progress event to crewEventBus, throttled to avoid flooding
229
- * the widget with re-renders (max 1 event per EMIT_THROTTLE_MS per worker).
230
- */
231
- private emitThrottled(taskId: string, runId: string, progress: AgentProgress): void {
232
- const now = Date.now();
233
- const last = this.lastEmitTs.get(taskId) ?? 0;
234
- if (now - last < ProgressTracker.EMIT_THROTTLE_MS) return;
235
- this.lastEmitTs.set(taskId, now);
236
- crewEventBus.emit({
237
- type: "agent:progress",
238
- runId,
239
- agentId: taskId,
240
- payload: { ...progress },
241
- timestamp: now,
242
- });
243
- }
244
121
  }
245
122
 
246
123
  // Export singleton instance
247
124
  export const globalProgressTracker = new ProgressTracker();
248
-
249
- /**
250
- * Extract text content from a worker message's content field.
251
- * Handles string content and array-of-parts (like extractTextContent in context.ts).
252
- */
253
- function extractWorkerText(content: unknown): string {
254
- if (typeof content === "string") return content;
255
- if (!Array.isArray(content)) return "";
256
- return content
257
- .map((part) =>
258
- part && typeof part === "object" && !Array.isArray(part) && typeof (part as { text?: unknown }).text === "string"
259
- ? (part as { text: string }).text
260
- : "",
261
- )
262
- .filter(Boolean)
263
- .join("\n");
264
- }
@@ -79,6 +79,29 @@ function isLockHolderAlive(filePath: string): boolean {
79
79
  * Returns `{ canSteal: true }` if the lock is stale OR the holder is dead
80
80
  * (safe to forcibly remove); `{ canSteal: false }` if it is fresh AND held by
81
81
  * a live process (must keep waiting).
82
+ *
83
+ * ## EPERM Handling (Accepted Risk)
84
+ *
85
+ * When `process.kill(pid, 0)` returns EPERM, it means the holder process
86
+ * EXISTS but we lack permission to signal it. We treat this as "not alive"
87
+ * (stealable) because:
88
+ *
89
+ * 1. **Blocking indefinitely is worse** — on shared systems, a holder running
90
+ * under a different user/permission context would block us forever.
91
+ * 2. **EPERM requires elevated privileges** — on single-user workstations
92
+ * (the typical pi-crew environment), EPERM is rare.
93
+ * 3. **Defense in depth** — the lock staleness check provides a secondary
94
+ * safeguard; we only steal if the lock is also stale.
95
+ *
96
+ * **Trade-off:** On multi-user systems, this could theoretically allow two
97
+ * processes in the critical section simultaneously if: (a) holder is alive
98
+ * under different permissions, AND (b) our steal attempt races with holder's
99
+ * release. The practical risk is low because:
100
+ * - Lock holders typically complete quickly
101
+ * - Stale timeout provides a safety window
102
+ * - Critical sections are designed to tolerate rare races
103
+ *
104
+ * See also: SECURITY-ISSUES.md SEC-008 for documented acceptance.
82
105
  */
83
106
  function readLockSnapshot(filePath: string, staleMs: number): { canSteal: boolean } {
84
107
  let stat: fs.Stats | undefined;
@@ -203,6 +203,9 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
203
203
  }
204
204
 
205
205
  export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
206
+ if (!cwd || typeof cwd !== "string") {
207
+ throw new Error(`Invalid cwd: ${cwd}`);
208
+ }
206
209
  assertSafePathId("runId", runId);
207
210
  const baseRoot = scopeBaseRoot(cwd);
208
211
  const stateRoot = resolveContainedRelativePath(path.join(baseRoot, DEFAULT_PATHS.state.runsSubdir), runId, "runId");
@@ -9,6 +9,7 @@
9
9
  import { type Container, Text, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
11
11
  import { truncateToWidth } from "../../utils/visual.ts";
12
+ import { spinnerFrame } from "../spinner.ts";
12
13
  import type { CrewTheme } from "../theme-adapter.ts";
13
14
  import { formatDuration, formatTokens, truncLine } from "../tool-render.ts";
14
15
  import { briefToolResult, isBrief } from "./brief-mode.ts";
@@ -373,9 +374,25 @@ function renderAgentResult(result: Record<string, unknown>, options: unknown, th
373
374
  // isPartial = agent still running
374
375
  const isPartial = (options as Record<string, unknown>)?.isPartial === true;
375
376
  if (isPartial && !ctx.expanded) {
376
- const spinner = theme.fg("warning", "◉");
377
- const label = theme.fg("muted", "agent working...");
378
- contentLines.push(padVisual(` ${spinner} ${label}`, innerW));
377
+ const spinner = theme.fg("accent", spinnerFrame(d.agentId as string));
378
+ const parts: string[] = [theme.fg("muted", "working")];
379
+
380
+ // Extract progress from onUpdate content (formatCompactToolProgress output)
381
+ const progressText = extractContentText(result?.content);
382
+ if (progressText) {
383
+ const elapsedMatch = progressText.match(/elapsed=(\d+)s/);
384
+ const tokenMatch = progressText.match(/tokens=(\d+)/);
385
+ const toolMatch = progressText.match(/tool:\s+(\S+)/);
386
+ const elapsedSec = elapsedMatch ? parseInt(elapsedMatch[1]!, 10) : 0;
387
+ const tokens = tokenMatch ? parseInt(tokenMatch[1]!, 10) : 0;
388
+ const tps = elapsedSec > 0 ? Math.round(tokens / elapsedSec) : 0;
389
+
390
+ if (elapsedSec > 0) parts.push(theme.fg("dim", formatDuration(elapsedSec * 1000)));
391
+ if (tps > 0) parts.push(theme.fg("dim", `${formatTokens(tps)} tok/s`));
392
+ if (toolMatch) parts.push(theme.fg("dim", `· ${toolMatch[1]}`));
393
+ }
394
+
395
+ contentLines.push(padVisual(` ${spinner} ${parts.join(" ")}`, innerW));
379
396
  return buildFrameBottom(contentLines, w, theme, "borderAccent", "borderAccent");
380
397
  }
381
398
 
@@ -18,6 +18,7 @@ import { computeLiveDurationMs } from "../live-duration.ts";
18
18
  // verbatim (no truncation/crash) — these are rare one-off states, not jitter.
19
19
  const TOOLS_METRIC_WIDTH = 8; // "127 tools"
20
20
  const TOKENS_METRIC_WIDTH = 10; // "1.2k tok", "12.3M tok"
21
+ const TPS_METRIC_WIDTH = 9; // "411 tok/s"
21
22
  const CTX_METRIC_WIDTH = 7; // "100% ctx"
22
23
  const DURATION_METRIC_WIDTH = 6; // "120.0s"
23
24
 
@@ -139,10 +140,19 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
139
140
  /* ignore */
140
141
  }
141
142
  const ms = computeLiveDurationMs(act);
143
+ if (total > 0 && ms > 1000) {
144
+ const tps = Math.round(total / (ms / 1000));
145
+ if (tps > 0) parts.push(alignMetric(`${formatTokensCompact(tps)}/s`, TPS_METRIC_WIDTH));
146
+ }
142
147
  parts.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
143
148
  } else {
144
149
  if (agent.toolUses) parts.push(alignMetric(`${agent.toolUses} tools`, TOOLS_METRIC_WIDTH));
145
150
  if (agent.progress?.tokens) parts.push(alignMetric(formatTokensCompact(agent.progress.tokens), TOKENS_METRIC_WIDTH));
151
+ const ageMs = agent.startedAt ? Math.max(0, Date.now() - new Date(agent.startedAt).getTime()) : 0;
152
+ if (agent.progress?.tokens && ageMs > 1000) {
153
+ const tps = Math.round(agent.progress.tokens / (ageMs / 1000));
154
+ if (tps > 0) parts.push(alignMetric(`${formatTokensCompact(tps)}/s`, TPS_METRIC_WIDTH));
155
+ }
146
156
  const age = elapsed(agent.completedAt ?? agent.startedAt);
147
157
  if (age) parts.push(alignMetric(age, DURATION_METRIC_WIDTH));
148
158
  }