pi-crew 0.9.9 → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
  3. package/docs/fixes/v0.9.10/smoke-test.md +12 -0
  4. package/package.json +1 -1
  5. package/src/extension/team-tool/doctor.ts +41 -18
  6. package/src/runtime/child-pi.ts +122 -22
  7. package/src/runtime/compact-pipeline.ts +56 -0
  8. package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
  9. package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
  10. package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
  11. package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
  12. package/src/runtime/compact-stages/index.ts +13 -0
  13. package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
  14. package/src/runtime/compact-stages/truncation-stage.ts +71 -0
  15. package/src/runtime/handoff-manager.ts +10 -0
  16. package/src/runtime/important-line-classifier.ts +130 -0
  17. package/src/runtime/iteration-hooks.ts +7 -19
  18. package/src/runtime/live-session-runtime.ts +50 -1
  19. package/src/runtime/model-fallback.ts +29 -1
  20. package/src/runtime/role-permission.ts +2 -2
  21. package/src/runtime/stream-preview.ts +9 -2
  22. package/src/runtime/task-output-context.ts +161 -27
  23. package/src/runtime/task-runner.ts +76 -15
  24. package/src/state/locks.ts +16 -0
  25. package/src/state/state-store.ts +8 -2
  26. package/src/ui/live-run-sidebar.ts +6 -1
  27. package/src/ui/loaders.ts +24 -4
  28. package/src/ui/run-dashboard.ts +6 -1
  29. package/src/ui/run-event-bus.ts +1 -1
  30. package/src/ui/run-snapshot-cache.ts +50 -16
  31. package/src/ui/widget/index.ts +27 -5
  32. package/src/ui/widget/widget-renderer.ts +43 -13
  33. package/src/utils/redaction.ts +17 -1
  34. package/src/utils/visual.ts +6 -0
  35. package/src/ui/crew-widget.ts +0 -544
@@ -763,7 +763,30 @@ export async function runTeamTask(
763
763
  "",
764
764
  );
765
765
  if (!error) break;
766
- const nextModel = attemptModels[i + 1];
766
+ let nextModel = attemptModels[i + 1];
767
+ // FIX 1 (task packet 01_01-agent): when the precomputed attempt
768
+ // chain is exhausted but the failure is retryable, do a one-shot
769
+ // re-resolve via buildConfiguredModelRouting with the failed
770
+ // model as parent. This finds alternative providers/models the
771
+ // original chain missed (e.g. a registry gained new fallbacks
772
+ // after the precompute, or the precompute ran before the parent
773
+ // model was known). If a different candidate is found, use it as
774
+ // nextModel; otherwise fall through to the existing break.
775
+ if (!nextModel && isRetryableModelFailure(error)) {
776
+ const reResolved = buildConfiguredModelRouting({
777
+ overrideModel: undefined,
778
+ stepModel: undefined,
779
+ teamRoleModel: undefined,
780
+ agentModel: undefined,
781
+ fallbackModels: undefined,
782
+ parentModel: attempt.model,
783
+ modelRegistry: input.modelRegistry,
784
+ cwd: task.cwd,
785
+ scopeModelsPatterns: await resolveTaskScopeModelsPatterns(task.cwd),
786
+ });
787
+ const alt = reResolved.candidates.find((c) => c !== attempt.model);
788
+ if (alt) nextModel = alt;
789
+ }
767
790
  if (!nextModel || !isRetryableModelFailure(error)) break;
768
791
  logs.push(formatModelAttemptNote(attempt, nextModel), "");
769
792
  }
