pi-crew 0.9.41 → 0.9.44

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 (170) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +14 -0
  3. package/dist/build-meta.json +6450 -12419
  4. package/dist/index.mjs +57998 -68417
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/migration/atomic-write-v2-migration.md +2 -0
  7. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  8. package/package.json +4 -2
  9. package/scripts/README.md +6 -0
  10. package/scripts/build-bundle.mjs +2 -1
  11. package/scripts/check-all-skills.ts +3 -3
  12. package/scripts/dev-runner.mjs +106 -0
  13. package/scripts/postinstall.mjs +6 -2
  14. package/scripts/verify-skill.ts +2 -2
  15. package/src/agents/agent-config.ts +1 -1
  16. package/src/config/config.ts +20 -1
  17. package/src/config/drift-detector.ts +97 -15
  18. package/src/config/resilient-parser.ts +4 -4
  19. package/src/config/types.ts +0 -2
  20. package/src/extension/crew-cleanup.ts +17 -3
  21. package/src/extension/crew-vibes/config.ts +1 -1
  22. package/src/extension/crew-vibes/font-detect.ts +16 -3
  23. package/src/extension/crew-vibes/index.ts +1 -1
  24. package/src/extension/cross-extension-rpc.ts +26 -9
  25. package/src/extension/management.ts +6 -5
  26. package/src/extension/register.ts +58 -1573
  27. package/src/extension/registration/command-registration.ts +57 -0
  28. package/src/extension/registration/commands.ts +7 -2
  29. package/src/extension/registration/context-builder.ts +131 -0
  30. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  31. package/src/extension/registration/foreground-run-controller.ts +238 -0
  32. package/src/extension/registration/hook-registration.ts +119 -0
  33. package/src/extension/registration/lazy-configurers.ts +124 -0
  34. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  35. package/src/extension/registration/observability.ts +33 -4
  36. package/src/extension/registration/registration-types.ts +149 -0
  37. package/src/extension/registration/runtime-cleanup.ts +230 -0
  38. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  39. package/src/extension/registration/tool-registration.ts +50 -0
  40. package/src/extension/registration/wire-cross-extension.ts +37 -0
  41. package/src/extension/rpc-hmac.ts +21 -1
  42. package/src/extension/run-import.ts +3 -3
  43. package/src/extension/team-tool/auto-summarize.ts +4 -4
  44. package/src/extension/team-tool/cancel.ts +5 -5
  45. package/src/extension/team-tool/chain-executor.ts +5 -4
  46. package/src/extension/team-tool/context.ts +11 -1
  47. package/src/extension/team-tool/doctor.ts +2 -1
  48. package/src/extension/team-tool/goal-wrap.ts +2 -2
  49. package/src/extension/team-tool/goal.ts +1 -1
  50. package/src/extension/team-tool/handle-schedule.ts +25 -36
  51. package/src/extension/team-tool/orchestrate.ts +0 -1
  52. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  53. package/src/extension/team-tool/run.ts +220 -214
  54. package/src/extension/team-tool/workflow-manage.ts +1 -1
  55. package/src/extension/team-tool.ts +69 -16
  56. package/src/hooks/registry.ts +7 -2
  57. package/src/observability/correlation.ts +4 -3
  58. package/src/observability/event-bus.ts +10 -1
  59. package/src/observability/event-to-metric.ts +8 -7
  60. package/src/observability/exporters/adapter.ts +6 -9
  61. package/src/observability/exporters/otlp-exporter.ts +81 -0
  62. package/src/observability/metric-retention.ts +4 -0
  63. package/src/observability/metric-sink.ts +14 -4
  64. package/src/prompt/prompt-runtime.ts +15 -0
  65. package/src/runtime/adaptive-plan.ts +1 -1
  66. package/src/runtime/async-runner.ts +13 -4
  67. package/src/runtime/background-runner.ts +32 -23
  68. package/src/runtime/capability-inventory.ts +1 -1
  69. package/src/runtime/chain-runner.ts +5 -5
  70. package/src/runtime/checkpoint.ts +6 -16
  71. package/src/runtime/child-pi-constants.ts +42 -0
  72. package/src/runtime/child-pi-kill.ts +180 -0
  73. package/src/runtime/child-pi-spawn.ts +234 -0
  74. package/src/runtime/child-pi-steering.ts +128 -0
  75. package/src/runtime/child-pi-streams.ts +296 -0
  76. package/src/runtime/child-pi-transcript.ts +169 -0
  77. package/src/runtime/child-pi.ts +80 -822
  78. package/src/runtime/coalesce-tasks.ts +1 -1
  79. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  80. package/src/runtime/crash-recovery.ts +63 -47
  81. package/src/runtime/delivery-coordinator.ts +1 -1
  82. package/src/runtime/deterministic-ast.ts +2 -2
  83. package/src/runtime/diagnostic-export.ts +2 -1
  84. package/src/runtime/dwf-state-store.ts +5 -0
  85. package/src/runtime/dynamic-workflow-context.ts +78 -18
  86. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  87. package/src/runtime/foreground-control.ts +3 -2
  88. package/src/runtime/goal-evaluator.ts +59 -27
  89. package/src/runtime/goal-loop-runner.ts +46 -13
  90. package/src/runtime/goal-state-store.ts +1 -1
  91. package/src/runtime/handoff-manager.ts +0 -2
  92. package/src/runtime/intercom-bridge.ts +0 -2
  93. package/src/runtime/live-agent-manager.ts +30 -5
  94. package/src/runtime/live-extension-bridge.ts +0 -2
  95. package/src/runtime/live-session-runtime.ts +65 -57
  96. package/src/runtime/manifest-cache.ts +59 -0
  97. package/src/runtime/mcp-proxy.ts +1 -1
  98. package/src/runtime/orphan-worker-registry.ts +3 -3
  99. package/src/runtime/output-validator.ts +1 -1
  100. package/src/runtime/path-overlap.ts +1 -1
  101. package/src/runtime/pi-args.ts +3 -3
  102. package/src/runtime/pi-json-output.ts +4 -3
  103. package/src/runtime/pi-spawn.ts +7 -1
  104. package/src/runtime/pipeline-runner.ts +1 -4
  105. package/src/runtime/post-checks.ts +0 -1
  106. package/src/runtime/retry-runner.ts +1 -1
  107. package/src/runtime/run-coalesced-task-group.ts +18 -23
  108. package/src/runtime/scheduler.ts +2 -0
  109. package/src/runtime/session-resources.ts +1 -1
  110. package/src/runtime/settings-store.ts +31 -1
  111. package/src/runtime/skill-effectiveness.ts +2 -2
  112. package/src/runtime/stale-reconciler.ts +23 -53
  113. package/src/runtime/subagent-manager.ts +19 -3
  114. package/src/runtime/task-output-context.ts +2 -1
  115. package/src/runtime/task-runner/prompt-builder.ts +3 -3
  116. package/src/runtime/task-runner/run-projection.ts +3 -1
  117. package/src/runtime/task-runner/state-helpers.ts +13 -1
  118. package/src/runtime/task-runner.ts +31 -6
  119. package/src/runtime/team-runner.ts +8 -2
  120. package/src/runtime/verification-gates.ts +54 -5
  121. package/src/schema/config-schema.ts +19 -1
  122. package/src/schema/team-tool-schema.ts +2 -0
  123. package/src/skills/discover-skills.ts +25 -15
  124. package/src/state/active-run-registry.ts +0 -1
  125. package/src/state/atomic-write.ts +37 -11
  126. package/src/state/blob-store.ts +3 -9
  127. package/src/state/contracts.ts +5 -1
  128. package/src/state/crew-init.ts +3 -2
  129. package/src/state/decision-ledger.ts +2 -2
  130. package/src/state/event-log-rotation.ts +1 -1
  131. package/src/state/event-log.ts +4 -3
  132. package/src/state/gitignore-manager.ts +2 -1
  133. package/src/state/health-store.ts +5 -2
  134. package/src/state/instinct-store.ts +11 -2
  135. package/src/state/locks.ts +132 -49
  136. package/src/state/mailbox.ts +3 -4
  137. package/src/state/observation-store.ts +1 -13
  138. package/src/state/run-cache.ts +12 -12
  139. package/src/state/run-graph.ts +2 -1
  140. package/src/state/run-metrics.ts +1 -1
  141. package/src/state/schedule.ts +7 -1
  142. package/src/state/state-store.ts +9 -3
  143. package/src/state/tiered-eval.ts +1 -1
  144. package/src/state/worker-atomic-writer.ts +1 -0
  145. package/src/ui/dynamic-border.ts +13 -3
  146. package/src/ui/live-conversation-overlay.ts +7 -27
  147. package/src/ui/live-duration.ts +2 -2
  148. package/src/ui/live-run-sidebar.ts +56 -32
  149. package/src/ui/mascot.ts +19 -13
  150. package/src/ui/powerbar-publisher.ts +244 -178
  151. package/src/ui/render-coalescer.ts +44 -3
  152. package/src/ui/render-diff.ts +2 -2
  153. package/src/ui/run-dashboard.ts +122 -21
  154. package/src/ui/run-event-bus.ts +66 -0
  155. package/src/ui/run-snapshot-cache.ts +84 -81
  156. package/src/ui/settings-overlay.ts +3 -3
  157. package/src/ui/status-colors.ts +24 -0
  158. package/src/ui/tool-progress-formatter.ts +1 -1
  159. package/src/ui/tool-render.ts +3 -3
  160. package/src/ui/transcript-cache.ts +5 -10
  161. package/src/ui/transcript-entries.ts +68 -72
  162. package/src/ui/transcript-viewer.ts +54 -14
  163. package/src/ui/widget/index.ts +26 -15
  164. package/src/ui/widget/widget-model.ts +18 -9
  165. package/src/ui/widget/widget-renderer.ts +2 -5
  166. package/src/utils/fingerprint.ts +1 -1
  167. package/src/utils/paths.ts +1 -1
  168. package/src/utils/project-detector.ts +1 -1
  169. package/src/worktree/worktree-manager.ts +47 -19
  170. package/src/state/atomic-write-v2.ts +0 -108
