pi-crew 0.11.7 → 0.11.9

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 (66) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +196 -1
  4. package/dist/index.mjs +533 -515
  5. package/docs/publishing.md +29 -0
  6. package/docs/usage.md +1 -1
  7. package/index.ts +3 -3
  8. package/install.mjs +4 -1
  9. package/package.json +8 -5
  10. package/scripts/install-hooks.mjs +32 -0
  11. package/skills/real-test-pi-crew/SKILL.md +57 -18
  12. package/src/config/config-validation.ts +8 -1
  13. package/src/config/defaults.ts +8 -1
  14. package/src/config/env-vars.ts +16 -1
  15. package/src/config/types.ts +28 -4
  16. package/src/extension/command-completions.ts +44 -22
  17. package/src/extension/registration/foreground-run-controller.ts +19 -10
  18. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  19. package/src/extension/registration/lifecycle.ts +7 -90
  20. package/src/extension/registration/render-loop.ts +532 -0
  21. package/src/extension/team-tool/doctor.ts +6 -0
  22. package/src/extension/team-tool/handle-settings.ts +4 -1
  23. package/src/runtime/child-pi/child-pi-spawn.ts +7 -0
  24. package/src/runtime/child-pi/child-pi.ts +37 -2
  25. package/src/runtime/child-pi/pi-version.ts +187 -0
  26. package/src/runtime/classifier/classifier-service.ts +298 -0
  27. package/src/runtime/dispatch-batch.ts +4 -0
  28. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  29. package/src/runtime/model/pi-args.ts +62 -3
  30. package/src/runtime/process/zombie-scanner.ts +168 -7
  31. package/src/runtime/rpc/README.md +64 -25
  32. package/src/runtime/rpc/rpc-worker.ts +146 -26
  33. package/src/runtime/run-worker.ts +89 -15
  34. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  35. package/src/runtime/skill-instructions.ts +66 -1
  36. package/src/runtime/surface/resolve-surface.ts +34 -19
  37. package/src/runtime/task-runner/child-executor.ts +132 -0
  38. package/src/runtime/task-runner/pre-execution.ts +8 -0
  39. package/src/runtime/task-runner.ts +6 -0
  40. package/src/schema/config-schema.ts +20 -8
  41. package/src/schema/team-tool-schema.ts +4 -2
  42. package/src/state/contracts.ts +5 -0
  43. package/src/ui/dashboard-panes/agents-pane.ts +3 -2
  44. package/src/ui/dashboard-panes/health-pane.ts +6 -1
  45. package/src/ui/dashboard-panes/mailbox-pane.ts +12 -7
  46. package/src/ui/dashboard-panes/plan-pane.ts +5 -2
  47. package/src/ui/dashboard-panes/progress-pane.ts +8 -2
  48. package/src/ui/format-helpers.ts +11 -0
  49. package/src/ui/live-conversation-overlay.ts +5 -3
  50. package/src/ui/live-duration.ts +5 -4
  51. package/src/ui/live-run-sidebar.ts +90 -20
  52. package/src/ui/overlays/mailbox-compose-overlay.ts +31 -1
  53. package/src/ui/rail.ts +14 -1
  54. package/src/ui/run-action-dispatcher.ts +2 -1
  55. package/src/ui/run-dashboard.ts +62 -22
  56. package/src/ui/run-snapshot-cache.ts +32 -0
  57. package/src/ui/settings-overlay.ts +36 -3
  58. package/src/ui/tool-renderers/brief-mode.ts +2 -2
  59. package/src/ui/transcript-viewer.ts +4 -1
  60. package/src/ui/widget/index.ts +10 -2
  61. package/src/ui/widget/task-list.ts +22 -7
  62. package/src/ui/widget/widget-model.ts +5 -4
  63. package/src/ui/widget/widget-renderer.ts +212 -37
  64. package/src/utils/relative-time.ts +11 -3
  65. package/src/utils/socket-path.ts +48 -8
  66. package/src/utils/visual.ts +32 -2