@@ -1368,19 +1391,57 @@ async function resolveTaskScopeModelsPatterns(cwd: string): Promise<string[]> {
1368
1391
  * or when there are no retryable error messages.
1369
1392
  */
1370
1393
  export function detectRetryableModelFailureFromOutput(parsed: ParsedPiJsonOutput): string | undefined {
1394
+ // Primary signal: pre-extracted `errorMessages` (from pi-json-output parser).
1395
+ // The parser already filters to non-empty trimmed strings from message_end
1396
+ // events.
1371
1397
  const messages = parsed.errorMessages;
1372
- if (!messages || messages.length === 0) return undefined;
1373
- // Find the first retryable model-failure message (429 / rate-limit / overloaded / 5xx / ...).
1374
- const retryable = messages.find((m) => isRetryableModelFailure(m));
1375
- if (!retryable) return undefined;
1376
- // Did the run actually produce real output despite the transient errors?
1377
- // If finalText / textEvents / patches exist, the model recovered and we
1378
- // should NOT mark the run as failed only flag it when the worker yielded
1379
- // nothing (the 429-only case from the bug report).
1380
- const hasRealOutput =
1381
- (parsed.finalText?.trim().length ?? 0) > 0 ||
1382
- parsed.textEvents.some((t) => t.trim().length > 0) ||
1383
- (parsed.patches?.length ?? 0) > 0;
1384
- if (hasRealOutput) return undefined;
1385
- return `Model returned only retryable errors and no output: ${retryable}`;
1398
+ if (messages && messages.length > 0) {
1399
+ // Find the first retryable model-failure message
1400
+ // (429 / rate-limit / overloaded / 5xx / ...).
1401
+ const retryable = messages.find((m) => isRetryableModelFailure(m));
1402
+ if (retryable) {
1403
+ // Did the run actually produce real output despite the transient errors?
1404
+ // If finalText / textEvents / patches exist, the model recovered and we
1405
+ // should NOT mark the run as failed — only flag it when the worker
1406
+ // yielded nothing (the 429-only case from the bug report).
1407
+ const hasRealOutput =
1408
+ (parsed.finalText?.trim().length ?? 0) > 0 ||
1409
+ parsed.textEvents.some((t) => t.trim().length > 0) ||
1410
+ (parsed.patches?.length ?? 0) > 0;
1411
+ if (hasRealOutput) return undefined;
1412
+ return `Model returned only retryable errors and no output: ${retryable}`;
1413
+ }
1414
+ }
1415
+ // Secondary signal (FIX 3, task packet 01_01-agent): inspect a raw
1416
+ // `messageEndEvents` (or `transcript`) array on the parsed output. The
1417
+ // ParsedPiJsonOutput type does not currently declare this field, so we
1418
+ // read it through a local extension cast. Callers that pass it (tests, a
1419
+ // future parser that captures the full event stream) get a second chance
1420
+ // to surface retryable failures. Primary path still wins when it matches.
1421
+ const raw = parsed as ParsedPiJsonOutput & {
1422
+ messageEndEvents?: unknown;
1423
+ transcript?: unknown;
1424
+ };
1425
+ const eventSource = Array.isArray(raw.messageEndEvents)
1426
+ ? raw.messageEndEvents
1427
+ : Array.isArray(raw.transcript)
1428
+ ? raw.transcript
1429
+ : undefined;
1430
+ if (!eventSource || eventSource.length === 0) return undefined;
1431
+ for (const candidate of eventSource) {
1432
+ if (!candidate || typeof candidate !== "object") continue;
1433
+ const event = candidate as { stopReason?: unknown; errorMessage?: unknown };
1434
+ if (event.stopReason !== "error") continue;
1435
+ if (typeof event.errorMessage !== "string" || event.errorMessage.length === 0) continue;
1436
+ if (!isRetryableModelFailure(event.errorMessage)) continue;
1437
+ // Same real-output gate as the primary signal — don't flag runs that
1438
+ // recovered with real final text / patches.
1439
+ const hasRealOutput =
1440
+ (parsed.finalText?.trim().length ?? 0) > 0 ||
1441
+ parsed.textEvents.some((t) => t.trim().length > 0) ||
1442
+ (parsed.patches?.length ?? 0) > 0;
1443
+ if (hasRealOutput) return undefined;
1444
+ return `Model returned only retryable errors and no output: ${event.errorMessage}`;
1445
+ }
1446
+ return undefined;
1386
1447
  }
@@ -292,6 +292,17 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
292
292
  // append, or even the lock acquisition itself) would race with the lock.
293
293
  const lockFile = `${filePath}.lock`;
294
294
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
295
+ // FIX (Round 29): re-entrance guard — mirrors withRunLockSync below.
296
+ // When the same call stack already holds the file lock (e.g.
297
+ // registerWorker -> cleanupOrphanWorkers -> readRegistry), the second
298
+ // acquisition would otherwise read its own freshly-written lock file
299
+ // (same pid, fresh createdAt), fail the steal check, and deadlock for
300
+ // the full staleMs window. Strace-confirmed in
301
+ // .github/issues/pre-existing-2026-06-10/04-orphan-worker-registry-tests.md:75-86.
302
+ const existingToken = fileLockHeldByUs.get(lockFile);
303
+ if (existingToken) {
304
+ return fn();
305
+ }
295
306
  // FIX: Validate the parent directory is not a symlink BEFORE calling mkdirSync.
296
307
  // Between mkdir and lock acquisition, an attacker could plant a symlink.
297
308
  if (!isSymlinkSafePath(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
@@ -322,10 +333,12 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
322
333
  }
323
334
  }
324
335
  if (token === "") throw new Error(`Run '${path.basename(lockFile)}' is locked by another operation.`);
336
+ fileLockHeldByUs.set(lockFile, token);
325
337
  try {
326
338
  return fn();
327
339
  } finally {
328
340
  // Token-guarded release: don't rm the lock if it has been stolen.
341
+ fileLockHeldByUs.delete(lockFile);
329
342
  releaseLock(lockFile, token);
330
343
  }
331
344
  }