@@ -0,0 +1,731 @@
1
+ /**
2
+ * Lifecycle handler installer for pi-crew.
3
+ *
4
+ * Owns:
5
+ * • `session_start` — heavy setup (config, scheduler, render loop, watchers,
6
+ * deferred crash recovery). This is the bulk of the orchestrator's
7
+ * per-session work, extracted here so `register.ts` stays thin.
8
+ * • `session_shutdown` — reason-aware cleanup (quit/reload aborts
9
+ * foreground runs; resume/new/fork preserves them).
10
+ * • `session_before_switch` — graceful session switch handoff.
11
+ *
12
+ * Imports here are kept top-level (non-lazy) on purpose: this module IS
13
+ * where the heavy work happens, so there is no cold-start benefit to
14
+ * deferring it. The session_start handler internally uses lazy imports
15
+ * for its own per-call optional work (foreground-watchdog, atomic-write).
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { loadConfig } from "../../config/config.ts";
21
+ import { DEFAULT_UI } from "../../config/defaults.ts";
22
+ import { pruneFinishedRuns, pruneUserLevelRuns } from "../../extension/run-maintenance.ts";
23
+ import { reconcileAllStaleRuns } from "../../runtime/crash-recovery.ts";
24
+ import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
25
+ import type { createManifestCache } from "../../runtime/manifest-cache.ts";
26
+ import { cleanupOrphanWorkers } from "../../runtime/orphan-worker-registry.ts";
27
+ import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs } from "../../runtime/pi-args.ts";
28
+ import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduler.ts";
29
+ import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
30
+ import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
31
+ import { applyCrewSettingsToConfig, loadCrewSettings } from "../../runtime/settings-store.ts";
32
+ import { loadRunManifestById } from "../../state/state-store.ts";
33
+ import type { TeamRunManifest } from "../../state/types.ts";
34
+ import { terminateActiveChildPiProcesses } from "../../subagents/spawn.ts";
35
+ import { summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
36
+ import { requestRender, setExtensionWidget } from "../../ui/pi-ui-compat.ts";
37
+ import {
38
+ registerPiCrewPowerbarSegments,
39
+ requestPowerbarUpdate,
40
+ resetPowerbarDedupState,
41
+ updatePiCrewPowerbar,
42
+ } from "../../ui/powerbar-publisher.ts";
43
+ import { RenderScheduler } from "../../ui/render-scheduler.ts";
44
+ import { runEventBus } from "../../ui/run-event-bus.ts";
45
+ import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
46
+ import { updateCrewWidget } from "../../ui/widget/index.ts";
47
+ import { logInternalError } from "../../utils/internal-error.ts";
48
+ import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
49
+ import { RunWatcherRegistry } from "../../utils/run-watcher-registry.ts";
50
+ import { extractSessionId } from "../../utils/session-utils.ts";
51
+ import { startAsyncRunNotifier, stopAsyncRunNotifier } from "../async-notifier.ts";
52
+ import { registerCrewAutocomplete } from "../crew-autocomplete.ts";
53
+ import { notifyActiveRuns } from "../session-summary.ts";
54
+ import { persistScheduledJobUpdate } from "../team-tool/handle-schedule.ts";
55
+ import { handleTeamTool } from "../team-tool.ts";
56
+ import { runArtifactCleanup } from "./artifact-cleanup.ts";
57
+ import type { RegistrationContext } from "./registration-types.ts";
58
+
59
+ /**
60
+ * Register all session-lifecycle handlers on the ExtensionAPI. The caller
61
+ * (`register.ts`) must have already wired the orchestrator-side cleanup
62
+ * functions into `ctx.cleanupRuntime` and `ctx.cleanupSessionResourcesOnly`.
63
+ */
64
+ export function installSessionLifecycleHandlers(pi: ExtensionAPI, ctx: RegistrationContext): void {
65
+ installSessionShutdownHandler(pi, ctx);
66
+ installSessionStartHandler(pi, ctx);
67
+ installSessionBeforeSwitchHandler(pi, ctx);
68
+ }
69
+
70
+ /**
71
+ * session_shutdown:
72
+ * • reason="quit" / "reload" → full cleanup (abort foreground runs).
73
+ * • reason="resume" / "new" / "fork" → resource cleanup only (preserve
74
+ * foreground runs; they share the process with the session).
75
+ */
76
+ function installSessionShutdownHandler(pi: ExtensionAPI, ctx: RegistrationContext): void {
77
+ pi.on("session_shutdown", (event) => {
78
+ const reason = typeof event === "object" && event !== null && "reason" in event ? (event as { reason: string }).reason : undefined;
79
+ if (reason === "quit" || reason === "reload") {
80
+ // Actual shutdown — abort foreground runs and cleanup everything
81
+ ctx.cleanupRuntime();
82
+ } else {
83
+ // Session switch (resume/new/fork) — cleanup resources but preserve foreground runs
84
+ ctx.cleanupSessionResourcesOnly();
85
+ }
86
+ });
87
+ }
88
+
89
+ /**
90
+ * session_before_switch:
91
+ * Bump generation, deactivate delivery coordinator, stop async notifier,
92
+ * abort session-bound subagents. Foreground team runs are NOT aborted here.
93
+ */
94
+ function installSessionBeforeSwitchHandler(pi: ExtensionAPI, ctx: RegistrationContext): void {
95
+ pi.on("session_before_switch", () => {
96
+ ctx.sessionGeneration++;
97
+ const pendingCount = ctx.lifecycleState.deliveryCoordinator?.getPendingCount() ?? 0;
98
+ try {
99
+ const activeRuns = ctx.currentCtx
100
+ ? ctx
101
+ .getManifestCache(ctx.currentCtx.cwd)
102
+ .list(50)
103
+ .filter((run) => run.status === "running" || run.status === "queued" || run.status === "blocked")
104
+ : [];
105
+ const snapshot = createSessionSnapshot(activeRuns, pendingCount, ctx.sessionGeneration);
106
+ if (pendingCount > 0 || snapshot.activeRunIds.length > 0)
107
+ logInternalError("register.session-before-switch", undefined, JSON.stringify(snapshot));
108
+ } catch (error) {
109
+ logInternalError("register.session-before-switch.snapshot", error);
110
+ }
111
+ if (pendingCount > 0) {
112
+ logInternalError("register.session-before-switch", `Switching session with ${pendingCount} pending deliveries`);
113
+ }
114
+ ctx.lifecycleState.deliveryCoordinator?.deactivate();
115
+ resetPowerbarDedupState();
116
+ stopAsyncRunNotifier(ctx.notifierState);
117
+ ctx.stopSessionBoundSubagents();
118
+ });
119
+ }
120
+
121
+ /**
122
+ * session_start — the bulk of pi-crew's per-session work.
123
+ *
124
+ * Pipeline:
125
+ * 1. Resolve session metadata + restore brief mode (best-effort).
126
+ * 2. Bump generation, set currentCtx, register autocomplete (once).
127
+ * 3. Schedule deferred crash recovery (orphan cleanup, stale-reconcile,
128
+ * auto-prune). MUST run in setTimeout(0) — these block 100ms-1s on
129
+ * Windows and cannot stall the session_start event.
130
+ * 4. Synchronously: load config + crew settings, start CrewScheduler,
131
+ * configure notifications/observability/delivery-coordinator,
132
+ * register Pi-side powerbar segments, start async notifier,
133
+ * kick off the render scheduler + preload loop + bounded watchers.
134
+ */
135
+ function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext): void {
136
+ pi.on("session_start", (_event, extensionCtx) => {
137
+ runArtifactCleanup(extensionCtx.cwd);
138
+
139
+ // Restore brief mode state from session entries (best-effort).
140
+ try {
141
+ const entries = extensionCtx.sessionManager?.getEntries?.();
142
+ if (entries) {
143
+ // LAZY: brief-mode is only used inside the session-start restore path.
144
+ import("../../ui/tool-renderers/brief-mode.ts")
145
+ .then(({ restoreBriefState }) => {
146
+ restoreBriefState(entries);
147
+ })
148
+ .catch(() => {
149
+ /* non-critical */
150
+ });
151
+ }
152
+ } catch {
153
+ /* non-critical */
154
+ }
155
+
156
+ ctx.cleanedUp = false;
157
+ ctx.sessionGeneration++;
158
+ const ownerGeneration = ctx.sessionGeneration;
159
+ ctx.currentCtx = extensionCtx;
160
+ // Round 13 UX: register the crew natural-language autocomplete provider
161
+ // once we have a UI context. Guarded so repeated session_start events
162
+ // don't stack wrappers (each wrapper delegates, but stacking wastes
163
+ // call depth).
164
+ if (!ctx.crewAutocompleteRegistered) {
165
+ ctx.crewAutocompleteRegistered = true;
166
+ registerCrewAutocomplete(extensionCtx);
167
+ }
168
+ if (ctx.widgetState.interval) clearInterval(ctx.widgetState.interval);
169
+ ctx.widgetState.interval = undefined;
170
+ notifyActiveRuns(extensionCtx);
171
+
172
+ const currentSessionId = extractSessionId(extensionCtx);
173
+
174
+ // Defer ALL heavy cleanup to after the session_start handler returns.
175
+ // These operations involve synchronous directory scanning (readdirSync, readFileSync)
176
+ // which can take 100ms–1s+ on Windows. They MUST NOT block the session_start event.
177
+ setTimeout(() => {
178
+ void runDeferredSessionCleanup(pi, ctx, ownerGeneration, currentSessionId, extensionCtx);
179
+ }, 0);
180
+
181
+ const loadedConfig = loadConfig(extensionCtx.cwd);
182
+ const crewSettings = loadCrewSettings(extensionCtx.cwd);
183
+ applyCrewSettingsToConfig(loadedConfig.config, crewSettings);
184
+
185
+ // Start scheduler with event-based executor
186
+ const sessionId =
187
+ extensionCtx.sessionManager?.getSessionId?.() ??
188
+ (typeof extensionCtx === "object" && extensionCtx !== null && "sessionId" in extensionCtx
189
+ ? (extensionCtx as Record<string, unknown>).sessionId
190
+ : undefined);
191
+ ctx.crewScheduler = setupCrewScheduler(pi, ctx, extensionCtx, sessionId);
192
+
193
+ // Wire scheduler into handle-schedule.ts so handlers can add/list jobs.
194
+ // Uses a global symbol so the module doesn't need a direct circular import.
195
+ (globalThis as Record<symbol | string, unknown>)[Symbol.for("pi-crew:scheduler")] = ctx.crewScheduler;
196
+ // Load scheduled jobs from settings if present
197
+ if (Array.isArray(crewSettings.scheduledJobs)) {
198
+ for (const job of crewSettings.scheduledJobs) {
199
+ try {
200
+ ctx.crewScheduler.add(job as ScheduledJob);
201
+ } catch {
202
+ /* skip invalid */
203
+ }
204
+ }
205
+ }
206
+ ctx.autoRecoveryLast.clear();
207
+ ctx.configureNotifications(extensionCtx);
208
+ ctx.configureObservability(extensionCtx);
209
+ ctx.configureDeliveryCoordinator();
210
+ if (typeof sessionId === "string" && sessionId) ctx.lifecycleState.deliveryCoordinator?.activate(sessionId);
211
+ tryRegisterSessionCleanup(pi, () => {
212
+ terminateActiveChildPiProcesses();
213
+ ctx.cleanupRuntime();
214
+ });
215
+ registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
216
+ startAsyncRunNotifier(extensionCtx, ctx.notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs, {
217
+ generation: ownerGeneration,
218
+ isCurrent: (generation) => generation === ctx.sessionGeneration && ctx.currentCtx === extensionCtx && !ctx.cleanedUp,
219
+ });
220
+ const cache = ctx.getManifestCache(extensionCtx.cwd);
221
+ updateCrewWidget(extensionCtx, ctx.widgetState, loadedConfig.config.ui, cache, ctx.getRunSnapshotCache(extensionCtx.cwd));
222
+ updatePiCrewPowerbar(
223
+ pi.events,
224
+ extensionCtx.cwd,
225
+ loadedConfig.config.ui,
226
+ cache,
227
+ ctx.getRunSnapshotCache(extensionCtx.cwd),
228
+ extensionCtx,
229
+ ctx.widgetState.notificationCount ?? 0,
230
+ );
231
+ setupRenderLoop(pi, ctx, extensionCtx, loadedConfig);
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Heavy cleanup that runs after session_start returns.
237
+ *
238
+ * Wrapped in setTimeout(0) so the session_start event is not blocked by
239
+ * the synchronous I/O involved (readdirSync, readFileSync) — observed to
240
+ * take 100ms-1s+ on Windows with many runs on disk.
241
+ */
242
+ async function runDeferredSessionCleanup(
243
+ pi: ExtensionAPI,
244
+ ctx: RegistrationContext,
245
+ ownerGeneration: number,
246
+ currentSessionId: string | undefined,
247
+ extensionCtx: ExtensionContext,
248
+ ): Promise<void> {
249
+ if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return; // session switched while we waited
250
+
251
+ // 2.7: load crash-recovery lazily once per session_start cleanup batch.
252
+ let crashRecovery: Awaited<ReturnType<typeof ctx.importCrashRecovery>> | undefined;
253
+ try {
254
+ crashRecovery = await ctx.importCrashRecovery();
255
+ } catch (error) {
256
+ logInternalError("register.sessionStart.lazyCrashRecovery", error);
257
+ return;
258
+ }
259
+ if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
260
+ const { cancelOrphanedRuns: cancelOrphanedRunsFn, purgeStaleActiveRunIndex: purgeStaleActiveRunIndexFn } = crashRecovery;
261
+
262
+ // Auto-cancel orphaned runs
263
+ if (currentSessionId) {
264
+ try {
265
+ const { cancelled } = (
266
+ cancelOrphanedRunsFn as (
267
+ cwd: string,
268
+ cache: ReturnType<typeof createManifestCache>,
269
+ sessionId: string,
270
+ ) => { cancelled: string[] }
271
+ )(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd), currentSessionId);
272
+ if (cancelled.length > 0) {
273
+ ctx.notifyOperator({
274
+ id: `orphan_cleanup`,
275
+ severity: "info",
276
+ source: "crash-recovery",
277
+ title: `Cleaned up ${cancelled.length} orphaned run(s)`,
278
+ body: `Runs from previous sessions were auto-cancelled: ${cancelled.join(", ")}`,
279
+ });
280
+ }
281
+ } catch (error) {
282
+ logInternalError("register.sessionStart.orphanCleanup", error);
283
+ }
284
+ }
285
+
286
+ // Startup cleanup (Fix A): run orphan-temp-dir cleanup
287
+ try {
288
+ const orphanTmp = cleanupOrphanTempDirs();
289
+ const legacyTmp = cleanupLegacyOrphanTempDirs();
290
+ if (orphanTmp.cleaned > 0 || legacyTmp.cleaned > 0) {
291
+ ctx.notifyOperator({
292
+ id: `startup_temp_cleanup_${Date.now()}`,
293
+ severity: "info",
294
+ source: "temp-cleanup",
295
+ title: `Startup cleanup: removed ${orphanTmp.cleaned + legacyTmp.cleaned} orphan temp dir(s)`,
296
+ body: `${orphanTmp.cleaned} from ~/.pi/agent/pi-crew/tmp/ + ${legacyTmp.cleaned} legacy /tmp/pi-crew-*`,
297
+ });
298
+ }
299
+ } catch (error) {
300
+ logInternalError("register.sessionStart.startupTempCleanup", error);
301
+ }
302
+
303
+ // Orphan worker cleanup (Fix B): kill stale background-runner processes
304
+ try {
305
+ const orphanWorkers = cleanupOrphanWorkers(currentSessionId);
306
+ if (orphanWorkers.killed > 0) {
307
+ ctx.notifyOperator({
308
+ id: `orphan_workers_cleanup`,
309
+ severity: "info",
310
+ source: "worker-cleanup",
311
+ title: `Cleaned up ${orphanWorkers.killed} orphan worker(s)`,
312
+ body: `Background workers from previous (SIGKILL'd) sessions were terminated (pruned ${orphanWorkers.pruned} dead, kept ${orphanWorkers.kept}).`,
313
+ });
314
+ }
315
+ } catch (error) {
316
+ logInternalError("register.sessionStart.orphanWorkers", error);
317
+ }
318
+
319
+ // Global purge of stale active-run-index entries
320
+ try {
321
+ const { purged } = purgeStaleActiveRunIndexFn();
322
+ if (purged.length > 0) {
323
+ ctx.notifyOperator({
324
+ id: `active_index_purge`,
325
+ severity: "info",
326
+ source: "crash-recovery",
327
+ title: `Purged ${purged.length} stale active-run-index entr${purged.length === 1 ? "y" : "ies"}`,
328
+ body: `Cleaned up global active run index`,
329
+ });
330
+ }
331
+ } catch (error) {
332
+ logInternalError("register.sessionStart.globalIndexPurge", error);
333
+ }
334
+
335
+ // Reconcile stale runs found on disk
336
+ try {
337
+ const staleResults = reconcileAllStaleRuns(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd)) ?? [];
338
+ if (staleResults.length > 0) {
339
+ ctx.notifyOperator({
340
+ id: "stale_reconcile",
341
+ severity: "info",
342
+ source: "crash-recovery",
343
+ title: `Reconciled ${staleResults.length} stale run(s)`,
344
+ body: `Found and repaired ghost runs from previous sessions: ${staleResults.map((r) => r.runId).join(", ")}`,
345
+ });
346
+ }
347
+ } catch (error) {
348
+ logInternalError("register.sessionStart.reconcileStale", error);
349
+ }
350
+
351
+ // Auto-prune finished project-level run directories
352
+ try {
353
+ const { removed } = pruneFinishedRuns(extensionCtx.cwd, 10);
354
+ if (removed.length > 0) {
355
+ ctx.notifyOperator({
356
+ id: `auto_prune_project`,
357
+ severity: "info",
358
+ source: "run-maintenance",
359
+ title: `Auto-pruned ${removed.length} finished project run(s)`,
360
+ body: `Removed old finished runs: ${removed.join(", ")}`,
361
+ });
362
+ }
363
+ } catch (error) {
364
+ logInternalError("register.sessionStart.autoPruneProject", error);
365
+ }
366
+
367
+ // Auto-prune finished user-level run directories
368
+ try {
369
+ const { removed } = pruneUserLevelRuns(10);
370
+ if (removed.length > 0) {
371
+ ctx.notifyOperator({
372
+ id: `auto_prune_user`,
373
+ severity: "info",
374
+ source: "run-maintenance",
375
+ title: `Auto-pruned ${removed.length} finished user-level run(s)`,
376
+ body: `Removed old finished runs: ${removed.join(", ")}`,
377
+ });
378
+ }
379
+ } catch (error) {
380
+ logInternalError("register.sessionStart.autoPruneUser", error);
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Build a CrewScheduler wired to the current session. The scheduler's
386
+ * executor closure invokes handleTeamTool lazily — the heavy team-tool
387
+ * import only fires when a scheduled job actually runs.
388
+ */
389
+ function setupCrewScheduler(
390
+ pi: ExtensionAPI,
391
+ ctx: RegistrationContext,
392
+ extensionCtx: ExtensionContext,
393
+ sessionId: string | undefined,
394
+ ): CrewScheduler {
395
+ const crewScheduler = new CrewScheduler();
396
+ crewScheduler.start({
397
+ emit: (event) => {
398
+ if (ctx.cleanedUp) return;
399
+ pi.events?.emit?.("crew-scheduler", event);
400
+ },
401
+ executor: (job) => {
402
+ let runParams: { action: string; team: string; goal: string };
403
+ try {
404
+ runParams = JSON.parse(job.prompt);
405
+ } catch {
406
+ runParams = {
407
+ action: "run",
408
+ team: "default",
409
+ goal: job.prompt,
410
+ };
411
+ }
412
+ if (runParams.action !== "run") return `scheduled-${job.id}-${Date.now()}`;
413
+ const agentId = `scheduled-${job.id}-${Date.now()}`;
414
+ setImmediate(async () => {
415
+ try {
416
+ const runResult = await handleTeamTool(
417
+ {
418
+ action: "run",
419
+ team: runParams.team,
420
+ goal: runParams.goal,
421
+ async: true,
422
+ },
423
+ { cwd: extensionCtx.cwd, sessionId },
424
+ );
425
+ const runId = runResult?.details?.runId;
426
+ if (runId && typeof runId === "string") {
427
+ crewScheduler?.recordSpawnedRun(job.id, runId);
428
+ // Update run manifest with scheduler provenance for traceability
429
+ try {
430
+ const cwd = extensionCtx.cwd ?? process.cwd();
431
+ const loaded = loadRunManifestById(cwd, runId);
432
+ if (loaded) {
433
+ // LAZY: defer dynamic import of atomic-write.ts to its call site.
434
+ const { atomicWriteJson } = await import("../../state/atomic-write.ts");
435
+ atomicWriteJson(loaded.manifest.stateRoot + "/manifest.json", {
436
+ ...loaded.manifest,
437
+ schedulerJobId: job.id,
438
+ schedulerName: job.name,
439
+ });
440
+ }
441
+ } catch {
442
+ /* best-effort provenance tracking */
443
+ }
444
+ }
445
+ try {
446
+ const updatedJob = crewScheduler?.list().find((j) => j.id === job.id);
447
+ if (updatedJob) persistScheduledJobUpdate(extensionCtx.cwd, updatedJob);
448
+ } catch {
449
+ /* best-effort */
450
+ }
451
+ crewScheduler?.update(job.id, {
452
+ runCount: job.runCount + 1,
453
+ lastRun: new Date().toISOString(),
454
+ lastStatus: "success",
455
+ });
456
+ } catch (err) {
457
+ logInternalError("scheduler.execute", err);
458
+ crewScheduler?.update(job.id, { lastStatus: "error" });
459
+ }
460
+ });
461
+ return agentId;
462
+ },
463
+ finalizer: () => {},
464
+ runCancelFn: (runId: string) => {
465
+ try {
466
+ handleTeamTool({ action: "cancel", runId, confirm: true }, { cwd: extensionCtx.cwd, sessionId }).catch((err) =>
467
+ logInternalError("scheduler.runCancelFn", err, `runId=${runId}`),
468
+ );
469
+ } catch (err) {
470
+ logInternalError("scheduler.runCancelFn.sync", err, `runId=${runId}`);
471
+ }
472
+ },
473
+ });
474
+ return crewScheduler;
475
+ }
476
+
477
+ /**
478
+ * Build the render scheduler + preload loop + bounded run watchers.
479
+ *
480
+ * Render path:
481
+ * - RenderScheduler fires renderTick() every `effectiveRefreshMs()`.
482
+ * - 160ms when live agents OR background runs are active (spinner-friendly),
483
+ * else the configured `dashboardLiveRefreshMs` (default DEFAULT_UI.refreshMs).
484
+ * - renderTick reads from a pre-computed frame (`lastPreloadedManifests`) —
485
+ * zero fs I/O on the hot path.
486
+ *
487
+ * Watchers:
488
+ * - pts/2 hang fix (2026-06-16): a SINGLE non-recursive watcher on the
489
+ * `runs/` root (new-run detection) plus per-active-run watchers
490
+ * reconciled each preload tick. Total inotify cost: O(active runs).
491
+ */
492
+ function setupRenderLoop(
493
+ pi: ExtensionAPI,
494
+ ctx: RegistrationContext,
495
+ extensionCtx: ExtensionContext,
496
+ loadedConfig: ReturnType<typeof loadConfig>,
497
+ ): void {
498
+ ctx.disposeRenderSchedulerSubscriptions();
499
+ ctx.renderScheduler?.dispose();
500
+ ctx.terminalStatus?.dispose();
501
+ ctx.terminalStatus = undefined;
502
+ ctx.terminalStatusActive = false;
503
+
504
+ // Phase 12: Async preloading — renderTick reads only a pre-computed frame.
505
+ let preloading = false;
506
+ let lastPreloadedConfig: ReturnType<typeof loadConfig> | undefined;
507
+ let lastPreloadedManifests: TeamRunManifest[] = [];
508
+ let lastFrameManifestCache: ReturnType<typeof createManifestCache> | undefined;
509
+ let lastFrameSnapshotCache: ReturnType<typeof createRunSnapshotCache> | undefined;
510
+
511
+ const ownerGeneration = ctx.sessionGeneration;
512
+
513
+ const buildFrame = async (): Promise<boolean> => {
514
+ if (!ctx.currentCtx) return false;
515
+ lastPreloadedConfig = loadConfig(ctx.currentCtx.cwd);
516
+ lastFrameManifestCache = ctx.getManifestCache(ctx.currentCtx.cwd);
517
+ lastFrameSnapshotCache = ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
518
+ const manifests = lastFrameManifestCache.list(20);
519
+ lastPreloadedManifests = manifests;
520
+ // pts/2 hang fix: reconcile per-run watchers against the ACTIVE set only.
521
+ {
522
+ const onRunChange = (runId: string): void => {
523
+ if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
524
+ ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).invalidate(runId);
525
+ ctx.renderScheduler?.schedule({ runId });
526
+ };
527
+ const onWatchErr = (error: unknown): void => {
528
+ logInternalError("register.runWatcher.change", error);
529
+ };
530
+ const active = manifests
531
+ .filter((r) => r.status === "running" || r.status === "queued" || r.status === "planning")
532
+ .map((r) => ({ runId: r.runId, runDir: r.stateRoot }));
533
+ ctx.crewRunWatchers?.reconcile(active, onRunChange, onWatchErr);
534
+ ctx.userCrewWatchers?.reconcile(active, onRunChange, onWatchErr);
535
+ }
536
+ const runIds = manifests.map((r) => r.runId);
537
+ await lastFrameSnapshotCache.preloadAllStale(runIds);
538
+ return true;
539
+ };
540
+
541
+ const backgroundPreload = (): void => {
542
+ if (!ctx.currentCtx || preloading) return;
543
+ preloading = true;
544
+ buildFrame()
545
+ .then((ok) => {
546
+ preloading = false;
547
+ if (ok) ctx.renderScheduler?.schedule();
548
+ })
549
+ .catch((error: unknown) => {
550
+ preloading = false;
551
+ logInternalError("register.backgroundPreload", error);
552
+ });
553
+ };
554
+
555
+ const startPreloadLoop = (intervalMs: number, dynamicMs?: () => number): void => {
556
+ if (ctx.preloadTimer) clearTimeout(ctx.preloadTimer);
557
+ const tick = (): void => {
558
+ backgroundPreload();
559
+ const nextMs = dynamicMs?.() ?? intervalMs;
560
+ ctx.preloadTimer = setTimeout(tick, nextMs);
561
+ ctx.preloadTimer.unref();
562
+ };
563
+ ctx.preloadTimer = setTimeout(tick, intervalMs);
564
+ ctx.preloadTimer.unref();
565
+ };
566
+
567
+ const renderTick = (): void => {
568
+ if (!ctx.currentCtx) return;
569
+ const config = lastPreloadedConfig?.config.ui;
570
+ const activeCache = lastFrameManifestCache ?? ctx.getManifestCache(ctx.currentCtx.cwd);
571
+ const snapshotCache = lastFrameSnapshotCache ?? ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
572
+ const manifests = lastPreloadedManifests;
573
+ if (!lastPreloadedConfig) backgroundPreload();
574
+ if (ctx.uiState.liveSidebarRunId) {
575
+ const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
576
+ if (ctx.widgetState.lastVisibility !== "hidden" || ctx.widgetState.lastPlacement !== placement) {
577
+ setExtensionWidget(ctx.currentCtx, "pi-crew", undefined, { placement });
578
+ setExtensionWidget(ctx.currentCtx, "pi-crew-active", undefined, { placement });
579
+ ctx.widgetState.lastVisibility = "hidden";
580
+ ctx.widgetState.lastPlacement = placement;
581
+ ctx.widgetState.lastKey = "pi-crew-active";
582
+ ctx.widgetState.model = undefined;
583
+ }
584
+ requestRender(ctx.currentCtx);
585
+ } else {
586
+ updateCrewWidget(ctx.currentCtx, ctx.widgetState, config, activeCache, snapshotCache, manifests);
587
+ }
588
+ requestPowerbarUpdate(
589
+ pi.events,
590
+ ctx.currentCtx.cwd,
591
+ config,
592
+ activeCache,
593
+ snapshotCache,
594
+ ctx.currentCtx,
595
+ ctx.widgetState.notificationCount ?? 0,
596
+ manifests,
597
+ );
598
+ // Health notifications: only warn about genuinely running runs.
599
+ const currentSessionGen = ctx.sessionGeneration;
600
+ const currentSessionId = ctx.currentCtx ? (ctx.currentCtx as { sessionId?: string }).sessionId : undefined;
601
+ const sessionManifests = manifests.filter(
602
+ (run) =>
603
+ !run.ownerSessionId ||
604
+ run.ownerSessionId === currentSessionId ||
605
+ (run as unknown as Record<string, unknown>).ownerSessionGeneration === currentSessionGen,
606
+ );
607
+ const now = Date.now();
608
+ for (const run of sessionManifests) {
609
+ if (run.status !== "running") continue;
610
+ try {
611
+ const snapshot = snapshotCache.get(run.runId);
612
+ if (!snapshot) continue;
613
+ if (snapshot.manifest.status !== "running") continue;
614
+ const summary = summarizeHeartbeats(snapshot, { now });
615
+ const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
616
+ if (count <= 0) return;
617
+ const key = `${kind}_${run.runId}`;
618
+ const previous = ctx.autoRecoveryLast.get(key);
619
+ if (previous !== undefined && now - previous.lastAccessAt < 5 * 60_000) return;
620
+ // Defensive cap: evict entry with oldest lastAccessAt before inserting/updating.
621
+ while (ctx.autoRecoveryLast.size >= ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES) {
622
+ let oldestKey: string | undefined;
623
+ let oldestAccess = Infinity;
624
+ for (const [k, v] of ctx.autoRecoveryLast) {
625
+ if (v.lastAccessAt < oldestAccess) {
626
+ oldestAccess = v.lastAccessAt;
627
+ oldestKey = k;
628
+ }
629
+ }
630
+ if (oldestKey === undefined) break;
631
+ ctx.autoRecoveryLast.delete(oldestKey);
632
+ }
633
+ ctx.autoRecoveryLast.set(key, {
634
+ insertedAt: now,
635
+ lastAccessAt: now,
636
+ });
637
+ ctx.notifyOperator({
638
+ id: key,
639
+ severity: "warning",
640
+ source: "health",
641
+ runId: run.runId,
642
+ title,
643
+ body,
644
+ });
645
+ };
646
+ maybeNotifyHealth(
647
+ "recovery_dead_workers",
648
+ summary.dead,
649
+ `Run ${run.runId} has ${summary.dead} dead worker(s).`,
650
+ "Open /team-dashboard → 5 health → R recovery / K kill stale / D diagnostic.",
651
+ );
652
+ maybeNotifyHealth(
653
+ "recovery_missing_heartbeat",
654
+ summary.missing,
655
+ `Run ${run.runId} has ${summary.missing} worker(s) missing heartbeat.`,
656
+ "Open /team-dashboard → 5 health → inspect health actions.",
657
+ );
658
+ } catch (error) {
659
+ logInternalError("register.health-notification", error, run.runId);
660
+ }
661
+ }
662
+ };
663
+
664
+ const fallbackMs = loadedConfig.config.ui?.dashboardLiveRefreshMs ?? DEFAULT_UI.refreshMs;
665
+ const liveRefreshMs = 160;
666
+ const hasActiveWork = (): boolean => {
667
+ if (listLiveAgents().some((a) => a.status === "running")) return true;
668
+ return lastPreloadedManifests.some((r) => r.status === "running" || r.status === "queued" || r.status === "planning");
669
+ };
670
+ const effectiveRefreshMs = () => (hasActiveWork() ? liveRefreshMs : fallbackMs);
671
+ ctx.renderScheduler = new RenderScheduler(pi.events, renderTick, {
672
+ fallbackMs: effectiveRefreshMs,
673
+ onInvalidate: (payload: unknown) => {
674
+ const runId =
675
+ typeof payload === "object" &&
676
+ payload !== null &&
677
+ "runId" in payload &&
678
+ typeof (payload as { runId: unknown }).runId === "string"
679
+ ? (payload as { runId: string }).runId
680
+ : undefined;
681
+ ctx.getRunSnapshotCache(extensionCtx.cwd).invalidate(runId);
682
+ },
683
+ });
684
+ // Fix D: bridge internal runEventBus events to renderScheduler so the UI
685
+ // re-renders within debounceMs of any agent lifecycle event.
686
+ const sched = ctx.renderScheduler;
687
+ const unsubscribeRunEvents = runEventBus.onAny((event) => {
688
+ sched.schedule({
689
+ runId: event.runId,
690
+ source: "runEventBus",
691
+ type: event.type,
692
+ });
693
+ });
694
+ ctx.renderSchedulerUnsubscribers.push(unsubscribeRunEvents);
695
+ startPreloadLoop(fallbackMs, effectiveRefreshMs);
696
+
697
+ // Bounded run watcher setup (pts/2 hang fix 2026-06-16).
698
+ const crewRunWatcherOnChange = (runId: string): void => {
699
+ if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
700
+ ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).invalidate(runId);
701
+ ctx.renderScheduler?.schedule({ runId });
702
+ };
703
+ const crewRunWatcherOnError = (error: unknown): void => {
704
+ logInternalError("register.crewRunWatchers.error", error);
705
+ };
706
+ try {
707
+ ctx.crewRunWatchers?.closeAll();
708
+ ctx.crewRunWatchers = undefined;
709
+ const crewRunsDir = path.join(projectCrewRoot(extensionCtx.cwd), "state", "runs");
710
+ if (fs.existsSync(crewRunsDir)) {
711
+ ctx.crewRunWatchers = new RunWatcherRegistry();
712
+ ctx.crewRunWatchers.setRootWatcher(crewRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
713
+ }
714
+ } catch (error) {
715
+ logInternalError("register.crewRunWatchers.start", error);
716
+ }
717
+ try {
718
+ ctx.userCrewWatchers?.closeAll();
719
+ ctx.userCrewWatchers = undefined;
720
+ const userRunsDir = path.join(userCrewRoot(), "state", "runs");
721
+ if (fs.existsSync(userRunsDir)) {
722
+ ctx.userCrewWatchers = new RunWatcherRegistry();
723
+ ctx.userCrewWatchers.setRootWatcher(userRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
724
+ }
725
+ } catch (error) {
726
+ logInternalError("register.userCrewWatchers.start", error);
727
+ }
728
+ // Kick an immediate preload so the first buildFrame reconciles per-run
729
+ // watchers for any runs that are already active on session start.
730
+ backgroundPreload();
731
+ }