@@ -0,0 +1,532 @@
1
+ /**
2
+ * setupRenderLoop — the per-session render pipeline (RenderScheduler +
3
+ * preload loop + bounded run watchers + health-notification gates) plus the
4
+ * pure frame helpers it shares. Extracted verbatim from
5
+ * lifecycle-handlers.ts (QW#2, deep review 2026-10-01) so the lifecycle
6
+ * module stays focused on session_start/shutdown/before_switch wiring.
7
+ *
8
+ * The loop itself is driven end-to-end through
9
+ * `installSessionLifecycleHandlers` (see preload-idle-render.test.ts); the
10
+ * exported pure helpers are pinned directly by
11
+ * test/unit/extension/registration/lifecycle-health-filter.test.ts.
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { loadConfig } from "../../config/config.ts";
17
+ import { DEFAULT_UI } from "../../config/defaults.ts";
18
+ import { forgetDetachedRun, hasDetachedRuns, peekFinishedDetachedRunResults } from "../../runtime/detached-run-results.ts";
19
+ import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
20
+ import type { createManifestCache } from "../../runtime/manifest-cache.ts";
21
+ import { loadTasksWithRecovery } from "../../state/stores/manifest-io.ts";
22
+ import type { TeamRunManifest } from "../../state/types.ts";
23
+ import { overlayFreshTaskStatuses, summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
24
+ import { requestRender, setExtensionWidget, toPiWidgetPlacement } from "../../ui/pi-ui-compat.ts";
25
+ import { requestPowerbarUpdate } from "../../ui/powerbar-publisher.ts";
26
+ import { RenderScheduler } from "../../ui/render-scheduler.ts";
27
+ import { runEventBus } from "../../ui/run-event-bus.ts";
28
+ import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
29
+ import { updateCrewWidget } from "../../ui/widget/index.ts";
30
+ import { logInternalError } from "../../utils/internal-error.ts";
31
+ import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
32
+ import { RunWatcherRegistry } from "../../utils/run-watcher-registry.ts";
33
+ import { healthNotifyFingerprint, recordHealthNotifyDecision, resetHealthNotifyEntry } from "./health-notify-policy.ts";
34
+ import type { RegistrationContext } from "./registration-types.ts";
35
+ import { purgeQueuedAmbientNotifications } from "./subagent-helpers.ts";
36
+
37
+ /**
38
+ * Hand over the outcome of runs detached by an agent-view switch.
39
+ *
40
+ * Delivered as a DISPLAYED session entry (not a follow-up queue item): an idle
41
+ * session never flushes a follow-up, so the queued variant left the result
42
+ * invisible. Each result is dropped from the registry only after its send
43
+ * succeeded, and only while the owning session is current — a worker's view
44
+ * session must never receive the parent run's report.
45
+ */
46
+ export function deliverDetachedRunResults(pi: ExtensionAPI, extensionCtx: ExtensionContext): void {
47
+ if (!hasDetachedRuns()) return;
48
+ try {
49
+ // Agent views are in-document panes now, never sessions: the current
50
+ // session is always the main one, so detached results always deliver.
51
+ const inViewSession = false;
52
+ for (const { runId, text } of peekFinishedDetachedRunResults({ inViewSession })) {
53
+ pi.sendMessage({ customType: "pi-crew-run-result", content: text, display: true });
54
+ forgetDetachedRun(runId);
55
+ try {
56
+ extensionCtx.ui.notify(text.split("\n")[0] ?? `pi-crew run finished: ${runId}`, "info");
57
+ } catch {
58
+ /* toast is secondary to the session entry */
59
+ }
60
+ }
61
+ } catch (error) {
62
+ // Keep the entry: the next tick retries rather than losing the outcome.
63
+ logInternalError("register.detachedRunResults", error);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Phase 5 (Vector #3): keep only the CURRENT session's owned runs (plus
69
+ * ownerless runs) for health notifications. Previously the inline filter derived
70
+ * `currentSessionId` from a cast that was always `undefined` and compared
71
+ * against `ownerSessionGeneration` (a field absent from TeamRunManifest), so
72
+ * together they dropped EVERY owned run. Exported for unit testing.
73
+ */
74
+ export function filterManifestsForHealthNotifications(
75
+ manifests: TeamRunManifest[],
76
+ currentSessionId: string | undefined,
77
+ ): TeamRunManifest[] {
78
+ return manifests.filter((run) => !run.ownerSessionId || run.ownerSessionId === currentSessionId);
79
+ }
80
+
81
+ /**
82
+ * bug-026 sub-issue C: runEventBus event types that mark a run TERMINAL,
83
+ * across BOTH type namespaces seen on the bus — dotted `run.*` strings (kept
84
+ * for parity with classifyEventChannel's WORKER_LIFECYCLE_TYPES, see
85
+ * src/ui/run-event-bus.ts:31-44) and underscore `run_*` (the native
86
+ * RunEventType namespace actually emitted by team-runner, e.g.
87
+ * `run_completed`). Exported for unit testing.
88
+ */
89
+ export const TERMINAL_RUN_EVENT_TYPES: ReadonlySet<string> = new Set([
90
+ "run.completed",
91
+ "run.failed",
92
+ "run.cancelled",
93
+ "run_completed",
94
+ "run_failed",
95
+ "run_cancelled",
96
+ ]);
97
+
98
+ /** True when a runEventBus event type marks the run terminal (either namespace). */
99
+ export function isTerminalRunEventType(type: string): boolean {
100
+ return TERMINAL_RUN_EVENT_TYPES.has(type);
101
+ }
102
+
103
+ /** Pure filter: drop `runId` from a preloaded-manifest frame (bug-026 sub-issue C eviction). */
104
+ export function evictRunFromManifests(manifests: TeamRunManifest[], runId: string): TeamRunManifest[] {
105
+ return manifests.filter((m) => m.runId !== runId);
106
+ }
107
+
108
+ /**
109
+ * F14 (RR-019): cheap signature of the preloaded-manifest frame. Only a
110
+ * CHANGED signature lets `backgroundPreload` count as render activity
111
+ * (`schedule()`); an identical signature means "nothing the widget would
112
+ * paint differently" and must NOT reset the render scheduler's idle
113
+ * counters (that defeated the R1 idle stop — see setupRenderLoop). Covers
114
+ * run identity, status transitions, and updatedAt bumps. Exported for tests.
115
+ */
116
+ export function manifestsFrameSignature(manifests: TeamRunManifest[]): string {
117
+ return manifests.map((m) => `${m.runId}:${m.status}:${m.updatedAt}`).join("|");
118
+ }
119
+
120
+ /**
121
+ * Apply a runEventBus payload to a preloaded-manifest frame: evict the run on
122
+ * terminal events, pass through unchanged otherwise. This is the exact logic
123
+ * wired into the setupRenderLoop `runEventBus.onAny` subscription (bug-026
124
+ * sub-issue C); exported so tests can exercise it end-to-end against the
125
+ * real bus without spinning up the full render loop.
126
+ */
127
+ export function applyTerminalRunEventToManifests(manifests: TeamRunManifest[], event: { type: string; runId: string }): TeamRunManifest[] {
128
+ return isTerminalRunEventType(event.type) ? evictRunFromManifests(manifests, event.runId) : manifests;
129
+ }
130
+
131
+ /**
132
+ * Build the render scheduler + preload loop + bounded run watchers.
133
+ *
134
+ * Render path:
135
+ * - RenderScheduler fires renderTick() every `effectiveRefreshMs()`.
136
+ * - 160ms when live agents OR background runs are active (spinner-friendly),
137
+ * else the configured `dashboardLiveRefreshMs` (default DEFAULT_UI.refreshMs).
138
+ * - renderTick reads from a pre-computed frame (`lastPreloadedManifests`) —
139
+ * zero fs I/O on the hot path.
140
+ *
141
+ * Watchers:
142
+ * - pts/2 hang fix (2026-06-16): a SINGLE non-recursive watcher on the
143
+ * `runs/` root (new-run detection) plus per-active-run watchers
144
+ * reconciled each preload tick. Total inotify cost: O(active runs).
145
+ */
146
+ export function setupRenderLoop(
147
+ pi: ExtensionAPI,
148
+ ctx: RegistrationContext,
149
+ extensionCtx: ExtensionContext,
150
+ loadedConfig: ReturnType<typeof loadConfig>,
151
+ ): void {
152
+ ctx.disposeRenderSchedulerSubscriptions();
153
+ ctx.renderScheduler?.dispose();
154
+ ctx.terminalStatus?.dispose();
155
+ ctx.terminalStatus = undefined;
156
+ ctx.terminalStatusActive = false;
157
+
158
+ // Phase 12: Async preloading — renderTick reads only a pre-computed frame.
159
+ let preloading = false;
160
+ let lastPreloadedConfig: ReturnType<typeof loadConfig> | undefined;
161
+ let lastPreloadedManifests: TeamRunManifest[] = [];
162
+ let lastFrameManifestCache: ReturnType<typeof createManifestCache> | undefined;
163
+ let lastFrameSnapshotCache: ReturnType<typeof createRunSnapshotCache> | undefined;
164
+ // F14: signature of the last frame backgroundPreload saw — only a change
165
+ // here counts as render activity.
166
+ let lastFrameSignature: string | undefined;
167
+
168
+ const ownerGeneration = ctx.sessionGeneration;
169
+
170
+ const buildFrame = async (): Promise<{ ok: boolean; changed: boolean }> => {
171
+ if (!ctx.currentCtx) return { ok: false, changed: false };
172
+ lastPreloadedConfig = loadConfig(ctx.currentCtx.cwd);
173
+ lastFrameManifestCache = ctx.getManifestCache(ctx.currentCtx.cwd);
174
+ lastFrameSnapshotCache = ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
175
+ const manifests = lastFrameManifestCache.list(20);
176
+ lastPreloadedManifests = manifests;
177
+ // F14 (RR-019): compute the frame signature BEFORE the preload await so
178
+ // `changed` reflects what this tick saw, not a mid-preload mutation.
179
+ const frameSignature = manifestsFrameSignature(manifests);
180
+ const frameChanged = frameSignature !== lastFrameSignature;
181
+ lastFrameSignature = frameSignature;
182
+ // pts/2 hang fix: reconcile per-run watchers against the ACTIVE set only.
183
+ {
184
+ const onRunChange = (runId: string): void => {
185
+ if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
186
+ // FLICKER FIX: rebuild-in-place instead of deleting the entry. The
187
+ // file just changed on disk, so force a fresh snapshot while keeping
188
+ // the entry populated — deleting it left a window where the widget's
189
+ // `get()` returned undefined and dropped the run to "(loading…)".
190
+ // PERF (2026-08-24): route through the coalesced ASYNC refresh —
191
+ // fs.watch can fire many times per second and the sync rebuild
192
+ // blocked the UI event loop. The entry stays populated until the
193
+ // async rebuild re-sets it in place; the render schedule below
194
+ // repaints while it lands.
195
+ try {
196
+ ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).scheduleRefresh(runId);
197
+ } catch (error) {
198
+ logInternalError("register.runWatcher.refresh", error, runId);
199
+ }
200
+ ctx.renderScheduler?.schedule({ runId });
201
+ };
202
+ const onWatchErr = (error: unknown): void => {
203
+ logInternalError("register.runWatcher.change", error);
204
+ };
205
+ const active = manifests
206
+ .filter((r) => r.status === "running" || r.status === "queued" || r.status === "planning")
207
+ .map((r) => ({ runId: r.runId, runDir: r.stateRoot }));
208
+ ctx.crewRunWatchers?.reconcile(active, onRunChange, onWatchErr);
209
+ ctx.userCrewWatchers?.reconcile(active, onRunChange, onWatchErr);
210
+ }
211
+ const runIds = manifests.map((r) => r.runId);
212
+ await lastFrameSnapshotCache.preloadAllStale(runIds);
213
+ return { ok: true, changed: frameChanged };
214
+ };
215
+
216
+ const backgroundPreload = (): void => {
217
+ if (!ctx.currentCtx || preloading) return;
218
+ preloading = true;
219
+ buildFrame()
220
+ .then(({ ok, changed }) => {
221
+ preloading = false;
222
+ // F14 (RR-019): preload completion is maintenance, NOT user activity.
223
+ // Only a genuinely CHANGED frame may call schedule() — an unchanged
224
+ // frame calling schedule() reset `lastEventAt`/`idleFallbackRenders`
225
+ // and re-armed the fallback loop that fallbackLoop() had just
226
+ // deliberately stopped (R1), rendering forever while idle. Real
227
+ // work still animates: runEventBus + fs.watch subscriptions call
228
+ // schedule() directly on genuine events.
229
+ if (ok && changed) ctx.renderScheduler?.schedule();
230
+ })
231
+ .catch((error: unknown) => {
232
+ preloading = false;
233
+ logInternalError("register.backgroundPreload", error);
234
+ });
235
+ };
236
+
237
+ const startPreloadLoop = (intervalMs: number, dynamicMs?: () => number): void => {
238
+ if (ctx.preloadTimer) clearTimeout(ctx.preloadTimer);
239
+ const tick = (): void => {
240
+ backgroundPreload();
241
+ const nextMs = dynamicMs?.() ?? intervalMs;
242
+ ctx.preloadTimer = setTimeout(tick, nextMs);
243
+ ctx.preloadTimer.unref();
244
+ };
245
+ ctx.preloadTimer = setTimeout(tick, intervalMs);
246
+ ctx.preloadTimer.unref();
247
+ };
248
+
249
+ const renderTick = (): void => {
250
+ if (!ctx.currentCtx) return;
251
+ deliverDetachedRunResults(pi, ctx.currentCtx);
252
+ const config = lastPreloadedConfig?.config.ui;
253
+ const activeCache = lastFrameManifestCache ?? ctx.getManifestCache(ctx.currentCtx.cwd);
254
+ const snapshotCache = lastFrameSnapshotCache ?? ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
255
+ const manifests = lastPreloadedManifests;
256
+ if (!lastPreloadedConfig) backgroundPreload();
257
+ if (ctx.uiState.liveSidebarRunId || ctx.uiState.dashboardOpen) {
258
+ const placement = toPiWidgetPlacement(config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement);
259
+ if (ctx.widgetState.lastVisibility !== "hidden" || ctx.widgetState.lastPlacement !== placement) {
260
+ setExtensionWidget(ctx.currentCtx, "pi-crew", undefined, { placement });
261
+ setExtensionWidget(ctx.currentCtx, "pi-crew-active", undefined, { placement });
262
+ ctx.widgetState.lastVisibility = "hidden";
263
+ ctx.widgetState.lastPlacement = placement;
264
+ ctx.widgetState.lastKey = "pi-crew-active";
265
+ ctx.widgetState.model = undefined;
266
+ }
267
+ requestRender(ctx.currentCtx);
268
+ } else {
269
+ updateCrewWidget(ctx.currentCtx, ctx.widgetState, config, activeCache, snapshotCache, manifests);
270
+ }
271
+ requestPowerbarUpdate(
272
+ pi.events,
273
+ ctx.currentCtx.cwd,
274
+ config,
275
+ activeCache,
276
+ snapshotCache,
277
+ ctx.currentCtx,
278
+ ctx.widgetState.notificationCount ?? 0,
279
+ manifests,
280
+ );
281
+ // Health notifications: only warn about genuinely running runs.
282
+ // Phase 5 (Vector #3): derive currentSessionId via the working accessor.
283
+ // ctx is RegistrationContext; currentCtx holds the ExtensionContext whose
284
+ // sessionManager exposes getSessionId(). The previous cast to {sessionId?}
285
+ // was always undefined, and the ownerSessionGeneration clause referenced a
286
+ // field absent from TeamRunManifest — together they dropped EVERY owned
287
+ // run. Now only the current session's owned runs + ownerless runs pass.
288
+ const currentSessionId = ctx.currentCtx?.sessionManager?.getSessionId();
289
+ const sessionManifests = filterManifestsForHealthNotifications(manifests, currentSessionId);
290
+ const now = Date.now();
291
+ // FIX #2: clear path — when a run is detected terminal, dismiss any
292
+ // previously-emitted health notification for it AND drop its cooldown
293
+ // (autoRecoveryLast) so a future genuine re-occurrence can re-notify.
294
+ // Keeps the dashboard clean and stops the 5-min re-fire cycle.
295
+ const clearHealthNotifications = (runId: string): void => {
296
+ for (const kind of ["recovery_dead_workers", "recovery_missing_heartbeat"]) {
297
+ const key = `${kind}_${runId}`;
298
+ // FINDING 6: reset the fire budget (a genuine recurrence after
299
+ // this clear re-notifies) AND opportunistically purge
300
+ // still-QUEUED host follow-up copies of the original warning —
301
+ // the host drains the queue one message per turn boundary, so
302
+ // stale copies otherwise drip in for hours after the clear
303
+ // (live: 5h of "missing heartbeat" replays after the 2026-09-23
304
+ // zombie cleanup). The purge is feature-detected: hosts without
305
+ // clearQueuedUserMessagesMatching (e.g. 0.87.0) no-op here; the
306
+ // fire-cap policy bounds the backlog on every host.
307
+ resetHealthNotifyEntry({ entries: ctx.autoRecoveryLast, maxEntries: ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES }, key);
308
+ ctx.notifyOperator({
309
+ id: key,
310
+ clear: true,
311
+ severity: "info",
312
+ source: "health",
313
+ runId,
314
+ title: `Cleared ${kind} for ${runId}`,
315
+ });
316
+ purgeQueuedAmbientNotifications(
317
+ pi,
318
+ (text) => text.includes(runId) && (text.includes("dead worker") || text.includes("missing heartbeat")),
319
+ );
320
+ }
321
+ };
322
+ // L2 (real-test 2026-10-07, report L2): rebuild-in-place, NEVER
323
+ // hard-delete. The health gates below used `invalidate(runId)`
324
+ // (entries.delete) on every tick for terminal/divergent runs — exactly
325
+ // what the FLICKER FIX invariant (run-snapshot-cache.ts scheduleRefresh
326
+ // docs + the onRunChange wiring above) forbids: for 1-n frames
327
+ // `snapshotCache.get(runId)` returned undefined and the widget/powerbar
328
+ // fell back to their disk-read branch around every run completion
329
+ // (visible flicker). Route through the coalesced ASYNC rebuild instead —
330
+ // the entry stays populated until the rebuild re-sets it in place. No
331
+ // renderScheduler.schedule() here: unlike the fs.watch onRunChange path
332
+ // we are ALREADY inside a renderTick, and list(20) keeps terminal runs
333
+ // in sessionManifests for many ticks, so a schedule() from the gates
334
+ // would reset lastEventAt on every tick and defeat the R1 idle stop
335
+ // (pinned by preload-idle-render.test.ts).
336
+ const refreshSnapshotInPlace = (runId: string): void => {
337
+ try {
338
+ snapshotCache.scheduleRefresh(runId);
339
+ } catch (error) {
340
+ logInternalError("register.renderLoop.snapshotRefresh", error, runId);
341
+ }
342
+ };
343
+ for (const run of sessionManifests) {
344
+ if (run.status !== "running") {
345
+ // GATE 1 — preloaded manifest says terminal. Refresh the snapshot in
346
+ // place (never delete) and clear previously-emitted health notifications
347
+ // so the dashboard stays clean (belt-and-suspenders with the FIX #1
348
+ // fresh-read gate).
349
+ refreshSnapshotInPlace(run.runId);
350
+ clearHealthNotifications(run.runId);
351
+ continue;
352
+ }
353
+ try {
354
+ // FIX #1: re-verify against a FRESH manifest read. The preloaded `run`
355
+ // (from lastPreloadedManifests) can lag the on-disk terminal
356
+ // transition; the manifest cache has a 500ms TTL + file watcher so it
357
+ // is the source of truth. A terminal run must NEVER reach
358
+ // maybeNotifyHealth. Also refresh the stale snapshot in place
359
+ // (never delete) and clear any previously-emitted health
360
+ // notification for this run.
361
+ const freshManifest = ctx.getManifestCache(extensionCtx.cwd).get(run.runId);
362
+ if (freshManifest?.status !== "running") {
363
+ refreshSnapshotInPlace(run.runId);
364
+ clearHealthNotifications(run.runId);
365
+ continue;
366
+ }
367
+ // L5: paint-path read through the read-only accessor — cached
368
+ // snapshot only; a lapsed TTL schedules the coalesced async refresh
369
+ // instead of ever rebuilding synchronously inside renderTick.
370
+ const snapshot = snapshotCache.readForRender(run.runId);
371
+ if (!snapshot) continue;
372
+ if (snapshot.manifest.status !== "running") {
373
+ // GATE 2 — a running manifest paired with a now-terminal snapshot is
374
+ // stale. Refresh it in place so subsequent ticks get the fresh view,
375
+ // and clear.
376
+ refreshSnapshotInPlace(run.runId);
377
+ clearHealthNotifications(run.runId);
378
+ continue;
379
+ }
380
+ // GATE 3 (FINDING 5) — task-status truth lives on DISK, not in the
381
+ // snapshot cache. A worker parked on `ask` writes running → waiting
382
+ // to tasks.json immediately, but the cached snapshot can lag that
383
+ // transition; in the window a parked (alive, silent-by-design)
384
+ // worker counted as active-without-heartbeat and fired a false
385
+ // "dead worker" (live: team_20260923100114, 01_explore parked on
386
+ // ask). Overlay FRESH statuses before summarizing; divergence also
387
+ // schedules an in-place rebuild so the cache syncs off the tick.
388
+ const freshTasks = loadTasksWithRecovery(freshManifest.tasksPath, freshManifest.eventsPath, run.runId);
389
+ const overlaid = overlayFreshTaskStatuses(snapshot, freshTasks);
390
+ if (overlaid !== snapshot) refreshSnapshotInPlace(run.runId);
391
+ const summary = summarizeHeartbeats(overlaid, { now });
392
+ const fingerprint = healthNotifyFingerprint(summary, overlaid.tasks.length);
393
+ const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
394
+ if (count <= 0) return;
395
+ const key = `${kind}_${run.runId}`;
396
+ // FINDING 6: bounded re-fire — ≤ MAX_HEALTH_NOTIFY_FIRES per
397
+ // UNCHANGED fingerprint (5-min cooldown between fires). The
398
+ // old forever-re-arming cooldown fed the host follow-up queue
399
+ // duplicates that drained one-per-turn-boundary for hours.
400
+ // Policy detail + LRU eviction: health-notify-policy.ts.
401
+ const state = { entries: ctx.autoRecoveryLast, maxEntries: ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES };
402
+ if (!recordHealthNotifyDecision(state, key, fingerprint, now)) return;
403
+ ctx.notifyOperator({
404
+ id: key,
405
+ severity: "warning",
406
+ source: "health",
407
+ runId: run.runId,
408
+ title,
409
+ body,
410
+ });
411
+ };
412
+ maybeNotifyHealth(
413
+ "recovery_dead_workers",
414
+ summary.dead,
415
+ `Run ${run.runId} has ${summary.dead} dead worker(s).`,
416
+ "Open /team-dashboard → 5 health → R recovery / K kill stale / D diagnostic.",
417
+ );
418
+ maybeNotifyHealth(
419
+ "recovery_missing_heartbeat",
420
+ summary.missing,
421
+ `Run ${run.runId} has ${summary.missing} worker(s) missing heartbeat.`,
422
+ "Open /team-dashboard → 5 health → inspect health actions.",
423
+ );
424
+ } catch (error) {
425
+ logInternalError("register.health-notification", error, run.runId);
426
+ }
427
+ }
428
+ };
429
+
430
+ const fallbackMs = loadedConfig.config.ui?.dashboardLiveRefreshMs ?? DEFAULT_UI.refreshMs;
431
+ const liveRefreshMs = 160;
432
+ const hasActiveWork = (): boolean => {
433
+ if (listLiveAgents().some((a) => a.status === "running")) return true;
434
+ return lastPreloadedManifests.some((r) => r.status === "running" || r.status === "queued" || r.status === "planning");
435
+ };
436
+ const effectiveRefreshMs = () => (hasActiveWork() ? liveRefreshMs : fallbackMs);
437
+ ctx.renderScheduler = new RenderScheduler(pi.events, renderTick, {
438
+ fallbackMs: effectiveRefreshMs,
439
+ onInvalidate: (payload: unknown) => {
440
+ const runId =
441
+ typeof payload === "object" &&
442
+ payload !== null &&
443
+ "runId" in payload &&
444
+ typeof (payload as { runId: unknown }).runId === "string"
445
+ ? (payload as { runId: string }).runId
446
+ : undefined;
447
+ // FLICKER FIX: never hard-delete snapshot entries from a render-scheduler
448
+ // invalidate. A no-runId payload — emitted by EVERY fallback tick
449
+ // (~every 160ms while a run is active) — previously ran
450
+ // `invalidate(undefined)` → `entries.clear()`, wiping ALL snapshots.
451
+ // The next `renderTick` then saw `get() === undefined` for every run,
452
+ // so `activeWidgetRuns` dropped them to "(loading…)" until the async
453
+ // preload rebuilt the cache — an endless visible flicker. For a
454
+ // specific runId we schedule the coalesced ASYNC refresh (L5,
455
+ // real-test 2026-10-07): refreshIfStale() did a SYNC full rebuild
456
+ // between paints (and a sync build for missing entries), blocking the
457
+ // event loop mid-render; scheduleRefresh keeps the entry populated and
458
+ // rebuilds off the tick. A no-runId tick does nothing (renderTick
459
+ // itself repaints; the cache's own run:state/worker:lifecycle
460
+ // subscription refreshes affected runs).
461
+ if (!runId) return;
462
+ try {
463
+ ctx.getRunSnapshotCache(extensionCtx.cwd).scheduleRefresh(runId);
464
+ } catch (error) {
465
+ logInternalError("register.renderScheduler.refresh", error, runId);
466
+ }
467
+ },
468
+ });
469
+ // Fix D: bridge internal runEventBus events to renderScheduler so the UI
470
+ // re-renders within debounceMs of any agent lifecycle event.
471
+ const sched = ctx.renderScheduler;
472
+ const unsubscribeRunEvents = runEventBus.onAny((event) => {
473
+ // bug-026 sub-issue C: evict terminal runs from the preloaded manifest
474
+ // frame so a stale "running" entry cannot persist for the session
475
+ // lifetime. Additive to the renderTick GATE 1 / FIX #1 / GATE 2
476
+ // snapshot refreshes (which rebuild the snapshot cache entries in
477
+ // place) — those gates do not touch lastPreloadedManifests itself.
478
+ lastPreloadedManifests = applyTerminalRunEventToManifests(lastPreloadedManifests, event);
479
+ sched.schedule({
480
+ runId: event.runId,
481
+ source: "runEventBus",
482
+ type: event.type,
483
+ });
484
+ });
485
+ ctx.renderSchedulerUnsubscribers.push(unsubscribeRunEvents);
486
+ startPreloadLoop(fallbackMs, effectiveRefreshMs);
487
+
488
+ // Bounded run watcher setup (pts/2 hang fix 2026-06-16).
489
+ const crewRunWatcherOnChange = (runId: string): void => {
490
+ if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
491
+ // FLICKER FIX: rebuild-in-place instead of deleting the entry (see
492
+ // onRunChange above). A hard delete left `get()` returning undefined for
493
+ // a frame, dropping the run to "(loading…)" and causing visible flicker.
494
+ // PERF (2026-08-24): coalesced ASYNC refresh — fs.watch can fire many
495
+ // times per second and the sync rebuild blocked the UI event loop; the
496
+ // render schedule below repaints while the rebuild lands.
497
+ try {
498
+ ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).scheduleRefresh(runId);
499
+ } catch (error) {
500
+ logInternalError("register.crewRunWatcher.refresh", error, runId);
501
+ }
502
+ ctx.renderScheduler?.schedule({ runId });
503
+ };
504
+ const crewRunWatcherOnError = (error: unknown): void => {
505
+ logInternalError("register.crewRunWatchers.error", error);
506
+ };
507
+ try {
508
+ ctx.crewRunWatchers?.closeAll();
509
+ ctx.crewRunWatchers = undefined;
510
+ const crewRunsDir = path.join(projectCrewRoot(extensionCtx.cwd), "state", "runs");
511
+ if (fs.existsSync(crewRunsDir)) {
512
+ ctx.crewRunWatchers = new RunWatcherRegistry();
513
+ ctx.crewRunWatchers.setRootWatcher(crewRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
514
+ }
515
+ } catch (error) {
516
+ logInternalError("register.crewRunWatchers.start", error);
517
+ }
518
+ try {
519
+ ctx.userCrewWatchers?.closeAll();
520
+ ctx.userCrewWatchers = undefined;
521
+ const userRunsDir = path.join(userCrewRoot(), "state", "runs");
522
+ if (fs.existsSync(userRunsDir)) {
523
+ ctx.userCrewWatchers = new RunWatcherRegistry();
524
+ ctx.userCrewWatchers.setRootWatcher(userRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
525
+ }
526
+ } catch (error) {
527
+ logInternalError("register.userCrewWatchers.start", error);
528
+ }
529
+ // Kick an immediate preload so the first buildFrame reconciles per-run
530
+ // watchers for any runs that are already active on session start.
531
+ backgroundPreload();
532
+ }
@@ -865,6 +865,9 @@ export async function handleDoctor(ctx: TeamContext, params: TeamToolParamsValue
865
865
  // cleanup only closes multiplexer panes (zombie workers' panes + terminal
866
866
  // runs' leaked panes) through the provider, gated on detect(). The user's
867
867
  // main session never carries PI_CREW_KIND, so it can never appear here.
868
+ // R3-16: the scan ALSO reports "foreign" pi processes (AI_AGENT=pi /
869
+ // PI_CODING_AGENT=true CLI markers + pi-looking argv) — WARN listing only,
870
+ // never kill candidates, never fed to pane cleanup.
868
871
  if (params.focus === "zombies") {
869
872
  const scan = scanZombieSubagents();
870
873
  const cleanup = await cleanupOrphanSurfacePanes({ cwd: ctx.cwd, scan });
@@ -877,6 +880,9 @@ export async function handleDoctor(ctx: TeamContext, params: TeamToolParamsValue
877
880
  data: {
878
881
  zombies: scan.zombies.length,
879
882
  live: scan.live.length,
883
+ // R3-16: foreign pi processes — WARN-only report count (never killed,
884
+ // never pane-cleaned; see zombie-scanner.ts discriminator notes).
885
+ foreignPi: scan.foreign.length,
880
886
  errors: scan.errors.length,
881
887
  orphanPanes: cleanup.orphans.length,
882
888
  panesClosed: cleanup.closed.length,
@@ -35,7 +35,10 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
35
35
  "ui.showModel": true,
36
36
  "ui.showTokens": true,
37
37
  "ui.showTools": true,
38
- "ui.dashboardPlacement": "center",
38
+ // L3 (2026-10-07): mirrors DEFAULT_UI.dashboardPlacement = "right"
39
+ // (defaults.ts) — G17: duplicated EFFECTIVE_DEFAULTS maps must track the
40
+ // canonical default or the settings overlay shows a stale value.
41
+ "ui.dashboardPlacement": "right",
39
42
  "ui.dashboardWidth": 72,
40
43
  "ui.autoOpenDashboard": false,
41
44
  // G17 sync (2026-09-10 review): canonical DEFAULT_UI.widgetPlacement =
@@ -270,6 +270,10 @@ export function prepareSpawnContext(
270
270
  agent: input.agent,
271
271
  model: input.model,
272
272
  sessionEnabled: true,
273
+ // R3-15: human-facing `--name crew-<taskId>` session label (agentId IS the
274
+ // task id; anonymous spawns without one simply get no name — same
275
+ // optional-shape as sessionId/sessionDir above).
276
+ taskId: input.agentId,
273
277
  // W2 (P1-1): deterministic session identity so crash-path tail-recovery
274
278
  // can find the worker's session JSONL (see session-recovery.ts). Falls
275
279
  // back to child-pi.ts deriveSessionPaths when unset (idempotent both ways).
@@ -281,6 +285,9 @@ export function prepareSpawnContext(
281
285
  thinkingOverride: input.thinkingOverride,
282
286
  systemPromptAppend: input.systemPromptAppend,
283
287
  hermeticWorkers: input.hermeticWorkers,
288
+ // U9: version-gated flags (--no-mcp floor) — threaded by runChildPi's
289
+ // single-flight probe; undefined/null = unknown → builder emits nothing.
290
+ piVersion: input.piVersion,
284
291
  // ADR-5 §3: depthOverride is pre-encoded into depthEnv by runChildPi
285
292
  // (parent's record depth as base-env PI_CREW_DEPTH); forward it so the
286
293
  // child env gets parentDepth+1 = the true grandchild depth.
@@ -53,9 +53,10 @@ export { buildChildPiSpawnOptions, buildFinalChildPiSpawnOptions } from "./child
53
53
  // ── Re-export from child-pi-streams.ts (H-7 decomposition step 4) ──
54
54
  export { ChildPiLineObserver } from "./child-pi-streams.ts";
55
55
 
56
- import { checkCrewDepth, cleanupTempDir } from "../model/pi-args.ts";
56
+ import { checkCrewDepth, cleanupTempDir, resolveHermeticWorkers } from "../model/pi-args.ts";
57
57
  import { attachPostExitStdioGuard, trySignalChild } from "../process/post-exit-stdio-guard.ts";
58
58
  import { classifyProcessCrash } from "../recovery/crash-classification.ts";
59
+ import { probePiVersion } from "./pi-version.ts";
59
60
 
60
61
  /** Maximum size (bytes) for the ChildPiLineObserver's line accumulation buffer.
61
62
  * When exceeded, the buffer is force-flushed to prevent unbounded memory growth
@@ -226,6 +227,15 @@ export interface ChildPiRunInput {
226
227
  * buildPiWorkerArgs (--no-extensions). Env PI_CREW_HERMETIC_WORKERS
227
228
  * overrides either way; default TRUE. */
228
229
  hermeticWorkers?: boolean;
230
+ /** U9 (pi 1.0.4 version floor): host pi binary version for the argv
231
+ * builder's version-gated flags (--no-mcp at the 1.0.4 floor). runChildPi
232
+ * OVERWRITES this with the single-flight probePiVersion() result whenever
233
+ * this spawn can consume a version-gated flag (parity non-hermetic + not
234
+ * pre-aborted); on hermetic spawns (the default — no version-gated flag
235
+ * consulted) the field passes through untouched and the probe is SKIPPED,
236
+ * keeping default-path spawns (and unit-test surface paths) free of a real
237
+ * `pi --version` child. See child-pi/pi-version.ts for the floor registry. */
238
+ piVersion?: string | null;
229
239
  /** Root directory for artifacts (used to validate transcriptPath). */
230
240
  artifactsRoot?: string;
231
241
  /** I5: run events JSONL path — threaded to the worker so its scratchpad
@@ -312,6 +322,19 @@ export interface ChildPiRunResult {
312
322
  exitStatus?: WorkerExitStatus;
313
323
  /** True if the agent was hard-aborted (max_turns + grace exceeded). */
314
324
  aborted?: boolean;
325
+ /**
326
+ * DR5 (RPC double-execution guard, deep-review 2026-10-05 §2): set by the
327
+ * RPC transport when a session event (message_start/message_end/…) was
328
+ * observed after the prompt was sent but the turn never settled — i.e.
329
+ * the agent turn HAD begun when the transport failed, even though no
330
+ * assistant text was captured. `isEarlyRpcTransportFailure` treats this
331
+ * as non-retryable: the turn may already have performed side effects, and
332
+ * double-executing a task that already produced work is worse than
333
+ * surfacing the transport error. An explicit result field (not an
334
+ * error-string marker) so the contract is testable and greppable; the
335
+ * stdio transport never sets it.
336
+ */
337
+ rpcAgentStarted?: boolean;
315
338
  /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
316
339
  steered?: boolean;
317
340
  /** #7 hardening: bounded digest of intermediate findings (last N tool results or
@@ -728,7 +751,19 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
728
751
  brokerSpawn = undefined;
729
752
  }
730
753
  }
731
- const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...input, brokerSpawn } : input, effectiveTask, depthEnv);
754
+ // U9 (pi 1.0.4 version floor): resolve the host pi binary's version ONCE per
755
+ // process (probePiVersion — single-flight memoized, injectable exec seam,
756
+ // never rejects) and thread it into the argv builder via input. Probed ONLY
757
+ // when this spawn can consume a version-gated flag: the parity
758
+ // (non-hermetic) argv applies the --no-mcp floor at 1.0.4, while hermetic
759
+ // spawns (the default) are already MCP-clean via --no-extensions and consult
760
+ // no version-gated flag — skipping the probe there keeps every default-path
761
+ // spawn — and the unit-test surface paths — free of a real `pi --version`
762
+ // child. An already-aborted signal skips too: prepareSpawnContext's B5
763
+ // guard below returns before the argv is ever consumed.
764
+ const versionedInput: ChildPiRunInput =
765
+ !input.signal?.aborted && !resolveHermeticWorkers(input.hermeticWorkers) ? { ...input, piVersion: await probePiVersion() } : input;
766
+ const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...versionedInput, brokerSpawn } : versionedInput, effectiveTask, depthEnv);
732
767
  if (spawnPrep.kind === "aborted") return spawnPrep.result;
733
768
  const { spawnSpec, mergedEnv, tempDir, builtEnv, builtArgs } = spawnPrep.ctx;
734
769
  // W2 (session-file recovery): resolve the worker's deterministic session