@@ -353,6 +366,9 @@ export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, optio
353
366
  // already held by this call stack (handleResume -> executeTeamRun ->
354
367
  // executeTeamRunCore), we skip re-acquisition to avoid deadlock.
355
368
  const runLockHeldByUs = new Map<string, string>(); // filePath -> token
369
+ // Round 29: parallel map for withFileLockSync re-entrance. See the comment
370
+ // at the top of withFileLockSync for the full deadlock mechanism.
371
+ const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
356
372
 
357
373
  export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
358
374
  const filePath = lockPath(manifest);
@@ -634,7 +634,10 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
634
634
  // between the final stat and the read. Callers needing strict consistency
635
635
  // MUST use withRunLock() around load+modify+save.
636
636
  if (attempts > 0) {
637
- console.warn(`[state-store] loadRunManifestById: retry loop detected instability for run ${runId} after ${attempts} attempt(s) best-effort only, use withRunLock() for strict consistency`);
637
+ // Round 19: downgrade to debugretry-loop instability is expected under
638
+ // concurrent writes (live team runs constantly append to tasks.json).
639
+ // This is best-effort by design; strict consistency requires withRunLock().
640
+ console.debug(`[state-store] loadRunManifestById: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
638
641
  }
639
642
  // NOTE: manifest mtime may legitimately be >= tasks mtime because
640
643
  // saveManifestAndTasksAtomicSync writes manifest before tasks. However,
@@ -724,7 +727,10 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
724
727
  // between the final stat and the read. Callers needing strict consistency
725
728
  // MUST use withRunLock() around load+modify+save.
726
729
  if (attempts > 0) {
727
- console.warn(`[state-store] loadRunManifestByIdAsync: retry loop detected instability for run ${runId} after ${attempts} attempt(s) best-effort only, use withRunLock() for strict consistency`);
730
+ // Round 19: downgrade to debugretry-loop instability is expected under
731
+ // concurrent writes (live team runs constantly append to tasks.json).
732
+ // This is best-effort by design; strict consistency requires withRunLock().
733
+ console.debug(`[state-store] loadRunManifestByIdAsync: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
728
734
  }
729
735
  // NOTE: manifest mtime may legitimately be >= tasks mtime because
730
736
  // saveManifestAndTasksAtomicSync writes manifest before tasks. However,
@@ -76,7 +76,12 @@ export class LiveRunSidebar {
76
76
  this.config = input.config ?? {};
77
77
  this.snapshotCache = input.snapshotCache;
78
78
  this.unsubscribeTheme = subscribeThemeChange(input.theme, () => this.invalidate());
79
- this.unsubscribeEventBus = runEventBus.onAny(() => this.invalidate());
79
+ this.unsubscribeEventBus = (() => {
80
+ const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
81
+ const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
82
+ const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
83
+ return () => { unsub1(); unsub2(); unsub3(); };
84
+ })();
80
85
  }
81
86
 
82
87
  private buildSignature(manifestStatus: string, tasks: TeamTaskState[], agents: ReturnType<typeof readCrewAgents>, waitingCount: number, snapshot?: RunUiSnapshot): string {
package/src/ui/loaders.ts CHANGED
@@ -113,23 +113,43 @@ export class CountdownTimer {
113
113
  private readonly timeoutMs: number;
114
114
  private timer: ReturnType<typeof setTimeout> | undefined;
115
115
  private expired = false;
116
+ private lastEmittedSeconds = -1;
116
117
 
117
118
  constructor(options: CountdownTimerOptions) {
118
119
  this.timeoutMs = Math.max(0, options.timeoutMs);
119
120
  this.onTick = options.onTick;
120
121
  this.onExpire = options.onExpire;
121
122
  this.startedAt = Date.now();
122
- this.onTick(this.secondsLeft());
123
+ this.lastEmittedSeconds = this.secondsLeft();
124
+ this.onTick(this.lastEmittedSeconds);
123
125
  if (this.timeoutMs === 0) {
124
126
  this.emitExpire();
125
127
  return;
126
128
  }
127
- this.timer = setInterval(() => {
129
+ this.scheduleNextTick();
130
+ }
131
+
132
+ /**
133
+ * Schedule the next tick via recursive setTimeout. Each tick re-emits the
134
+ * current `secondsLeft()` only if it differs from the last emitted value
135
+ * (lastEmittedSeconds guard). This makes the countdown correct even under
136
+ * event-loop pressure: if the previous tick fired 1.2s late, the next
137
+ * tick still emits the right value for the current second rather than
138
+ * skipping it (the pre-fix `setInterval` could SKIP a second value when
139
+ * the loop was busy, producing [3,2,0] instead of [3,2,1,0] in tests).
140
+ */
141
+ private scheduleNextTick(): void {
142
+ this.timer = setTimeout(() => {
128
143
  const seconds = this.secondsLeft();
129
- this.onTick(seconds);
144
+ if (seconds !== this.lastEmittedSeconds) {
145
+ this.lastEmittedSeconds = seconds;
146
+ this.onTick(seconds);
147
+ }
130
148
  if (seconds <= 0) {
131
149
  this.emitExpire();
150
+ return;
132
151
  }
152
+ this.scheduleNextTick();
133
153
  }, 1000);
134
154
  // Defense-in-depth: never let the countdown timer keep the event loop
135
155
  // alive. If dispose() is missed (e.g. UI unmount race), the timer must
@@ -151,7 +171,7 @@ export class CountdownTimer {
151
171
 
152
172
  dispose(): void {
153
173
  if (this.timer === undefined) return;
154
- clearInterval(this.timer);
174
+ clearTimeout(this.timer);
155
175
  this.timer = undefined;
156
176
  }
157
177
  }
@@ -294,7 +294,12 @@ export class RunDashboard implements DashboardComponent {
294
294
  this.theme = asCrewTheme(theme);
295
295
  this.options = options;
296
296
  this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidateAndRender());
297
- this.unsubscribeEventBus = runEventBus.onAny(() => this.invalidateAndRender());
297
+ this.unsubscribeEventBus = (() => {
298
+ const unsub1 = runEventBus.onChannel("run:state", () => this.invalidateAndRender());
299
+ const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidateAndRender());
300
+ const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidateAndRender());
301
+ return () => { unsub1(); unsub2(); unsub3(); };
302
+ })();
298
303
  }
299
304
 
300
305
  /**
@@ -40,7 +40,7 @@ const RUN_STATE_TYPES = new Set([
40
40
  "manifest.saved", "task.claimed", "task.unclaimed", "mailbox_updated",
41
41
  ]);
42
42
  const UI_INVALIDATE_TYPES = new Set([
43
- "effectiveness_changed", "snapshot_stale",
43
+ "effectiveness_changed", "snapshot_stale", "run.cache_invalidated",
44
44
  ]);
45
45
 
46
46
  /** Classify an event type string into a typed channel. */
@@ -787,11 +787,55 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
787
787
  }
788
788
  }
789
789
 
790
- const unsubscribe = runEventBus.onAny((event) => {
791
- if (entries.has(event.runId)) {
792
- entries.delete(event.runId);
793
- }
790
+ // Coalesced eager refresh on event-bus signals. Previously every
791
+ // `run:state` / `worker:lifecycle` event deleted the cache entry, leaving
792
+ // a window where `widget-model.ts: snapshotCache.get(runId)` returned
793
+ // `undefined`. The widget then fell back to `agentsFor(run)` (a disk read
794
+ // with no snapshot.tasks) and rendered the "0/1 done" branch of
795
+ // `widget-renderer.ts:39-41` instead of the "Phase 1/1 default: 0% (0/3)"
796
+ // branch — producing the live flicker between those two progressPart
797
+ // values every render tick. Replacing the delete with a coalesced
798
+ // refreshIfStale keeps the cache populated so the widget always sees the
799
+ // same logical snapshot between stamp changes; multiple events for the
800
+ // same runId within INVAL_COALESCE_MS are batched into one refresh.
801
+ function localRefresh(runId: string): RunUiSnapshot {
802
+ const previous = entries.get(runId);
803
+ const entry = build(runId, previous);
804
+ entries.set(runId, entry);
805
+ evictIfNeeded();
806
+ return entry.snapshot;
807
+ }
808
+ function localRefreshIfStale(runId: string): RunUiSnapshot {
809
+ const previous = entries.get(runId);
810
+ if (!previous) return localRefresh(runId);
811
+ const now = Date.now();
812
+ if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
813
+ const stamps = currentStamps(previous);
814
+ if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
815
+ return localRefresh(runId);
816
+ }
817
+ const pendingRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
818
+ const INVAL_COALESCE_MS = 80;
819
+ const scheduleRefresh = (runId: string): void => {
820
+ const existing = pendingRefreshes.get(runId);
821
+ if (existing) clearTimeout(existing);
822
+ pendingRefreshes.set(runId, setTimeout(() => {
823
+ pendingRefreshes.delete(runId);
824
+ try { localRefreshIfStale(runId); } catch { /* best-effort; widget falls back gracefully */ }
825
+ }, INVAL_COALESCE_MS));
826
+ };
827
+ const unsubState = runEventBus.onChannel("run:state", (event) => {
828
+ if (entries.has(event.runId)) scheduleRefresh(event.runId);
794
829
  });
830
+ const unsubLifecycle = runEventBus.onChannel("worker:lifecycle", (event) => {
831
+ if (entries.has(event.runId)) scheduleRefresh(event.runId);
832
+ });
833
+ const unsubscribe = () => {
834
+ unsubState();
835
+ unsubLifecycle();
836
+ for (const timer of pendingRefreshes.values()) clearTimeout(timer);
837
+ pendingRefreshes.clear();
838
+ };
795
839
 
796
840
  return {
797
841
  get(runId: string): RunUiSnapshot | undefined {
@@ -799,20 +843,10 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
799
843
  return entry ? touch(runId, entry) : undefined;
800
844
  },
801
845
  refresh(runId: string): RunUiSnapshot {
802
- const previous = entries.get(runId);
803
- const entry = build(runId, previous);
804
- entries.set(runId, entry);
805
- evictIfNeeded();
806
- return entry.snapshot;
846
+ return localRefresh(runId);
807
847
  },
808
848
  refreshIfStale(runId: string): RunUiSnapshot {
809
- const previous = entries.get(runId);
810
- if (!previous) return this.refresh(runId);
811
- const now = Date.now();
812
- if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
813
- const stamps = currentStamps(previous);
814
- if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
815
- return this.refresh(runId);
849
+ return localRefreshIfStale(runId);
816
850
  },
817
851
  preloadStale,
818
852
  preloadAllStale,
@@ -18,13 +18,30 @@ import { spinnerBucket, spinnerFrame } from "../spinner.ts";
18
18
  import { runEventBus } from "../run-event-bus.ts";
19
19
  import { DEFAULT_UI } from "../../config/defaults.ts";
20
20
  import { activeWidgetRuns, statusSummary } from "./widget-model.ts";
21
- import { buildWidgetLines, colorWidgetLine, renderLines } from "./widget-renderer.ts";
21
+ import { buildWidgetLines, colorWidgetLine, renderLines, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
22
22
  import type { CrewWidgetModel, CrewWidgetState, WidgetRun } from "./widget-types.ts";
23
23
 
24
24
  // Re-export types and helpers for backward compatibility
25
25
  export type { WidgetRun, CrewWidgetModel, CrewWidgetState } from "./widget-types.ts";
26
26
  export { activeWidgetRuns, statusSummary } from "./widget-model.ts";
27
- export { buildWidgetLines as buildCrewWidgetLines, widgetHeader } from "./widget-renderer.ts";
27
+ export { buildWidgetLines as buildCrewWidgetLines, widgetHeader, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
28
+
29
+ /**
30
+ * Resolve the real render width for widget lines, in priority order:
31
+ * 1. explicit `width` argument (e.g. from caller that already knows terminal width)
32
+ * 2. `process.stdout.columns` (works in Node when stdout is a TTY)
33
+ * 3. `DEFAULT_WIDGET_WIDTH` (100) — last-resort fallback so we never paint
34
+ * a line wider than the smallest expected TUI.
35
+ *
36
+ * Callers SHOULD pass the width they already hold (e.g. `WidgetRender.render(width)`
37
+ * in this file already receives one). This helper exists for paths that don't.
38
+ */
39
+ export function getRenderWidth(width?: number): number {
40
+ if (Number.isFinite(width) && width! > 0) return Math.floor(width!);
41
+ const stdoutCols = (globalThis as { process?: { stdout?: { columns?: number } } }).process?.stdout?.columns;
42
+ if (Number.isFinite(stdoutCols) && stdoutCols! > 0) return Math.floor(stdoutCols!);
43
+ return DEFAULT_WIDGET_WIDTH;
44
+ }
28
45
  export { notificationBadge } from "./widget-formatters.ts";
29
46
 
30
47
  // ── Constants ─────────────────────────────────────────────────────────
@@ -57,7 +74,12 @@ class CrewWidgetComponent implements WidgetComponent {
57
74
  this.theme = asCrewTheme(themeLike);
58
75
  this.cachedTheme = this.theme;
59
76
  this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
60
- this.unsubscribeEventBus = runEventBus.onAny(() => this.invalidate());
77
+ this.unsubscribeEventBus = (() => {
78
+ const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
79
+ const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
80
+ const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
81
+ return () => { unsub1(); unsub2(); unsub3(); };
82
+ })();
61
83
  }
62
84
 
63
85
  private buildSignature(runs: WidgetRun[]): string {
@@ -103,7 +125,7 @@ class CrewWidgetComponent implements WidgetComponent {
103
125
  const runningGlyph = spinnerFrame("widget-header");
104
126
 
105
127
  if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
106
- this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0).map((line, index) => {
128
+ this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0, width).map((line, index) => {
107
129
  if (index === 0 && line.length > 0) return `${runningGlyph}${line.slice(1)}`;
108
130
  return line;
109
131
  });
@@ -150,7 +172,7 @@ export function updateCrewWidget(
150
172
  }
151
173
 
152
174
  const runs = activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId);
153
- const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0);
175
+ const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0, getRenderWidth());
154
176
  const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
155
177
 
156
178
  ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
@@ -11,12 +11,21 @@ import { Box, Text } from "../layout-primitives.ts";
11
11
  import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
12
12
  import { computePhaseProgress, formatPhaseProgressLine } from "../../runtime/phase-progress.ts";
13
13
  import { spinnerFrame } from "../spinner.ts";
14
- import { agentActivity, agentStats, notificationBadge } from "./widget-formatters.ts";
15
- import { shortRunLabel } from "./widget-model.ts";
14
+ import { computeLiveDurationMs } from "../live-duration.ts";
15
+ import { getTaskUsage } from "../../runtime/usage-tracker.ts";
16
+ import { agentActivity, agentStats, elapsed, formatTokensCompact, notificationBadge } from "./widget-formatters.ts";
17
+ import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
16
18
  import type { WidgetRun } from "./widget-types.ts";
17
19
 
18
20
  const MAX_AGENTS_DISPLAY = 3;
19
21
  const FINISHED_LINGER_MAX_AGE = 1;
22
+ /** Default terminal width when caller doesn't pass one explicitly. Keep <= 116
23
+ * (the same default used elsewhere in pi-crew tool renderers) so we never paint
24
+ * a line wider than the smallest expected TUI. Callers SHOULD pass the real
25
+ * width when known (via ctx.width || process.stdout.columns). */
26
+ export const DEFAULT_WIDGET_WIDTH = 100;
27
+ /** Cap per-component text so a single field cannot blow past width on its own. */
28
+ export const TASK_DESC_MAX = 60;
20
29
  const ERROR_LINGER_MAX_AGE = 2;
21
30
  const ERROR_STATUSES = new Set(["failed", "cancelled", "stopped", "needs_attention"]);
22
31
 
@@ -37,8 +46,12 @@ export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines =
37
46
 
38
47
  // ── Line builder ──────────────────────────────────────────────────────
39
48
 
40
- export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0): string[] {
41
- const runs = providedRuns ?? [];
49
+ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0, width = DEFAULT_WIDGET_WIDTH): string[] {
50
+ // Match the legacy `buildCrewWidgetLines` API: when no runs are supplied,
51
+ // auto-fetch via activeWidgetRuns(cwd). Otherwise widgets calling with
52
+ // only `(cwd, frame)` would render an empty line set (regression vs. the
53
+ // pre-refactor implementation that called activeWidgetRuns here).
54
+ const runs = providedRuns ?? activeWidgetRuns(cwd);
42
55
  if (!runs.length) return [];
43
56
 
44
57
  const runningGlyph = spinnerFrame("widget-header");
@@ -56,9 +69,23 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
56
69
  });
57
70
  const completed = agents.filter((a) => a.status === "completed").length;
58
71
  const runGlyph = iconForStatus(run.status, { runningGlyph });
59
- const phaseLine = snapshot ? formatPhaseProgressLine(computePhaseProgress(snapshot.tasks)) : "";
60
- const progressPart = phaseLine || `${completed}/${agents.length} done`;
61
- lines.push(`├─ ${runGlyph} ${shortRunLabel(run)} · ${progressPart} · ${run.runId.slice(-8)}`);
72
+ // Run progress line. v1–v3 flickered on snapshot.tasks state, v4 was
73
+ // too minimal (`0/1 agents` only), v5 duplicated the worker activity
74
+ // line (tools/tokens/duration already shown one row below). v6 (this)
75
+ // shows only data that is RUN-level (not already in the per-agent
76
+ // activity line) and is GUARANTEED stable across ticks:
77
+ // - agents count — from `agents` array, always populated, never empty.
78
+ // - run elapsed — from `run.createdAt`, always set on manifest.
79
+ // Both come from sources with no race window — `agents` is read from
80
+ // snapshot.agents OR agentsFor(run) (both always return same length
81
+ // for a healthy run), and `run.createdAt` is immutable. The format
82
+ // shape `"X/Y agents · Ns"` is therefore truly invariant: same number
83
+ // of `·`-separated fields, same field meanings, every render tick.
84
+ const agentCountText = `${completed}/${agents.length} agents`;
85
+ const runElapsedMs = Math.max(0, Date.now() - new Date(run.createdAt).getTime());
86
+ const runElapsedText = `${Math.floor(runElapsedMs / 1000)}s`;
87
+ const progressPart = `${agentCountText} · ${runElapsedText}`;
88
+ lines.push(truncate(`├─ ${runGlyph} ${shortRunLabel(run)} · ${progressPart} · ${run.runId.slice(-8)}`, width));
62
89
 
63
90
  const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
64
91
 
@@ -67,8 +94,9 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
67
94
  const name = liveHandle?.agent ?? agent.agent;
68
95
  const icon = agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
69
96
  const stats = agentStats(agent, liveHandle);
70
- const desc = liveHandle?.description ?? agent.role;
71
- lines.push(`│ ├─ ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`);
97
+ const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
98
+ const _finished = truncate(`│ ├─ ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
99
+ lines.push(_finished);
72
100
  }
73
101
 
74
102
  const visibleAgents = activeAgents.slice(0, MAX_AGENTS_DISPLAY);
@@ -79,13 +107,15 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
79
107
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
80
108
  const stats = agentStats(agent, liveHandle);
81
109
  const name = liveHandle?.agent ?? agent.agent;
82
- const desc = liveHandle?.description ?? agent.role;
83
- lines.push(`│ ${branch} ${agentGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`);
84
- lines.push(`│ ⊶ ${agentActivity(agent, liveHandle)}${stats ? ` · ${stats}` : ""}`);
110
+ const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
111
+ const _activeMain = truncate(`│ ${branch} ${agentGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
112
+ lines.push(_activeMain);
113
+ const _activity = truncate(`│ ⊶ ${agentActivity(agent, liveHandle)}${stats ? ` · ${stats}` : ""}`, width);
114
+ lines.push(_activity);
85
115
  }
86
116
 
87
117
  if (activeAgents.length > MAX_AGENTS_DISPLAY) {
88
- lines.push(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`);
118
+ lines.push(truncate(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`, width));
89
119
  }
90
120
 
91
121
  if (lines.length >= maxLines) break;
@@ -208,6 +208,22 @@ export function redactSecretString(value: string): string {
208
208
  // i++ (advance 1 char) and re-scanned from i+1, so a long run was rescanned O(n)
209
209
  // times = O(n^2). The P1f ReDoS test (300KB no-dot input) surfaced this pre-existing
210
210
  // bug. Now advances past the whole run when it isn't a redactable secret -> O(n).
211
+ // FIX (BG2 follow-up): the inner-loop regex test /[a-zA-Z0-9_-]/.test(value[j])
212
+ // is O(1) per call but with measurable regex-engine overhead — for a 100KB
213
+ // underscore run that's 100K regex calls, well over the 200ms budget the
214
+ // P1f regression test allows. Replaced with a charCodeAt check (~5x faster
215
+ // in practice, O(1) per call with no regex allocation/eval cost).
216
+ function isKeyChar(c: string): boolean {
217
+ const code = c.charCodeAt(0);
218
+ return (
219
+ (code >= 48 && code <= 57) || // 0-9
220
+ (code >= 65 && code <= 90) || // A-Z
221
+ (code >= 97 && code <= 122) || // a-z
222
+ code === 95 || // _
223
+ code === 45 // -
224
+ );
225
+ }
226
+
211
227
  function redactInlineSecrets(value: string): string {
212
228
  const result: string[] = [];
213
229
  let i = 0;
@@ -215,7 +231,7 @@ function redactInlineSecrets(value: string): string {
215
231
  while (i < value.length) {
216
232
  // Collect a run of key characters (alphanumeric, underscore, hyphen).
217
233
  let j = i;
218
- while (j < value.length && /[a-zA-Z0-9_-]/.test(value[j])) {
234
+ while (j < value.length && isKeyChar(value[j])) {
219
235
  j++;
220
236
  }
221
237
  const keyLen = j - i;
@@ -29,6 +29,12 @@ const WIDE_RANGES: Array<[number, number]> = [
29
29
  [0x274C, 0x274C], [0x274E, 0x274E], [0x2753, 0x2755], [0x2757, 0x2757],
30
30
  [0x2763, 0x2764], [0x2795, 0x2797], [0x27A1, 0x27A1], [0x27B0, 0x27B0],
31
31
  [0x27BF, 0x27BF],
32
+ // Geometric Shapes / Misc Symbols-Arrows emoji that pi-tui upstream counts as
33
+ // width=2 (RGI emoji). Mismatch here caused the "Rendered line N exceeds
34
+ // terminal width (160 > 159)" TUI crash: pi-crew truncated to width 159 by
35
+ // its own (mismatched) measure, then Box padded to 159 chars, but pi-tui
36
+ // re-measured the padded line at 160 because ⬜ counts as 2 upstream.
37
+ [0x2B1B, 0x2B1C], // ⬛ BLACK LARGE SQUARE, ⬜ WHITE LARGE SQUARE
32
38
  [0x1F300, 0x1F9FF], // Misc Symbols, Emoticons, Transport, Map, Supplement
33
39
  [0x1FA00, 0x1FAFF], // Symbols Extended-A
34
40
  [0x1F000, 0x1F02F], // Mahjong, Dominos