pi-crew 0.9.8 → 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 (47) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/README.md +2 -2
  3. package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
  4. package/docs/fixes/v0.9.10/smoke-test.md +12 -0
  5. package/package.json +1 -1
  6. package/src/extension/register.ts +94 -21
  7. package/src/extension/registration/subagent-helpers.ts +1 -0
  8. package/src/extension/registration/subagent-tools.ts +9 -0
  9. package/src/extension/team-tool/doctor.ts +41 -18
  10. package/src/runtime/batch-barrier.ts +145 -0
  11. package/src/runtime/child-pi.ts +135 -22
  12. package/src/runtime/compact-pipeline.ts +56 -0
  13. package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
  14. package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
  15. package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
  16. package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
  17. package/src/runtime/compact-stages/index.ts +13 -0
  18. package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
  19. package/src/runtime/compact-stages/truncation-stage.ts +71 -0
  20. package/src/runtime/crash-classification.ts +208 -0
  21. package/src/runtime/custom-tools/irc-tool.ts +47 -7
  22. package/src/runtime/handoff-manager.ts +10 -0
  23. package/src/runtime/important-line-classifier.ts +130 -0
  24. package/src/runtime/iteration-hooks.ts +7 -19
  25. package/src/runtime/live-agent-manager.ts +185 -0
  26. package/src/runtime/live-session-runtime.ts +50 -1
  27. package/src/runtime/model-fallback.ts +29 -1
  28. package/src/runtime/process-lifecycle.ts +481 -0
  29. package/src/runtime/role-permission.ts +2 -2
  30. package/src/runtime/stream-preview.ts +9 -2
  31. package/src/runtime/subagent-manager.ts +6 -0
  32. package/src/runtime/task-output-context.ts +209 -24
  33. package/src/runtime/task-runner.ts +76 -15
  34. package/src/runtime/tool-output-pruner.ts +334 -0
  35. package/src/state/locks.ts +16 -0
  36. package/src/state/state-store.ts +8 -2
  37. package/src/state/types.ts +5 -0
  38. package/src/ui/live-run-sidebar.ts +6 -1
  39. package/src/ui/loaders.ts +24 -4
  40. package/src/ui/run-dashboard.ts +6 -1
  41. package/src/ui/run-event-bus.ts +1 -1
  42. package/src/ui/run-snapshot-cache.ts +50 -16
  43. package/src/ui/widget/index.ts +27 -5
  44. package/src/ui/widget/widget-renderer.ts +43 -13
  45. package/src/utils/redaction.ts +17 -1
  46. package/src/utils/visual.ts +6 -0
  47. package/src/ui/crew-widget.ts +0 -544
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Staleness-aware tool output pruning.
3
+ *
4
+ * Identifies tool results that have been superseded by a later result for the
5
+ * same target (same file read again, same search re-run) or invalidated by a
6
+ * later successful edit/write to a covered file, and replaces the stale
7
+ * content with a compact digest notice. Protect-window and minimum-savings
8
+ * hysteresis ensure recent results are preserved and pruning only fires when
9
+ * the savings justify it.
10
+ *
11
+ * Ported and adapted from gajae-code's compaction/pruning.ts to pi-crew's
12
+ * data shapes. Pi-crew delegates conversation management to child Pi
13
+ * processes, so this module operates on a generic {@link ToolResultEntry}
14
+ * sequence rather than SessionEntry[]. The primary integration point is
15
+ * task-output-context.ts (dependency output context injected into worker
16
+ * prompts), but the module is designed to be reusable for any in-process
17
+ * tool-result sequence.
18
+ */
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * A single tool result in a sequence (oldest → newest).
26
+ * Adapted to pi-crew's shapes — does not depend on gajae-code's SessionEntry.
27
+ */
28
+ export interface ToolResultEntry {
29
+ /** Stable identifier for deduplication and correlation. */
30
+ id: string;
31
+ /** Tool name: "read", "bash", "grep", "search", "edit", "write", etc. */
32
+ toolName: string;
33
+ /**
34
+ * Target identity: file path for read/edit/write, search pattern for
35
+ * grep/search, or undefined for tools without a natural target key.
36
+ */
37
+ target?: string;
38
+ /** The tool result content text. */
39
+ content: string;
40
+ /** Whether the tool result represents an error. */
41
+ isError?: boolean;
42
+ }
43
+
44
+ /** A file mutation event (edit/write) that can invalidate earlier reads. */
45
+ export interface FileEditEvent {
46
+ /** The file path that was mutated. */
47
+ target: string;
48
+ /**
49
+ * Sequence index of this edit relative to tool results. A read at index
50
+ * `i` is stale if an edit at index `j > i` touches the same file.
51
+ */
52
+ index: number;
53
+ }
54
+
55
+ export interface PruneConfig {
56
+ /** Keep the most recent tool output tokens intact (protect window). */
57
+ protectTokens: number;
58
+ /** Only prune if total savings meets this threshold (hysteresis). */
59
+ minimumSavings: number;
60
+ /** Tool names that should never be pruned. */
61
+ protectedTools: string[];
62
+ /**
63
+ * Tools in `protectedTools` whose protection is waived once the result is
64
+ * superseded (a later result for the same target, or a later successful
65
+ * edit/write to the covered file). The most recent result per target is
66
+ * never considered superseded. Optional; defaults to none.
67
+ */
68
+ staleOverridableTools?: string[];
69
+ }
70
+
71
+ export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
72
+ protectTokens: 40_000,
73
+ minimumSavings: 20_000,
74
+ protectedTools: ["read"],
75
+ staleOverridableTools: ["read"],
76
+ };
77
+
78
+ export interface PruneResult {
79
+ /** Number of entries pruned. */
80
+ prunedCount: number;
81
+ /** Estimated tokens saved. */
82
+ tokensSaved: number;
83
+ /** The pruned result entries (same length as input, content replaced for pruned). */
84
+ results: ToolResultEntry[];
85
+ /** IDs of entries that were pruned. */
86
+ prunedIds: string[];
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Token estimation (rough char/4 heuristic, matching gajae-code)
91
+ // ---------------------------------------------------------------------------
92
+
93
+ function estimateTokens(text: string): number {
94
+ return Math.ceil(text.length / 4);
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Digest notice generation
99
+ // ---------------------------------------------------------------------------
100
+
101
+ const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
102
+
103
+ function firstErrorLine(text: string): string | undefined {
104
+ return text
105
+ .split(/\r?\n/)
106
+ .find((line) => /error|failed|exception|panic/i.test(line))
107
+ ?.trim();
108
+ }
109
+
110
+ function truncateField(value: string, maxLength: number): string {
111
+ if (value.length <= maxLength) return value;
112
+ if (maxLength <= 1) return "…";
113
+ return `${value.slice(0, maxLength - 1)}…`;
114
+ }
115
+
116
+ /**
117
+ * Generate a compact digest of a tool result for the digest notice.
118
+ * Supports bash (exit code + tail line), grep/search (match/file counts),
119
+ * and falls back to undefined for tools without a known digest format.
120
+ */
121
+ export function resultDigest(toolName: string, content: string, isError?: boolean): string | undefined {
122
+ const name = toolName.toLowerCase();
123
+ const text = content ?? "";
124
+ if (name === "bash") {
125
+ const exitCode = isError ? 1 : 0;
126
+ const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
127
+ const error = firstErrorLine(text);
128
+ return [`exit=${exitCode}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
129
+ .filter((part): part is string => part !== undefined)
130
+ .join("; ");
131
+ }
132
+ if (name === "search" || name === "grep") {
133
+ const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
134
+ const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
135
+ const error = firstErrorLine(text);
136
+ return (
137
+ [
138
+ match ? `matches=${match[1]}` : undefined,
139
+ files ? `files=${files[1]}` : undefined,
140
+ error ? `error=${error}` : undefined,
141
+ ]
142
+ .filter((part): part is string => part !== undefined)
143
+ .join("; ") || "search digest unavailable"
144
+ );
145
+ }
146
+ return undefined;
147
+ }
148
+
149
+ function createPrunedNotice(tokens: number, entry: ToolResultEntry): string {
150
+ const generic = `[Output pruned — ${tokens} tokens]`;
151
+ const digest = resultDigest(entry.toolName, entry.content, entry.isError);
152
+ if (!digest) return generic;
153
+ const genericTokens = Math.ceil(generic.length / 4);
154
+ const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
155
+ const prefix = `[Output pruned — ${tokens} tokens; `;
156
+ const suffix = "]";
157
+ const maxChars = Math.max(0, maxTokens * 4 - prefix.length - suffix.length);
158
+ return `${prefix}${truncateField(digest, maxChars)}${suffix}`;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Target key resolution
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
167
+ * `:raw`, `:conflicts`), possibly stacked. Stripped to resolve the
168
+ * underlying file for edit invalidation.
169
+ */
170
+ const READ_SELECTOR_SUFFIX = /:(?:raw|conflicts|\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/;
171
+
172
+ /** Base file path of a read target with any line/mode selectors stripped. */
173
+ function readBasePath(filePath: string): string {
174
+ let base = filePath;
175
+ while (READ_SELECTOR_SUFFIX.test(base)) {
176
+ base = base.replace(READ_SELECTOR_SUFFIX, "");
177
+ }
178
+ return base;
179
+ }
180
+
181
+ /**
182
+ * Stable identity for "the same logical lookup": same tool re-targeting the
183
+ * same subject. A later result with the same key supersedes earlier ones.
184
+ */
185
+ function toolTargetKey(entry: ToolResultEntry): string | undefined {
186
+ if (!entry.target || entry.target.length === 0) return undefined;
187
+ return JSON.stringify([entry.toolName, entry.target]);
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Staleness index
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export interface StalenessIndex {
195
+ /** Indices of tool results superseded by a later same-target result or edit. */
196
+ staleIndices: Set<number>;
197
+ }
198
+
199
+ /**
200
+ * Build a staleness index over a sequence of tool results (oldest → newest):
201
+ * - a tool result is stale when a later non-error result shares its target key;
202
+ * - a `read` result is stale when a later edit event touches its file.
203
+ * The most recent result per target is never stale.
204
+ *
205
+ * @param toolResults Ordered tool result entries (oldest first).
206
+ * @param fileEdits Optional file mutation events with sequence indices.
207
+ */
208
+ export function buildStalenessIndex(toolResults: ToolResultEntry[], fileEdits: FileEditEvent[] = []): StalenessIndex {
209
+ // Map target key → last result index that has it.
210
+ const lastResultIndexByKey = new Map<string, number>();
211
+ for (let i = 0; i < toolResults.length; i++) {
212
+ const entry = toolResults[i]!;
213
+ if (entry.isError) continue;
214
+ const key = toolTargetKey(entry);
215
+ if (key !== undefined) lastResultIndexByKey.set(key, i);
216
+ }
217
+
218
+ // Map file path → last edit index.
219
+ const lastEditIndexByPath = new Map<string, number>();
220
+ for (const edit of fileEdits) {
221
+ lastEditIndexByPath.set(edit.target, edit.index);
222
+ }
223
+
224
+ const staleIndices = new Set<number>();
225
+ for (let i = 0; i < toolResults.length; i++) {
226
+ const entry = toolResults[i]!;
227
+ // Check superseded by same-target re-read.
228
+ const key = toolTargetKey(entry);
229
+ if (key !== undefined) {
230
+ const lastIndex = lastResultIndexByKey.get(key);
231
+ if (lastIndex !== undefined && lastIndex > i) {
232
+ staleIndices.add(i);
233
+ continue;
234
+ }
235
+ }
236
+ // Check invalidated by later file edit (read-specific).
237
+ if (entry.toolName.toLowerCase() === "read" && entry.target) {
238
+ const basePath = readBasePath(entry.target);
239
+ const editIndex = lastEditIndexByPath.get(basePath);
240
+ if (editIndex !== undefined && editIndex > i) {
241
+ staleIndices.add(i);
242
+ }
243
+ }
244
+ }
245
+
246
+ return { staleIndices };
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Pruning
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * Prune stale tool outputs from a sequence, replacing superseded content with
255
+ * compact digest notices. Protect-window, protected-tools immunity, and
256
+ * minimum-savings hysteresis are all respected.
257
+ *
258
+ * OPT-IN by default: {@link DEFAULT_PRUNE_CONFIG} protects recent results via
259
+ * a generous `protectTokens` window. Only results outside the window AND not
260
+ * protected AND stale (or old enough) are pruned.
261
+ *
262
+ * @param results Ordered tool result entries (oldest first).
263
+ * @param config Prune configuration. Defaults to {@link DEFAULT_PRUNE_CONFIG}.
264
+ */
265
+ export function pruneToolOutputs(results: ToolResultEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
266
+ const { staleIndices } = buildStalenessIndex(results);
267
+ const staleOverridable = new Set(config.staleOverridableTools ?? []);
268
+
269
+ let accumulatedTokens = 0;
270
+ let tokensSaved = 0;
271
+ let prunedCount = 0;
272
+
273
+ interface Candidate {
274
+ index: number;
275
+ entry: ToolResultEntry;
276
+ tokens: number;
277
+ notice: string;
278
+ savings: number;
279
+ }
280
+ const candidates: Candidate[] = [];
281
+ const prunedIds: string[] = [];
282
+
283
+ // Iterate newest → oldest to accumulate the protect window from the tail.
284
+ for (let i = results.length - 1; i >= 0; i--) {
285
+ const entry = results[i]!;
286
+ const tokens = estimateTokens(entry.content);
287
+ const isStale = staleIndices.has(i);
288
+
289
+ // Staleness waives protected-tool immunity for overridable tools
290
+ // (e.g. a superseded `read`); the most recent result per target is
291
+ // never stale, so the latest read of each file stays protected.
292
+ const isProtected =
293
+ config.protectedTools.includes(entry.toolName) &&
294
+ !(isStale && staleOverridable.has(entry.toolName));
295
+
296
+ // Stale results are prunable even inside the recency protect window —
297
+ // they are superseded, so recency no longer implies relevance. They
298
+ // still count toward window accounting so non-stale protection is
299
+ // unchanged.
300
+ const insideProtectWindow = accumulatedTokens < config.protectTokens;
301
+ if ((insideProtectWindow && !isStale) || isProtected) {
302
+ accumulatedTokens += tokens;
303
+ continue;
304
+ }
305
+
306
+ const notice = createPrunedNotice(tokens, entry);
307
+ candidates.push({
308
+ index: i,
309
+ entry,
310
+ tokens,
311
+ notice,
312
+ savings: Math.max(0, tokens - Math.ceil(notice.length / 4)),
313
+ });
314
+ accumulatedTokens += tokens;
315
+ }
316
+
317
+ for (const candidate of candidates) {
318
+ tokensSaved += candidate.savings;
319
+ }
320
+
321
+ // Hysteresis: only prune if savings meet the threshold.
322
+ if (tokensSaved < config.minimumSavings || candidates.length === 0) {
323
+ return { prunedCount: 0, tokensSaved: 0, results, prunedIds: [] };
324
+ }
325
+
326
+ const prunedResults = [...results];
327
+ for (const candidate of candidates) {
328
+ prunedResults[candidate.index] = { ...candidate.entry, content: candidate.notice };
329
+ prunedIds.push(candidate.entry.id);
330
+ prunedCount++;
331
+ }
332
+
333
+ return { prunedCount, tokensSaved, results: prunedResults, prunedIds };
334
+ }
@@ -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,
@@ -3,6 +3,7 @@ import type { TaskClaimState } from "./task-claims.ts";
3
3
  import type { WorkerHeartbeatState } from "../runtime/worker-heartbeat.ts";
4
4
  import type { CrewAgentProgress } from "../runtime/crew-agent-runtime.ts";
5
5
  import type { RolloutEntry, CoherenceMark } from "./decision-ledger.ts";
6
+ import type { CrashClass } from "../runtime/crash-classification.ts";
6
7
  export type { RolloutEntry, CoherenceMark };
7
8
  export type { CrewAgentProgress };
8
9
 
@@ -116,6 +117,10 @@ export interface WorkerExitStatus {
116
117
  signal?: string;
117
118
  cleanupErrors: string[];
118
119
  finalDrainMs: number;
120
+ /** Categorical classification of the exit (P0 crash taxonomy). Optional
121
+ * because it is populated by child-pi.ts at settle time; older/synthetic
122
+ * exit statuses may omit it. */
123
+ crashClass?: CrashClass;
119
124
  /** Phase-0 diagnostic (HB-003a): final-drain race state for the exit-null
120
125
  * disableTools bug. Optional + read-only — absent when no drain timer was
121
126
  * ever armed. Phase 1 will use `finalDrainArmed` to decide whether a
@@ -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);