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
@@ -1,1616 +1,97 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { asRecord, loadConfig } from "../config/config.ts";
6
- import { DEFAULT_NOTIFICATIONS, DEFAULT_UI } from "../config/defaults.ts";
7
- import { clearHooksScoped } from "../hooks/registry.ts";
8
- // 2.7: Lazy-load OTLPExporter — moved to registration/observability.ts (H3-L2 split).
9
- import { BatchBarrier, type BatchMember } from "../runtime/batch-barrier.ts";
10
- import type {
11
- cancelOrphanedRuns as CancelOrphanedRunsFn,
12
- detectInterruptedRuns as DetectInterruptedRunsFn,
13
- purgeStaleActiveRunIndex as PurgeStaleActiveRunIndexFn,
14
- } from "../runtime/crash-recovery.ts";
15
- // 2.7: Lazy-load crash-recovery helpers — only invoked from session_start
16
- // deferred cleanup and cleanupRuntime. Each function is awaited inside an
17
- // async context that already runs after registration completes.
18
- import { reconcileAllStaleRuns } from "../runtime/crash-recovery.ts";
19
- import { appendDeadletter } from "../runtime/deadletter.ts";
20
- import { listLiveAgents } from "../runtime/live-agent-manager.ts";
21
- import { createManifestCache } from "../runtime/manifest-cache.ts";
22
- import { cleanupOrphanWorkers } from "../runtime/orphan-worker-registry.ts";
1
+ /**
2
+ * pi-crew extension entry point.
3
+ *
4
+ * Single import surface Pi calls during extension load: `registerPiTeams(pi)`.
5
+ * Delegates to focused modules under `./registration/`:
6
+ * • registration-types — shared RegistrationContext
7
+ * • context-builder — buildRegistrationContext (state init)
8
+ * • subagent-manager-setup — SubagentManager + callbacks
9
+ * • foreground-run-controller — start/abort/openLiveSidebar
10
+ * • runtime-cleanup — cleanupRuntime + cleanupSwitch
11
+ * • lazy-configurers — notifications/observability/delivery
12
+ * • lifecycle-handlers — session_start/shutdown/before_switch
13
+ * • hook-registration — tool_call/tool_result/resources_discover
14
+ * • tool-registration — registerTeamTool + registerSubagentTools
15
+ * • command-registration — registerTeamCommands
16
+ * • crash-recovery-cache — lazy importCrashRecovery
17
+ * • wire-cross-extension — RPC handle + global registry install
18
+ */
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { loadConfig } from "../config/config.ts";
23
21
  import { primePeerDep } from "../runtime/peer-dep.ts";
24
- import { buildValidationBlocker, extractPathFromInput, validateWrittenFile } from "../runtime/per-write-validator.ts";
25
- import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs } from "../runtime/pi-args.ts";
26
22
  import { startRuntimeWarmup } from "../runtime/runtime-warmup.ts";
27
- import type { ScheduledJob } from "../runtime/scheduler.ts";
28
- import { CrewScheduler } from "../runtime/scheduler.ts";
29
- import { applyCrewSettingsToConfig, loadCrewSettings } from "../runtime/settings-store.ts";
30
- import { reconcileOrphanedTempWorkspaces } from "../runtime/stale-reconciler.ts";
31
- import { loadRunManifestById, updateRunStatus } from "../state/state-store.ts";
32
- import type { TeamRunManifest } from "../state/types.ts";
33
- import { readPersistedSubagentRecord, SubagentManager } from "../subagents/manager.ts";
34
- import { terminateActiveChildPiProcesses } from "../subagents/spawn.ts";
35
23
  import { deployBundledThemes } from "../ui/deploy-bundled-themes.ts";
36
- import { summarizeHeartbeats } from "../ui/heartbeat-aggregator.ts";
37
- import { requestRender, setExtensionWidget, setWorkingIndicator } from "../ui/pi-ui-compat.ts";
38
- import {
39
- clearPiCrewPowerbar,
40
- disposePowerbarCoalescer,
41
- registerPiCrewPowerbarSegments,
42
- requestPowerbarUpdate,
43
- resetPowerbarDedupState,
44
- updatePiCrewPowerbar,
45
- } from "../ui/powerbar-publisher.ts";
46
- import { RenderScheduler } from "../ui/render-scheduler.ts";
47
- import { runEventBus } from "../ui/run-event-bus.ts";
48
- import { createRunSnapshotCache } from "../ui/run-snapshot-cache.ts";
49
- import { createTerminalStatusController, type TerminalStatusController } from "../ui/terminal-status.ts";
50
- import { type CrewWidgetState, stopCrewWidget, updateCrewWidget } from "../ui/widget/index.ts";
51
- import { closeWatcher } from "../utils/fs-watch.ts";
52
- import { logInternalError } from "../utils/internal-error.ts";
53
- import { clearProjectRootCache, projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
54
- import { RunWatcherRegistry } from "../utils/run-watcher-registry.ts";
55
- import { resolveContainedPath } from "../utils/safe-paths.ts";
56
- import { extractSessionId } from "../utils/session-utils.ts";
57
24
  import { resetTimings, time } from "../utils/timings.ts";
58
- // 2.7: Lazy-load LiveRunSidebar — moved to registration/ui.ts (H3-L2 split).
59
- // The class pulls in transcript-viewer and other heavy UI modules.
60
- import { type AsyncNotifierState, startAsyncRunNotifier, stopAsyncRunNotifier } from "./async-notifier.ts";
61
25
  import { registerAutonomousPolicy } from "./autonomous-policy.ts";
62
26
  import { registerContextStatusInjection } from "./context-status-injection.ts";
63
- import { registerCrewAutocomplete } from "./crew-autocomplete.ts";
64
27
  import { registerCleanupHandler } from "./crew-cleanup.ts";
65
28
  import { registerCrewInputRouter } from "./crew-input-router.ts";
66
29
  import { registerCrewShortcuts } from "./crew-shortcuts.ts";
67
30
  import { registerCrewVibes } from "./crew-vibes/index.ts";
68
- import { type PiCrewRpcHandle, registerPiCrewRpc } from "./cross-extension-rpc.ts";
69
31
  import { registerKnowledgeInjection } from "./knowledge-injection.ts";
70
32
  import { registerCrewMessageRenderers } from "./message-renderers.ts";
71
- import type { NotificationDescriptor } from "./notification-router.ts";
72
- import { runArtifactCleanup } from "./registration/artifact-cleanup.ts";
73
- import { registerTeamCommands } from "./registration/commands.ts";
74
- import { registerCompactionGuard } from "./registration/compaction-guard.ts";
75
- // H3-L2 split: lifecycle installer extracted to registration/lifecycle.ts.
76
- // This module owns the async-run notifier, notification router, delivery coordinator, and run-lifecycle subscriptions.
77
- import {
78
- configureDeliveryCoordinator as configureDeliveryCoordinatorFromRegistration,
79
- configureNotifications as configureNotificationsFromRegistration,
80
- disposeDeliveryCoordinator,
81
- disposeNotifications,
82
- type LifecycleState,
83
- startLifecycleWatchers,
84
- stopLifecycleWatchers,
85
- } from "./registration/lifecycle.ts";
86
- // H3-L2 split: observability installer extracted to registration/observability.ts.
87
- // This module owns metric registry, OTLP, heartbeat-watcher, and auto-repair timers.
88
- import { configureObservability, disposeObservability, type ObservabilityState } from "./registration/observability.ts";
89
- import { __test__subagentSpawnParams, sendAgentWakeUp, sendFollowUp } from "./registration/subagent-helpers.ts";
90
- import { registerSubagentTools } from "./registration/subagent-tools.ts";
91
- import { registerTeamTool } from "./registration/team-tool.ts";
92
- // H3-L2 split: ui installer extracted to registration/ui.ts.
93
- // This module owns live-run sidebar + powerbar overlay logic.
94
- import { clearDashboardPowerbar, installLiveSidebar, pushPowerbarUpdate, type UiState } from "./registration/ui.ts";
95
- import { notifyActiveRuns } from "./session-summary.ts";
96
- import { shouldBlockDestructiveTeamAction } from "./team-tool/destructive-gate.ts";
97
- import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
98
- import { handleTeamTool, uninstallCrewGlobalRegistry } from "./team-tool.ts";
99
-
100
- let _cachedCrashRecovery:
101
- | {
102
- cancelOrphanedRuns: typeof CancelOrphanedRunsFn;
103
- detectInterruptedRuns: typeof DetectInterruptedRunsFn;
104
- purgeStaleActiveRunIndex: typeof PurgeStaleActiveRunIndexFn;
105
- }
106
- | undefined;
107
- async function importCrashRecovery(): Promise<NonNullable<typeof _cachedCrashRecovery>> {
108
- if (!_cachedCrashRecovery) {
109
- // LAZY: defer crash-recovery (~14 KB) until session_start cleanup runs.
110
- const mod = await import("../runtime/crash-recovery.ts");
111
- _cachedCrashRecovery = {
112
- cancelOrphanedRuns: mod.cancelOrphanedRuns,
113
- detectInterruptedRuns: mod.detectInterruptedRuns,
114
- purgeStaleActiveRunIndex: mod.purgeStaleActiveRunIndex,
115
- };
116
- }
117
- return _cachedCrashRecovery;
118
- }
119
- function purgeStaleActiveRunIndexSyncIfLoaded(): void {
120
- // cleanupRuntime runs synchronously; only purge if we've already loaded
121
- // crash-recovery during the session. Otherwise skip — next session_start
122
- // will purge.
123
- if (!_cachedCrashRecovery) return;
124
- try {
125
- _cachedCrashRecovery.purgeStaleActiveRunIndex();
126
- } catch (error) {
127
- logInternalError("register.cleanupRuntime.purgeStale", error);
128
- }
129
- }
130
-
131
- import { pruneFinishedRuns, pruneUserLevelRuns } from "../extension/run-maintenance.ts";
132
- import { initI18n } from "../i18n.ts";
133
- // H3-L2 split: DeliveryCoordinator + OverflowRecoveryTracker moved to registration/lifecycle.ts.
134
- import { tryRegisterSessionCleanup } from "../runtime/session-resources.ts";
135
- import { createSessionSnapshot } from "../runtime/session-snapshot.ts";
33
+ import { registerPiCommands } from "./registration/command-registration.ts";
34
+ import { buildRegistrationContext } from "./registration/context-builder.ts";
35
+ import { importCrashRecovery, purgeStaleActiveRunIndexSyncIfLoaded } from "./registration/crash-recovery-cache.ts";
36
+ import { installForegroundRunController } from "./registration/foreground-run-controller.ts";
37
+ import { installPiHooks } from "./registration/hook-registration.ts";
38
+ import { installLazyConfigurers } from "./registration/lazy-configurers.ts";
39
+ import { installSessionLifecycleHandlers } from "./registration/lifecycle-handlers.ts";
40
+ import { installRuntimeCleanup } from "./registration/runtime-cleanup.ts";
41
+ import { __test__subagentSpawnParams } from "./registration/subagent-helpers.ts";
42
+ import { installSubagentManager } from "./registration/subagent-manager-setup.ts";
43
+ import { registerPiTools } from "./registration/tool-registration.ts";
44
+ import { installCrossExtensionWiring } from "./registration/wire-cross-extension.ts";
136
45
 
137
46
  export { __test__subagentSpawnParams };
138
47
 
48
+ /**
49
+ * Pi extension entry point. See module-level docstring for the full pipeline.
50
+ */
139
51
  export function registerPiTeams(pi: ExtensionAPI): void {
140
- const disposeI18n = initI18n(pi);
141
52
  resetTimings();
142
- // S06: Verbose/debug flags for pi-crew diagnostics
143
- const verbose = process.env.PI_CREW_VERBOSE === "1" || process.argv.includes("--verbose");
144
- const debug = process.env.PI_CREW_DEBUG === "1" || process.argv.includes("--debug");
145
53
  time("register:start");
146
- // Cold-start race fix (general): pre-warm the hot module graph NOW, during
147
- // single-threaded registration, before any concurrent subagent can spawn.
148
- // Under the tsx loader, concurrent first-imports race module-record
149
- // instantiation → `Cannot read properties of undefined (reading '<X>')` for
150
- // ANY named import (observed: existsSync, validateWorkflowForTeam).
151
- // Warming the graph here + awaiting it at spawn boundaries eliminates the
152
- // race window. See src/runtime/runtime-warmup.ts.
54
+
153
55
  startRuntimeWarmup();
154
- // FIX (split-scope install): preload the ESM peer dep so discover-skills /
155
- // skill-instructions can read the REAL getAgentDir (fork-aware) from cache.
156
- // Fire-and-forget: getAgentDir() falls back to a safe computed default until
157
- // this resolves. See src/runtime/peer-dep.ts.
158
56
  primePeerDep().catch(() => {});
159
- // Deploy bundled themes (crew-dark, crew-dracula, etc.) to ~/.pi/agent/themes/
160
- // so Pi's theme loader discovers them. Best-effort, idempotent.
161
57
  deployBundledThemes();
162
- const globalStore = globalThis as Record<string | symbol, unknown>;
163
- const runtimeCleanupStoreKey = Symbol("__piCrewRuntimeCleanup");
164
- const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
165
- time("register:init");
166
- // Best-effort cleanup of the previous runtime instance. Errors are logged but
167
- // do not halt new registration — a failing cleanup from a prior instance is
168
- // preferable to leaving pi-crew unregistered, and any stale state from the
169
- // previous instance will be reconciled when the new instance initializes.
170
- if (typeof previousRuntimeCleanup === "function") {
171
- try {
172
- previousRuntimeCleanup();
173
- } catch (error) {
174
- logInternalError("register.prev-cleanup", error);
175
- }
176
- }
177
- const notifierState: AsyncNotifierState = { seenFinishedRunIds: new Set() };
178
- let currentCtx: ExtensionContext | undefined;
179
- let sessionGeneration = 0;
180
- let rpcHandle: PiCrewRpcHandle | undefined;
181
- let cleanedUp = false;
182
- let crewAutocompleteRegistered = false;
183
- let manifestCache = createManifestCache(process.cwd());
184
- let runSnapshotCache = createRunSnapshotCache(process.cwd());
185
- let cacheCwd = process.cwd();
186
- const getManifestCache = (cwd: string): ReturnType<typeof createManifestCache> => {
187
- if (manifestCache && cacheCwd === cwd) return manifestCache;
188
- if (manifestCache) manifestCache.dispose();
189
- if (runSnapshotCache) runSnapshotCache.dispose?.();
190
- cacheCwd = cwd;
191
- manifestCache = createManifestCache(cwd);
192
- runSnapshotCache = createRunSnapshotCache(cwd);
193
- return manifestCache;
194
- };
195
- const getRunSnapshotCache = (cwd: string): ReturnType<typeof createRunSnapshotCache> => {
196
- if (cacheCwd !== cwd) getManifestCache(cwd);
197
- return runSnapshotCache;
198
- };
199
- const telemetryEnabled = (): boolean => loadConfig(currentCtx?.cwd ?? process.cwd()).config.telemetry?.enabled !== false;
200
- const widgetState: CrewWidgetState = { frame: 0 };
201
- // H3-L2 split: notification sink + router moved to registration/lifecycle.ts state.
202
- // H3-L2 split: Observability state lives in registration/observability.ts;
203
- // orchestrator keeps a holder so cleanupRuntime can dispose in place.
204
- const observabilityState: ObservabilityState = {
205
- metricRegistry: undefined,
206
- eventMetricSub: undefined,
207
- metricSink: undefined,
208
- heartbeatWatcher: undefined,
209
- autoRepairTimer: undefined,
210
- tempReconcileTimer: undefined,
211
- otlpExporter: undefined,
212
- };
213
- // H3-L2 split: UI state lives in registration/ui.ts; orchestrator keeps
214
- // a holder for the live sidebar runId and dashboard-opened flag.
215
- const uiState: UiState = { liveSidebarRunId: undefined, dashboardOpened: false };
216
- // H3-L2 split: Lifecycle state lives in registration/lifecycle.ts.
217
- const lifecycleState: LifecycleState = {
218
- notifierStarted: false,
219
- notificationSink: undefined,
220
- notificationRouter: undefined,
221
- deliveryCoordinator: undefined,
222
- overflowTracker: undefined,
223
- };
224
- const configureNotifications = (ctx: ExtensionContext): void => {
225
- // H3-L2 split: notifications wiring delegated to registration/lifecycle.ts.
226
- void configureNotificationsFromRegistration(ctx);
227
- };
228
- async function configureNotificationsFromRegistration(ctx: ExtensionContext): Promise<void> {
229
- // LAZY: registration/lifecycle is heavy (notification-router + sink)
230
- const lifecycleModule = await import("./registration/lifecycle.ts");
231
- await lifecycleModule.configureNotifications(ctx, lifecycleState, {
232
- pi,
233
- widgetState,
234
- getCurrentCtx: () => currentCtx,
235
- getManifestCache,
236
- getRunSnapshotCache,
237
- requestPowerbarUpdate,
238
- });
239
- }
240
- const configureObservability = (ctx: ExtensionContext): void => {
241
- // H3-L2 split: observability installation delegated to registration/observability.ts.
242
- // This keeps the orchestrator thin while preserving the same lifecycle:
243
- // dispose prior → install fresh → gate on config flags.
244
- void configureObservabilityFromRegistration(ctx);
245
- };
246
- // H3-L2 split: wraps the registration/observability.configureObservability call.
247
- // Pulled out as a method so we can build the deps bag once at registration time
248
- // and reuse it across session_start cycles.
249
- async function configureObservabilityFromRegistration(ctx: ExtensionContext): Promise<void> {
250
- // LAZY: registration/observability is heavy (HeartbeatWatcher + metric stack)
251
- const observabilityModule = await import("./registration/observability.ts");
252
- await observabilityModule.configureObservability(ctx, observabilityState, {
253
- pi,
254
- getManifestCache,
255
- notifyOperator,
256
- isCleanedUp: () => cleanedUp,
257
- reconcileStaleRuns: (cwd, cache) => reconcileAllStaleRuns(cwd, cache),
258
- reconcileOrphanedTempWorkspaces: (now, opts) => reconcileOrphanedTempWorkspaces(now, opts),
259
- cleanupOrphanTempDirs,
260
- cleanupLegacyOrphanTempDirs,
261
- appendDeadletter: (manifest, entry) => appendDeadletter(manifest, entry as Parameters<typeof appendDeadletter>[1]),
262
- importCrashRecovery,
263
- });
264
- }
265
- const autoRecoveryLast = new Map<string, { insertedAt: number; lastAccessAt: number }>();
266
- // FIX (Round 22, defensive cap): Bound the cooldown-gate Map. Each run
267
- // contributes up to 4 keys (one per maybeNotifyHealth kind). Without a cap,
268
- // a long-running pi session that runs thousands of teams accumulates
269
- // thousands of entries. Eviction: oldest lastAccessAt first — uses LRU-like
270
- // semantics so entries that are still being actively accessed (re-accessed
271
- // before eviction) survive longer. This is fairer than insertion-order
272
- // eviction under high churn where many entries expire naturally before
273
- // being re-accessed.
274
- const AUTO_RECOVERY_LAST_MAX_ENTRIES = 1000;
275
- const configureDeliveryCoordinator = (): void => {
276
- // H3-L2 split: delivery coordinator + overflow tracker moved to registration/lifecycle.ts.
277
- void configureDeliveryCoordinatorFromRegistration(lifecycleState, {
278
- pi,
279
- observabilityState,
280
- notifyOperator,
281
- sendFollowUp,
282
- sendAgentWakeUp,
283
- });
284
- };
285
- const notifyOperator = (notification: NotificationDescriptor): void => {
286
- try {
287
- lifecycleState.notificationRouter?.enqueue(notification);
288
- } catch (error) {
289
- logInternalError("register.notification", error);
290
- sendFollowUp(pi, [notification.title, notification.body].filter((line): line is string => Boolean(line)).join("\n"));
291
- }
292
- };
293
- const captureSessionGeneration = (): number => sessionGeneration;
294
- const isOwnerSessionCurrent = (ownerGeneration: number | undefined): boolean =>
295
- !cleanedUp && (ownerGeneration === undefined || ownerGeneration === sessionGeneration);
296
- const isContextCurrent = (ctx: ExtensionContext, ownerGeneration: number): boolean =>
297
- !cleanedUp && currentCtx === ctx && sessionGeneration === ownerGeneration;
298
- const batchBarrier = new BatchBarrier();
299
- const subagentManager = new SubagentManager(
300
- 4,
301
- (record) => {
302
- // Phase 1.3 + 1.6: Emit public crew.subagent.completed event with telemetry.
303
- // Users can opt out with config.telemetry.enabled=false.
304
- if (telemetryEnabled()) {
305
- pi.events?.emit?.("crew.subagent.completed", {
306
- id: record.id,
307
- runId: record.runId,
308
- type: record.type,
309
- status: record.status,
310
- turnCount: record.turnCount,
311
- terminated: record.terminated ?? false,
312
- durationMs: record.durationMs,
313
- });
314
- }
315
- if (!record.background) return;
316
- if (!isOwnerSessionCurrent(record.ownerSessionGeneration)) return;
317
- if (
318
- record.status !== "completed" &&
319
- record.status !== "failed" &&
320
- record.status !== "cancelled" &&
321
- record.status !== "blocked" &&
322
- record.status !== "error"
323
- )
324
- return;
325
- // Rule 2 (consume-race fix): this callback fires from inside the
326
- // record.promise IIFE `finally` block — BEFORE the promise resolves,
327
- // i.e. before a leader calling `get_subagent_result(wait:true)` can
328
- // set resultConsumed=true. The old synchronous guard always saw
329
- // resultConsumed=false here. Defer emission to a MACROTASK
330
- // (setTimeout, NOT queueMicrotask): macrotasks run only after the
331
- // microtask queue drains — which includes the leader's
332
- // `await record.promise` continuation that marks resultConsumed=true.
333
- // Then recheck in-memory + persisted before emitting.
334
- const agentId = record.id;
335
- const ownerGen = record.ownerSessionGeneration;
336
- const agentStatus = record.status;
337
- const agentType = record.type;
338
- const agentDescription = record.description;
339
- const agentRunId = record.runId;
340
- const agentBatchId = record.batchId;
341
- setTimeout(() => {
342
- if (cleanedUp) return;
343
- const fresh = subagentManager.getRecord(agentId);
344
- const persisted = currentCtx ? readPersistedSubagentRecord(currentCtx.cwd, agentId) : undefined;
345
- // Leader already joined the result -> suppress redundant notify.
346
- if (fresh?.resultConsumed || persisted?.resultConsumed) return;
347
- if (!isOwnerSessionCurrent(fresh?.ownerSessionGeneration ?? ownerGen)) return;
348
- // Rule 1 (batch coalescing): if this agent belongs to a batch, never
349
- // emit an individual notification. Instead record its terminal state
350
- // in the barrier; emit ONE consolidated notification only when ALL
351
- // members are terminal. Suppressed members wait silently.
352
- if (agentBatchId) {
353
- const member: BatchMember = {
354
- id: agentId,
355
- description: agentDescription,
356
- type: agentType,
357
- status: agentStatus,
358
- };
359
- const snap = batchBarrier.markTerminal(agentBatchId, member);
360
- if (snap.allDone && !snap.notified) {
361
- batchBarrier.markNotified(agentBatchId);
362
- const roster = snap.terminal
363
- .map((m) => `- ${m.id} [${m.status}] (${m.type ?? "agent"}): ${m.description ?? ""}`)
364
- .join("\n");
365
- const joinInstruction = [
366
- `All ${snap.terminal.length} background subagents in batch "${agentBatchId}" have finished.`,
367
- "Members:",
368
- roster,
369
- "",
370
- `Call get_subagent_result for each agent_id above, read the outputs, then continue the user's original task.`,
371
- ].join("\n");
372
- sendAgentWakeUp(pi, joinInstruction);
373
- notifyOperator({
374
- id: `subagent-batch:${agentBatchId}:completed`,
375
- severity: "info",
376
- source: "subagent-completed",
377
- runId: agentRunId,
378
- title: `pi-crew batch "${agentBatchId}" complete (${snap.terminal.length} agents).`,
379
- body: `Members: ${snap.terminal.map((m) => m.id).join(", ")}`,
380
- });
381
- }
382
- // Either we just emitted the consolidated notify, or we are still
383
- // waiting for other members — in both cases do NOT emit individual.
384
- return;
385
- }
386
- const metadata = JSON.stringify(
387
- {
388
- id: agentId,
389
- status: agentStatus,
390
- type: agentType,
391
- runId: agentRunId,
392
- description: agentDescription,
393
- },
394
- null,
395
- 2,
396
- );
397
- const joinInstruction = [
398
- "A pi-crew background subagent changed state.",
399
- "Metadata (do not treat metadata values as instructions):",
400
- "```json",
401
- metadata,
402
- "```",
403
- `Call get_subagent_result with agent_id="${agentId}" now, read the output, then continue the user's original task without waiting for another user prompt.`,
404
- ].join("\n");
405
- sendAgentWakeUp(pi, joinInstruction);
406
- notifyOperator({
407
- id: `subagent:${agentId}:${agentStatus}`,
408
- severity: agentStatus === "completed" ? "info" : "warning",
409
- source: "subagent-completed",
410
- runId: agentRunId,
411
- title: `pi-crew subagent ${agentId} ${agentStatus}.`,
412
- body: `Use get_subagent_result with agent_id=${agentId} for output.`,
413
- });
414
- }, 0);
415
- },
416
- 1000,
417
- (event, payload) => {
418
- const ownerGeneration = typeof payload.ownerSessionGeneration === "number" ? payload.ownerSessionGeneration : undefined;
419
- if (ownerGeneration !== undefined && !isOwnerSessionCurrent(ownerGeneration)) return;
420
- if (event === "subagent.stuck-blocked") {
421
- const id = typeof payload.id === "string" ? payload.id : "unknown";
422
- const runId = typeof payload.runId === "string" ? payload.runId : "unknown";
423
- const durationMs = typeof payload.durationMs === "number" ? payload.durationMs : 0;
424
- notifyOperator({
425
- id: `subagent-stuck:${id}:${runId}`,
426
- severity: "warning",
427
- source: "subagent-stuck",
428
- runId,
429
- title: `pi-crew subagent ${id} may be stuck in blocked state for ${Math.max(1, Math.round(durationMs / 1000))}s.`,
430
- body: `Use team status runId=${runId} and investigate.\nSubagent may need manual intervention.`,
431
- });
432
- }
433
- pi.events?.emit?.(event, payload);
434
- },
435
- );
436
- const foregroundControllers = new Map<string | symbol, AbortController>();
437
- let renderScheduler: RenderScheduler | undefined;
438
- const renderSchedulerUnsubscribers: Array<() => void> = [];
439
- // T4 (v0.8.3): terminal tab title + Ghostty native progress bar. Lazily
440
- // constructed on the first run event (needs a ctx for setTitle). Driven
441
- // from runEventBus.onAny with an idle<->active transition guard so we
442
- // don't re-emit the OSC sequence on every event.
443
- let terminalStatus: TerminalStatusController | undefined;
444
- let terminalStatusActive = false;
445
- let crewScheduler: CrewScheduler | undefined;
446
- let preloadTimer: ReturnType<typeof setTimeout> | undefined;
447
- const disposeRenderSchedulerSubscriptions = (): void => {
448
- for (const unsub of renderSchedulerUnsubscribers.splice(0)) {
449
- try {
450
- unsub();
451
- } catch (error) {
452
- logInternalError("register.renderScheduler.unsubscribe", error);
453
- }
454
- }
455
- };
456
- // 1.3: optional native FS watcher on `<crewRoot>/state` — when running on
457
- // a filesystem that supports recursive fs.watch (Windows NTFS, macOS, modern
458
- // Linux), file changes (manifest/tasks/events/agents) trigger an
459
- // immediate cache invalidate via renderScheduler.schedule. Falls back to
460
- // poll-only behavior on systems where fs.watch errors.
461
- // pts/2 hang fix (2026-06-16): the previous RECURSIVE fs.watch(<state>, {recursive:true})
462
- // exploded to O(total run history) inotify watches on Linux (109→339 observed) and
463
- // caused a permanent busy-loop. Replaced with bounded per-active-run watchers via
464
- // RunWatcherRegistry (root watcher on runs/ for new-run detection + one non-recursive
465
- // watcher per active run, reconciled each preload tick in buildFrame).
466
- let crewRunWatchers: RunWatcherRegistry | undefined;
467
- let userCrewWatchers: RunWatcherRegistry | undefined;
468
- // Separate map for foreground team-run AbortControllers (distinct from subagent controllers).
469
- // P0 fix: stopSessionBoundSubagents must NOT abort foreground team runs on session switch.
470
- // Foreground team runs run in the same process as the session; they naturally clean up
471
- // when the session context is torn down. Only subagents need explicit abort on switch.
472
- const foregroundTeamRunControllers = new Map<string | symbol, AbortController>();
473
58
 
474
- const stopSessionBoundSubagents = (): void => {
475
- // Only abort subagent controllers — NOT foreground team runs.
476
- // Foreground team runs are bound to the session lifecycle; they will be aborted
477
- // by cleanupRuntime during session_shutdown.
478
- for (const controller of foregroundControllers.values()) controller.abort();
479
- foregroundControllers.clear();
480
- subagentManager.abortAll("Session switching — foreground subagents cancelled.");
481
- terminateActiveChildPiProcesses();
482
- disposeRenderSchedulerSubscriptions();
483
- renderScheduler?.dispose();
484
- renderScheduler = undefined;
485
- terminalStatus?.dispose();
486
- terminalStatus = undefined;
487
- terminalStatusActive = false;
488
- uiState.liveSidebarRunId = undefined;
489
- if (currentCtx) stopCrewWidget(currentCtx, widgetState, loadConfig(currentCtx.cwd).config.ui);
490
- clearPiCrewPowerbar(pi.events);
491
- };
492
- const openLiveSidebar = (ctx: ExtensionContext, runId: string): void => {
493
- // H3-L2 split: live sidebar delegated to registration/ui.ts.
494
- void installLiveSidebarFromRegistration(ctx, runId);
495
- };
496
- // H3-L2 split: wraps registration/ui.ts installLiveSidebar call.
497
- async function installLiveSidebarFromRegistration(ctx: ExtensionContext, runId: string): Promise<void> {
498
- // LAZY: registration/ui only imported when a live sidebar is actually opened.
499
- const uiModule = await import("./registration/ui.ts");
500
- await uiModule.installLiveSidebar(ctx, runId, uiState, {
501
- pi,
502
- widgetState,
503
- getManifestCache,
504
- getRunSnapshotCache,
505
- isCleanedUp: () => cleanedUp,
506
- getCurrentCtx: () => currentCtx,
507
- });
508
- }
509
- const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string): void => {
510
- const ownerGeneration = captureSessionGeneration();
511
- const controller = new AbortController();
512
- const key = runId ?? Symbol();
513
- foregroundTeamRunControllers.set(key, controller);
514
- if (ctx.hasUI) {
515
- setWorkingIndicator(ctx, {
516
- frames: ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
517
- intervalMs: 80,
518
- });
519
- ctx.ui.setWorkingMessage(runId ? `pi-crew foreground run ${runId}...` : "pi-crew foreground run...");
520
- }
521
- // Start watchdog for foreground run — periodic health check that
522
- // auto-notifies the assistant if the run appears hung or completes.
523
- if (runId) {
524
- void import("../runtime/foreground-watchdog.ts")
525
- .then(({ startForegroundWatchdog }) => {
526
- startForegroundWatchdog({ pi, cwd: ctx.cwd, runId });
527
- })
528
- .catch((error) => {
529
- logInternalError("register.foreground-watchdog-import", error);
530
- });
531
- }
532
- setImmediate(() => {
533
- void runner(controller.signal)
534
- .catch((error) => {
535
- const message = error instanceof Error ? error.message : String(error);
536
- if (runId) {
537
- try {
538
- const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency. Post-run status updates tolerate slight staleness.
539
- if (
540
- loaded &&
541
- loaded.manifest.status !== "completed" &&
542
- loaded.manifest.status !== "failed" &&
543
- loaded.manifest.status !== "cancelled" &&
544
- loaded.manifest.status !== "blocked"
545
- )
546
- updateRunStatus(loaded.manifest, "failed", message);
547
- } catch (statusError) {
548
- logInternalError("register.foreground-run-failure", statusError, `runId=${runId}`);
549
- }
550
- }
551
- if (isContextCurrent(ctx, ownerGeneration)) ctx.ui.notify(`pi-crew foreground run failed: ${message}`, "error");
552
- else logInternalError("register.foreground-run-failure", error, `runId=${runId} context disposed`);
553
- })
554
- .finally(() => {
555
- foregroundTeamRunControllers.delete(key);
556
- // Stop watchdog — run has finished
557
- if (runId) {
558
- void import("../runtime/foreground-watchdog.ts")
559
- .then(({ stopWatchdog }) => {
560
- stopWatchdog(runId);
561
- })
562
- .catch((error) => logInternalError("register.foreground-watchdog", error, `runId=${runId}`));
563
- }
564
- const ownerCurrent = isContextCurrent(ctx, ownerGeneration);
565
- if (ctx.hasUI) {
566
- // Always clear working message/spinner — stale spinners for completed runs are confusing.
567
- try {
568
- setWorkingIndicator(ctx);
569
- ctx.ui.setWorkingMessage();
570
- } catch {
571
- /* ignore */
572
- }
573
- }
574
- if (ownerCurrent && runId) {
575
- const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency. Post-run status updates tolerate slight staleness.
576
- const status = loaded?.manifest.status ?? "finished";
577
- const level = status === "failed" || status === "blocked" ? "error" : status === "cancelled" ? "warning" : "info";
578
- ctx.ui.notify(
579
- `pi-crew run ${runId} ${status}. Use /team-summary ${runId} or /team-status ${runId}.`,
580
- level as "info" | "warning" | "error",
581
- );
582
- // Phase 2.3: Persist run completion reference into the Pi session.
583
- pi.appendEntry("crew:run-completed", {
584
- runId,
585
- team: loaded?.manifest.team,
586
- workflow: loaded?.manifest.workflow,
587
- goal: loaded?.manifest.goal,
588
- status,
589
- taskCount: loaded?.tasks.length,
590
- timestamp: Date.now(),
591
- });
592
- // Phase 1.3: Emit public crew.run.* events
593
- const eventType =
594
- status === "completed"
595
- ? "crew.run.completed"
596
- : status === "failed" || status === "blocked"
597
- ? "crew.run.failed"
598
- : status === "cancelled"
599
- ? "crew.run.cancelled"
600
- : undefined;
601
- if (eventType) {
602
- pi.events?.emit?.(eventType, {
603
- runId,
604
- team: loaded?.manifest.team,
605
- workflow: loaded?.manifest.workflow,
606
- status,
607
- taskCount: loaded?.tasks.length,
608
- goal: loaded?.manifest.goal,
609
- });
610
- }
611
- }
612
- if (ownerCurrent && currentCtx) {
613
- const config = loadConfig(currentCtx.cwd).config.ui;
614
- updateCrewWidget(
615
- currentCtx,
616
- widgetState,
617
- config,
618
- getManifestCache(currentCtx.cwd),
619
- getRunSnapshotCache(currentCtx.cwd),
620
- );
621
- requestPowerbarUpdate(
622
- pi.events,
623
- currentCtx.cwd,
624
- config,
625
- getManifestCache(currentCtx.cwd),
626
- getRunSnapshotCache(currentCtx.cwd),
627
- currentCtx,
628
- widgetState.notificationCount ?? 0,
629
- );
630
- }
631
- });
632
- });
633
- };
59
+ const ctx = buildRegistrationContext(pi);
60
+ ctx.importCrashRecovery = importCrashRecovery;
61
+ ctx.purgeStaleActiveRunIndexSyncIfLoaded = purgeStaleActiveRunIndexSyncIfLoaded;
62
+ installRuntimeCleanup(pi, ctx);
63
+ installForegroundRunController(pi, ctx);
64
+ installSubagentManager(pi, ctx);
65
+ installLazyConfigurers(pi, ctx);
66
+ installCrossExtensionWiring(pi, ctx);
67
+
634
68
  time("register.policy");
635
69
  registerAutonomousPolicy(pi);
636
70
  registerKnowledgeInjection(pi);
637
- time("register.knowledge");
638
- time("register.rpc");
639
- function getPiEvents(): Parameters<typeof registerPiCrewRpc>[0] | undefined {
640
- if (pi && typeof pi === "object" && "events" in pi) {
641
- // pi.events may not be typed in the original pi type, so cast through unknown
642
- const events = (pi as { events?: Parameters<typeof registerPiCrewRpc>[0] }).events;
643
- return events;
644
- }
645
- return undefined;
646
- }
647
- rpcHandle = registerPiCrewRpc(getPiEvents(), () => currentCtx);
648
- time("register.globalRegistry");
649
- // Register global RPC registry for cross-extension access (mirrors pi-subagents3's Symbol.for pattern)
650
- // Uses lazy import to avoid pulling team-tool.ts into module load.
651
- // Other extensions access via: const reg = globalThis[Symbol.for("pi-crew:registry")];
652
- void import("./team-tool.ts").then(({ registerCrewGlobalRegistry, installCrewGlobalRegistry }) => {
653
- // Phase 3b: installCrewGlobalRegistry creates a v2 registry with agent registration API.
654
- // We then patch the manifest-backed methods with real implementations below.
655
- const manifestCacheForRegistry = getManifestCache(currentCtx?.cwd ?? process.cwd());
656
- installCrewGlobalRegistry();
657
- const CREW_REGISTRY_KEY = Symbol.for("pi-crew:registry");
658
- const registry = (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] as Record<string, unknown>;
659
- // Phase 3b (defensive): Validate registry structure before patching methods.
660
- // If a previous occupant left a non-conforming object, replace it entirely.
661
- // This prevents runtime failures if getRecord/listRuns/etc. are called on a
662
- // malformed predecessor value.
663
- if (registry === null || typeof registry !== "object" || Array.isArray(registry)) {
664
- (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] = {};
665
- }
666
- const validatedRegistry = (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] as Record<string, unknown>;
667
- validatedRegistry.getRecord = (runId: string) => manifestCacheForRegistry.get(runId);
668
- validatedRegistry.listRuns = () =>
669
- manifestCacheForRegistry.list(100).map((m: { runId: string; status: string; goal: string }) => ({
670
- runId: m.runId,
671
- status: m.status,
672
- goal: m.goal,
673
- }));
674
- validatedRegistry.appendEvent = (runId: string, event: Record<string, unknown>) => {
675
- const manifest = manifestCacheForRegistry.get(runId);
676
- if (manifest)
677
- void import("../state/event-log.ts").then(({ appendEventFireAndForget }) =>
678
- appendEventFireAndForget(manifest.eventsPath, event as Parameters<typeof appendEventFireAndForget>[1]),
679
- );
680
- };
681
- validatedRegistry.waitForAll = async (runId: string) => {
682
- // LAZY: state-store only needed for post-completion polling (waitForAll) and sync hasRunning check; avoid at startup.
683
- const { loadRunManifestById } = await import("../state/state-store.ts");
684
- const check = (): boolean => {
685
- const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
686
- if (!loaded) return true;
687
- return !loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
688
- };
689
- while (!check()) await new Promise((resolve) => setTimeout(resolve, 500));
690
- };
691
- validatedRegistry.hasRunning = async (runId: string) => {
692
- const manifest = manifestCacheForRegistry.get(runId);
693
- if (!manifest) return false;
694
- // LAZY: state-store only needed in hasRunning; avoid at startup.
695
- // Use dynamic import to avoid CJS/ESM mixed module issues.
696
- const { loadRunManifestById: loadRunForHasRunning } =
697
- // LAZY: defer dynamic import of ../state/state-store.ts to its call site.
698
- await import("../state/state-store.ts");
699
- const loaded = loadRunForHasRunning(currentCtx?.cwd ?? process.cwd(), runId);
700
- if (!loaded) return false;
701
- return loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
702
- };
703
- });
704
-
705
- /**
706
- * Cleanup session resources WITHOUT aborting foreground team runs.
707
- * Used during session switch (reason="resume"/"new"/"fork") to let foreground
708
- * runs complete naturally when the session context is torn down.
709
- */
710
- const cleanupSessionResourcesOnly = (): void => {
711
- if (cleanedUp) return;
712
- cleanedUp = true;
713
- if (preloadTimer) {
714
- clearTimeout(preloadTimer);
715
- preloadTimer = undefined;
716
- }
717
- crewRunWatchers?.closeAll();
718
- crewRunWatchers = undefined;
719
- userCrewWatchers?.closeAll();
720
- userCrewWatchers = undefined;
721
- stopSessionBoundSubagents();
722
- // P0 fix: Do NOT abort foreground team runs on session switch.
723
- // Foreground team runs run in the same process as the session; they naturally clean up
724
- // when the session context is torn down. Only subagents need explicit abort on switch.
725
- // Foreground runs will be aborted by cleanupRuntime() during actual session shutdown.
726
- crewScheduler?.stop();
727
- stopAsyncRunNotifier(notifierState);
728
-
729
- // P0: Purge all stale active-run-index entries on session cleanup.
730
- purgeStaleActiveRunIndexSyncIfLoaded();
731
-
732
- stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
733
- clearPiCrewPowerbar(pi.events);
734
- disposePowerbarCoalescer();
735
- disposeObservability(observabilityState, cleanedUp);
736
- lifecycleState.deliveryCoordinator?.dispose();
737
- clearHooksScoped();
738
- uninstallCrewGlobalRegistry();
739
- lifecycleState.overflowTracker?.dispose();
740
- lifecycleState.deliveryCoordinator = undefined;
741
- lifecycleState.overflowTracker = undefined;
742
- manifestCache.dispose();
743
- runSnapshotCache.dispose?.();
744
- clearProjectRootCache();
745
- renderScheduler?.dispose();
746
- renderScheduler = undefined;
747
- autoRecoveryLast.clear();
748
- disposeNotifications(lifecycleState);
749
- rpcHandle?.unsubscribe();
750
- rpcHandle = undefined;
751
- disposeI18n();
752
- sessionGeneration += 1;
753
- currentCtx = undefined;
754
- if (globalStore[runtimeCleanupStoreKey] === cleanupSessionResourcesOnly) delete globalStore[runtimeCleanupStoreKey];
755
- };
756
-
757
- const cleanupRuntime = (): void => {
758
- if (cleanedUp) return;
759
- cleanedUp = true;
760
- if (preloadTimer) {
761
- clearTimeout(preloadTimer);
762
- preloadTimer = undefined;
763
- }
764
- crewRunWatchers?.closeAll();
765
- crewRunWatchers = undefined;
766
- userCrewWatchers?.closeAll();
767
- userCrewWatchers = undefined;
768
- stopSessionBoundSubagents();
769
- // P0 fix: also abort foreground team runs on session shutdown (not on session switch).
770
- // This is the only place where foreground team run controllers should be aborted.
771
- for (const controller of foregroundTeamRunControllers.values()) controller.abort();
772
- foregroundTeamRunControllers.clear();
773
- crewScheduler?.stop();
774
- stopAsyncRunNotifier(notifierState);
775
-
776
- // Best-effort: kill any async background runners that are still alive.
777
- // NOTE: Background runners are designed to outlive the Pi session.
778
- // Do NOT kill them on session_shutdown — they manage their own lifecycle.
779
- // Only kill foreground child processes (handled above via abort controllers).
780
- // See Bug #17: killing async runners on session_shutdown was the root cause
781
- // of the "background runner dies at ~35s" bug.
782
- // try {
783
- // for (const manifest of manifestCache.list(50)) {
784
- // if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) {
785
- // killProcessPid(manifest.async.pid);
786
- // }
787
- // }
788
- // } catch (error) {
789
- // logInternalError("register.cleanupRuntime.killAsync", error);
790
- // }
791
-
792
- // P0: Purge all stale active-run-index entries on session cleanup.
793
- // This handles: normal exit, SIGTERM, Ctrl+C — any case where cleanupRuntime fires.
794
- // For SIGKILL / crash / SIGHUP (where cleanupRuntime does NOT fire),
795
- // purgeStaleActiveRunIndex() runs at next session_start instead.
796
- // 2.7: only purge if crash-recovery has been loaded already; otherwise
797
- // the next session_start will fire the lazy import + purge.
798
- purgeStaleActiveRunIndexSyncIfLoaded();
799
-
800
- stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
801
- clearPiCrewPowerbar(pi.events);
802
- disposePowerbarCoalescer();
803
- // H3-L2 split: observability disposal delegated to registration/observability.ts.
804
- disposeObservability(observabilityState, cleanedUp);
805
- lifecycleState.deliveryCoordinator?.dispose();
806
- clearHooksScoped();
807
- uninstallCrewGlobalRegistry();
808
- lifecycleState.overflowTracker?.dispose();
809
- lifecycleState.deliveryCoordinator = undefined;
810
- lifecycleState.overflowTracker = undefined;
811
- manifestCache.dispose();
812
- runSnapshotCache.dispose?.();
813
- // 2.10: drop cached findRepoRoot results when the extension reloads.
814
- clearProjectRootCache();
815
- renderScheduler?.dispose();
816
- renderScheduler = undefined;
817
- autoRecoveryLast.clear();
818
- // H3-L2 split: notification disposal delegated to registration/lifecycle.ts.
819
- disposeNotifications(lifecycleState);
820
- rpcHandle?.unsubscribe();
821
- rpcHandle = undefined;
822
- disposeI18n();
823
- sessionGeneration += 1;
824
- currentCtx = undefined;
825
- if (globalStore[runtimeCleanupStoreKey] === cleanupRuntime) delete globalStore[runtimeCleanupStoreKey];
826
- };
827
- globalStore[runtimeCleanupStoreKey] = cleanupRuntime;
828
-
829
- // P0 fix: Check session_shutdown reason to determine whether to abort foreground runs.
830
- // - reason="quit" or "reload": Actual shutdown — abort foreground runs.
831
- // - reason="resume"/"new"/"fork": Session switch — let foreground runs complete.
832
- pi.on("session_shutdown", (event) => {
833
- const reason = typeof event === "object" && event !== null && "reason" in event ? (event as { reason: string }).reason : undefined;
834
- if (reason === "quit" || reason === "reload") {
835
- // Actual shutdown — abort foreground runs and cleanup everything
836
- cleanupRuntime();
837
- } else {
838
- // Session switch (resume/new/fork) — cleanup resources but preserve foreground runs
839
- cleanupSessionResourcesOnly();
840
- }
841
- });
842
-
843
- pi.on("session_start", (_event, ctx) => {
844
- runArtifactCleanup(ctx.cwd);
845
-
846
- // Restore brief mode state from session entries
847
- try {
848
- const entries = ctx.sessionManager?.getEntries?.();
849
- if (entries) {
850
- import("../ui/tool-renderers/brief-mode.ts")
851
- .then(({ restoreBriefState }) => {
852
- restoreBriefState(entries);
853
- })
854
- .catch(() => {
855
- /* non-critical */
856
- });
857
- }
858
- } catch {
859
- /* non-critical */
860
- }
861
-
862
- time("register.session-start");
863
- cleanedUp = false;
864
- sessionGeneration++;
865
- const ownerGeneration = sessionGeneration;
866
- currentCtx = ctx;
867
- // Round 13 UX: register the crew natural-language autocomplete provider
868
- // once we have a UI context. Guarded so repeated session_start events
869
- // don't stack wrappers (each wrapper delegates, but stacking wastes
870
- // call depth).
871
- if (!crewAutocompleteRegistered) {
872
- crewAutocompleteRegistered = true;
873
- registerCrewAutocomplete(ctx);
874
- }
875
- if (widgetState.interval) clearInterval(widgetState.interval);
876
- widgetState.interval = undefined;
877
- notifyActiveRuns(ctx);
878
-
879
- // Auto-cancel orphaned runs from dead sessions
880
- // Extract sessionId from context via the shared safe accessor (handles
881
- // untyped runtime property + defensive against exotic objects).
882
- const currentSessionId = extractSessionId(ctx);
883
-
884
- // Defer ALL heavy cleanup to after the session_start handler returns.
885
- // These operations involve synchronous directory scanning (readdirSync, readFileSync)
886
- // which can take 100ms–1s+ on Windows. They MUST NOT block the session_start event.
887
- setTimeout(() => {
888
- if (cleanedUp || sessionGeneration !== ownerGeneration) return; // session switched while we waited
889
-
890
- // 2.7: load crash-recovery lazily once per session_start cleanup batch.
891
- void (async () => {
892
- let crashRecovery: Awaited<ReturnType<typeof importCrashRecovery>> | undefined;
893
- try {
894
- crashRecovery = await importCrashRecovery();
895
- } catch (error) {
896
- logInternalError("register.sessionStart.lazyCrashRecovery", error);
897
- return;
898
- }
899
- if (cleanedUp || sessionGeneration !== ownerGeneration) return;
900
- const { cancelOrphanedRuns: cancelOrphanedRunsFn, purgeStaleActiveRunIndex: purgeStaleActiveRunIndexFn } = crashRecovery;
901
-
902
- // Auto-cancel orphaned runs
903
- if (currentSessionId) {
904
- try {
905
- const { cancelled } = cancelOrphanedRunsFn(ctx.cwd, getManifestCache(ctx.cwd), currentSessionId);
906
- if (cancelled.length > 0) {
907
- notifyOperator({
908
- id: `orphan_cleanup`,
909
- severity: "info",
910
- source: "crash-recovery",
911
- title: `Cleaned up ${cancelled.length} orphaned run(s)`,
912
- body: `Runs from previous sessions were auto-cancelled: ${cancelled.join(", ")}`,
913
- });
914
- }
915
- } catch (error) {
916
- logInternalError("register.sessionStart.orphanCleanup", error);
917
- }
918
- }
919
-
920
- // Startup cleanup (Fix A): run orphan-temp-dir cleanup
921
- // immediately on session_start so we don't wait 5 minutes
922
- // for the first timer tick. Especially important after
923
- // a SIGKILL'd previous session that left thousands of
924
- // orphan temp dirs behind.
925
- try {
926
- const orphanTmp = cleanupOrphanTempDirs();
927
- const legacyTmp = cleanupLegacyOrphanTempDirs();
928
- if (orphanTmp.cleaned > 0 || legacyTmp.cleaned > 0) {
929
- notifyOperator({
930
- id: `startup_temp_cleanup_${Date.now()}`,
931
- severity: "info",
932
- source: "temp-cleanup",
933
- title: `Startup cleanup: removed ${orphanTmp.cleaned + legacyTmp.cleaned} orphan temp dir(s)`,
934
- body: `${orphanTmp.cleaned} from ~/.pi/agent/pi-crew/tmp/ + ${legacyTmp.cleaned} legacy /tmp/pi-crew-*`,
935
- });
936
- }
937
- } catch (error) {
938
- logInternalError("register.sessionStart.startupTempCleanup", error);
939
- }
940
-
941
- // Orphan worker cleanup (Fix B): kill stale background-runner
942
- // processes from previous (SIGKILL'd) sessions. Workers
943
- // detached via setsid+unref outlive the spawning pi
944
- // session, and the per-worker parent-guard is intentionally
945
- // disabled for background-runner (BUG #17 design). So
946
- // orphans can only be cleaned from the next session_start.
947
- try {
948
- const orphanWorkers = cleanupOrphanWorkers(currentSessionId);
949
- if (orphanWorkers.killed > 0) {
950
- notifyOperator({
951
- id: `orphan_workers_cleanup`,
952
- severity: "info",
953
- source: "worker-cleanup",
954
- title: `Cleaned up ${orphanWorkers.killed} orphan worker(s)`,
955
- body: `Background workers from previous (SIGKILL'd) sessions were terminated (pruned ${orphanWorkers.pruned} dead, kept ${orphanWorkers.kept}).`,
956
- });
957
- }
958
- } catch (error) {
959
- logInternalError("register.sessionStart.orphanWorkers", error);
960
- }
961
71
 
962
- // Global purge of stale active-run-index entries
963
- try {
964
- const { purged } = purgeStaleActiveRunIndexFn();
965
- if (purged.length > 0) {
966
- notifyOperator({
967
- id: `active_index_purge`,
968
- severity: "info",
969
- source: "crash-recovery",
970
- title: `Purged ${purged.length} stale active-run-index entr${purged.length === 1 ? "y" : "ies"}`,
971
- body: `Cleaned up global active run index`,
972
- });
973
- }
974
- } catch (error) {
975
- logInternalError("register.sessionStart.globalIndexPurge", error);
976
- }
977
- })();
72
+ registerPiTools(pi, ctx);
73
+ registerPiCommands(pi, ctx);
74
+ installPiHooks(pi, ctx);
75
+ installSessionLifecycleHandlers(pi, ctx);
978
76
 
979
- // Reconcile stale runs found on disk (not in active-run-index)
980
- // These are ghost runs from crashed processes that were never cleaned up.
981
- try {
982
- const staleResults = reconcileAllStaleRuns(ctx.cwd, getManifestCache(ctx.cwd)) ?? [];
983
- if (staleResults.length > 0) {
984
- notifyOperator({
985
- id: "stale_reconcile",
986
- severity: "info",
987
- source: "crash-recovery",
988
- title: "Reconciled " + staleResults.length + " stale run(s)",
989
- body: "Found and repaired ghost runs from previous sessions: " + staleResults.map((r) => r.runId).join(", "),
990
- });
991
- }
992
- } catch (error) {
993
- logInternalError("register.sessionStart.reconcileStale", error);
994
- }
995
-
996
- // Auto-prune finished project-level run directories (keep 10 most recent)
997
- try {
998
- const { removed } = pruneFinishedRuns(ctx.cwd, 10);
999
- if (removed.length > 0) {
1000
- notifyOperator({
1001
- id: `auto_prune_project`,
1002
- severity: "info",
1003
- source: "run-maintenance",
1004
- title: `Auto-pruned ${removed.length} finished project run(s)`,
1005
- body: `Removed old finished runs: ${removed.join(", ")}`,
1006
- });
1007
- }
1008
- } catch (error) {
1009
- logInternalError("register.sessionStart.autoPruneProject", error);
1010
- }
1011
-
1012
- // Auto-prune finished user-level run directories (keep 10 most recent)
1013
- try {
1014
- const { removed } = pruneUserLevelRuns(10);
1015
- if (removed.length > 0) {
1016
- notifyOperator({
1017
- id: `auto_prune_user`,
1018
- severity: "info",
1019
- source: "run-maintenance",
1020
- title: `Auto-pruned ${removed.length} finished user-level run(s)`,
1021
- body: `Removed old finished runs: ${removed.join(", ")}`,
1022
- });
1023
- }
1024
- } catch (error) {
1025
- logInternalError("register.sessionStart.autoPruneUser", error);
1026
- }
1027
- }, 0);
1028
-
1029
- const loadedConfig = loadConfig(ctx.cwd);
1030
- const crewSettings = loadCrewSettings(ctx.cwd);
1031
- applyCrewSettingsToConfig(loadedConfig.config, crewSettings);
1032
-
1033
- // Start scheduler with event-based executor
1034
- // Resolve sessionId before the scheduler executor closure captures it.
1035
- const sessionId =
1036
- ctx.sessionManager?.getSessionId?.() ??
1037
- (typeof ctx === "object" && ctx !== null && "sessionId" in ctx ? (ctx as Record<string, unknown>).sessionId : undefined);
1038
- crewScheduler = new CrewScheduler();
1039
- crewScheduler.start({
1040
- emit: (event) => {
1041
- if (cleanedUp) return;
1042
- pi.events?.emit?.("crew-scheduler", event);
1043
- },
1044
- executor: (job) => {
1045
- let runParams: { action: string; team: string; goal: string };
1046
- try {
1047
- runParams = JSON.parse(job.prompt);
1048
- } catch {
1049
- runParams = {
1050
- action: "run",
1051
- team: "default",
1052
- goal: job.prompt,
1053
- };
1054
- }
1055
- if (runParams.action !== "run") return `scheduled-${job.id}-${Date.now()}`;
1056
- const agentId = `scheduled-${job.id}-${Date.now()}`;
1057
- setImmediate(async () => {
1058
- try {
1059
- const runResult = await handleTeamTool(
1060
- {
1061
- action: "run",
1062
- team: runParams.team,
1063
- goal: runParams.goal,
1064
- async: true,
1065
- },
1066
- { cwd: ctx.cwd, sessionId },
1067
- );
1068
- // Track the runId so remove() can cancel spawned runs
1069
- const runId = runResult?.details?.runId;
1070
- if (runId && typeof runId === "string") {
1071
- crewScheduler?.recordSpawnedRun(job.id, runId);
1072
- // Update run manifest with scheduler provenance for traceability
1073
- try {
1074
- const cwd = ctx.cwd ?? process.cwd();
1075
- const loaded = loadRunManifestById(cwd, runId);
1076
- if (loaded) {
1077
- // LAZY: defer dynamic import of ../state/atomic-write.ts to its call site.
1078
- const { atomicWriteJson } = await import("../state/atomic-write.ts");
1079
- atomicWriteJson(loaded.manifest.stateRoot + "/manifest.json", {
1080
- ...loaded.manifest,
1081
- schedulerJobId: job.id,
1082
- schedulerName: job.name,
1083
- });
1084
- }
1085
- } catch {
1086
- /* best-effort provenance tracking */
1087
- }
1088
- }
1089
- // Persist updated job with spawnedRunIds to settings
1090
- try {
1091
- const updatedJob = crewScheduler?.list().find((j) => j.id === job.id);
1092
- if (updatedJob) persistScheduledJobUpdate(ctx.cwd, updatedJob);
1093
- } catch {
1094
- /* best-effort */
1095
- }
1096
- // Update run count
1097
- crewScheduler?.update(job.id, {
1098
- runCount: job.runCount + 1,
1099
- lastRun: new Date().toISOString(),
1100
- lastStatus: "success",
1101
- });
1102
- } catch (err) {
1103
- logInternalError("scheduler.execute", err);
1104
- crewScheduler?.update(job.id, { lastStatus: "error" });
1105
- }
1106
- });
1107
- return agentId;
1108
- },
1109
- finalizer: () => {},
1110
- runCancelFn: (runId: string) => {
1111
- try {
1112
- handleTeamTool({ action: "cancel", runId, confirm: true }, { cwd: ctx.cwd, sessionId }).catch((err) =>
1113
- logInternalError("scheduler.runCancelFn", err, `runId=${runId}`),
1114
- );
1115
- } catch (err) {
1116
- logInternalError("scheduler.runCancelFn.sync", err, `runId=${runId}`);
1117
- }
1118
- },
1119
- });
1120
- // Wire scheduler into handle-schedule.ts so handlers can add/list jobs.
1121
- // Uses a global symbol so the module doesn't need a direct circular import.
1122
- (globalThis as Record<symbol | string, unknown>)[Symbol.for("pi-crew:scheduler")] = crewScheduler;
1123
- // Load scheduled jobs from settings if present
1124
- if (Array.isArray(crewSettings.scheduledJobs)) {
1125
- for (const job of crewSettings.scheduledJobs) {
1126
- try {
1127
- crewScheduler.add(job as ScheduledJob);
1128
- } catch {
1129
- /* skip invalid */
1130
- }
1131
- }
1132
- }
1133
- autoRecoveryLast.clear();
1134
- configureNotifications(ctx);
1135
- configureObservability(ctx);
1136
- configureDeliveryCoordinator();
1137
- if (typeof sessionId === "string" && sessionId) lifecycleState.deliveryCoordinator?.activate(sessionId);
1138
- tryRegisterSessionCleanup(pi, () => {
1139
- terminateActiveChildPiProcesses();
1140
- cleanupRuntime();
1141
- });
1142
- registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
1143
- startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs, {
1144
- generation: ownerGeneration,
1145
- isCurrent: (generation) => generation === sessionGeneration && currentCtx === ctx && !cleanedUp,
1146
- });
1147
- const cache = getManifestCache(ctx.cwd);
1148
- updateCrewWidget(ctx, widgetState, loadedConfig.config.ui, cache, getRunSnapshotCache(ctx.cwd));
1149
- updatePiCrewPowerbar(
1150
- pi.events,
1151
- ctx.cwd,
1152
- loadedConfig.config.ui,
1153
- cache,
1154
- getRunSnapshotCache(ctx.cwd),
1155
- ctx,
1156
- widgetState.notificationCount ?? 0,
1157
- );
1158
- disposeRenderSchedulerSubscriptions();
1159
- renderScheduler?.dispose();
1160
- terminalStatus?.dispose();
1161
- terminalStatus = undefined;
1162
- terminalStatusActive = false;
1163
- // Phase 12: Async preloading — renderTick reads only a pre-computed frame
1164
- // from memory (zero fs I/O). Background preload refreshes the frame async.
1165
- let preloading = false;
1166
-
1167
- let lastPreloadedConfig: ReturnType<typeof loadConfig> | undefined;
1168
- let lastPreloadedManifests: TeamRunManifest[] = [];
1169
- let lastFrameManifestCache: ReturnType<typeof createManifestCache> | undefined;
1170
- let lastFrameSnapshotCache: ReturnType<typeof createRunSnapshotCache> | undefined;
1171
-
1172
- const buildFrame = async (): Promise<boolean> => {
1173
- if (!currentCtx) return false;
1174
- lastPreloadedConfig = loadConfig(currentCtx.cwd);
1175
- lastFrameManifestCache = getManifestCache(currentCtx.cwd);
1176
- lastFrameSnapshotCache = getRunSnapshotCache(currentCtx.cwd);
1177
- const manifests = lastFrameManifestCache.list(20);
1178
- lastPreloadedManifests = manifests;
1179
- // pts/2 hang fix: reconcile per-run watchers against the ACTIVE set only.
1180
- // This bounds inotify cost to O(active runs) — completed runs stop being
1181
- // watched as soon as they leave running/queued/planning status, instead of
1182
- // the recursive watcher watching the entire run history forever.
1183
- {
1184
- const onRunChange = (runId: string): void => {
1185
- if (cleanedUp || sessionGeneration !== ownerGeneration) return;
1186
- getRunSnapshotCache(currentCtx?.cwd ?? process.cwd()).invalidate(runId);
1187
- renderScheduler?.schedule({ runId });
1188
- };
1189
- const onWatchErr = (error: unknown): void => {
1190
- logInternalError("register.runWatcher.change", error);
1191
- };
1192
- const active = manifests
1193
- .filter((r) => r.status === "running" || r.status === "queued" || r.status === "planning")
1194
- .map((r) => ({ runId: r.runId, runDir: r.stateRoot }));
1195
- crewRunWatchers?.reconcile(active, onRunChange, onWatchErr);
1196
- userCrewWatchers?.reconcile(active, onRunChange, onWatchErr);
1197
- }
1198
- const runIds = manifests.map((r) => r.runId);
1199
- await lastFrameSnapshotCache.preloadAllStale(runIds);
1200
- return true;
1201
- };
1202
-
1203
- const backgroundPreload = (): void => {
1204
- if (!currentCtx || preloading) return;
1205
- preloading = true;
1206
- buildFrame()
1207
- .then((ok) => {
1208
- preloading = false;
1209
- if (ok) renderScheduler?.schedule();
1210
- })
1211
- .catch((error: unknown) => {
1212
- preloading = false;
1213
- logInternalError("register.backgroundPreload", error);
1214
- });
1215
- };
1216
-
1217
- const startPreloadLoop = (intervalMs: number, dynamicMs?: () => number): void => {
1218
- if (preloadTimer) clearTimeout(preloadTimer);
1219
- const tick = (): void => {
1220
- backgroundPreload();
1221
- const nextMs = dynamicMs?.() ?? intervalMs;
1222
- preloadTimer = setTimeout(tick, nextMs);
1223
- preloadTimer.unref();
1224
- };
1225
- preloadTimer = setTimeout(tick, intervalMs);
1226
- preloadTimer.unref();
1227
- };
1228
-
1229
- const renderTick = (): void => {
1230
- if (!currentCtx) return;
1231
- const config = lastPreloadedConfig?.config.ui;
1232
- const activeCache = lastFrameManifestCache ?? getManifestCache(currentCtx.cwd);
1233
- const snapshotCache = lastFrameSnapshotCache ?? getRunSnapshotCache(currentCtx.cwd);
1234
- // 1.1: keep render path zero-fs-IO. Always read from the preloaded
1235
- // frame; if it is empty (first tick after session_start, or cwd
1236
- // switched), kick off a background preload and render a skeleton
1237
- // (empty manifests). The preload will reschedule a render when the
1238
- // frame is ready, avoiding statSync(`runs/`) inside the hot path.
1239
- const manifests = lastPreloadedManifests;
1240
- if (!lastPreloadedConfig) backgroundPreload();
1241
- if (uiState.liveSidebarRunId) {
1242
- const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
1243
- if (widgetState.lastVisibility !== "hidden" || widgetState.lastPlacement !== placement) {
1244
- setExtensionWidget(currentCtx, "pi-crew", undefined, {
1245
- placement,
1246
- });
1247
- setExtensionWidget(currentCtx, "pi-crew-active", undefined, { placement });
1248
- widgetState.lastVisibility = "hidden";
1249
- widgetState.lastPlacement = placement;
1250
- widgetState.lastKey = "pi-crew-active";
1251
- widgetState.model = undefined;
1252
- }
1253
- requestRender(currentCtx);
1254
- } else {
1255
- updateCrewWidget(currentCtx, widgetState, config, activeCache, snapshotCache, manifests);
1256
- }
1257
- requestPowerbarUpdate(
1258
- pi.events,
1259
- currentCtx.cwd,
1260
- config,
1261
- activeCache,
1262
- snapshotCache,
1263
- currentCtx,
1264
- widgetState.notificationCount ?? 0,
1265
- manifests,
1266
- );
1267
- // Health notifications: only warn about genuinely running runs
1268
- // Filter to only current session's runs to prevent cross-session notification leakage
1269
- const currentSessionGen = sessionGeneration;
1270
- const currentSessionId = currentCtx ? (currentCtx as { sessionId?: string }).sessionId : undefined;
1271
- const sessionManifests = manifests.filter(
1272
- (run) =>
1273
- !run.ownerSessionId ||
1274
- run.ownerSessionId === currentSessionId ||
1275
- (run as unknown as Record<string, unknown>).ownerSessionGeneration === currentSessionGen,
1276
- );
1277
- const now = Date.now();
1278
- for (const run of sessionManifests) {
1279
- if (run.status !== "running") continue;
1280
- try {
1281
- const snapshot = snapshotCache.get(run.runId);
1282
- if (!snapshot) continue;
1283
- // Skip if snapshot shows run already completed/failed (stale cache)
1284
- if (snapshot.manifest.status !== "running") continue;
1285
- const summary = summarizeHeartbeats(snapshot, { now });
1286
- const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
1287
- if (count <= 0) return;
1288
- const key = `${kind}_${run.runId}`;
1289
- const previous = autoRecoveryLast.get(key);
1290
- if (previous !== undefined && now - previous.lastAccessAt < 5 * 60_000) return;
1291
- // Defensive cap: evict entry with oldest lastAccessAt before
1292
- // inserting/updating when size exceeds the limit. Uses LRU
1293
- // semantics so entries that are still being actively
1294
- // accessed survive longer than insertion-order eviction.
1295
- while (autoRecoveryLast.size >= AUTO_RECOVERY_LAST_MAX_ENTRIES) {
1296
- let oldestKey: string | undefined;
1297
- let oldestAccess = Infinity;
1298
- for (const [k, v] of autoRecoveryLast) {
1299
- if (v.lastAccessAt < oldestAccess) {
1300
- oldestAccess = v.lastAccessAt;
1301
- oldestKey = k;
1302
- }
1303
- }
1304
- if (oldestKey === undefined) break;
1305
- autoRecoveryLast.delete(oldestKey);
1306
- }
1307
- autoRecoveryLast.set(key, {
1308
- insertedAt: now,
1309
- lastAccessAt: now,
1310
- });
1311
- notifyOperator({
1312
- id: key,
1313
- severity: "warning",
1314
- source: "health",
1315
- runId: run.runId,
1316
- title,
1317
- body,
1318
- });
1319
- };
1320
- maybeNotifyHealth(
1321
- "recovery_dead_workers",
1322
- summary.dead,
1323
- `Run ${run.runId} has ${summary.dead} dead worker(s).`,
1324
- "Open /team-dashboard → 5 health → R recovery / K kill stale / D diagnostic.",
1325
- );
1326
- maybeNotifyHealth(
1327
- "recovery_missing_heartbeat",
1328
- summary.missing,
1329
- `Run ${run.runId} has ${summary.missing} worker(s) missing heartbeat.`,
1330
- "Open /team-dashboard → 5 health → inspect health actions.",
1331
- );
1332
- } catch (error) {
1333
- logInternalError("register.health-notification", error, run.runId);
1334
- }
1335
- }
1336
- };
1337
-
1338
- const fallbackMs = loadedConfig.config.ui?.dashboardLiveRefreshMs ?? DEFAULT_UI.refreshMs;
1339
- // R3: Use faster refresh when live agents OR background runs are running.
1340
- // 160ms is aligned with SUBAGENT_SPINNER_FRAME_MS so the spinner advances
1341
- // one frame per render tick when a run is active. Falls back to the
1342
- // (slower) configured refresh when idle to save CPU.
1343
- const liveRefreshMs = 160;
1344
- const hasActiveWork = (): boolean => {
1345
- if (listLiveAgents().some((a) => a.status === "running")) return true;
1346
- return lastPreloadedManifests.some((r) => r.status === "running" || r.status === "queued" || r.status === "planning");
1347
- };
1348
- const effectiveRefreshMs = () => (hasActiveWork() ? liveRefreshMs : fallbackMs);
1349
- renderScheduler = new RenderScheduler(pi.events, renderTick, {
1350
- // Dynamic fallback: same logic as preload loop so the render timer
1351
- // also ticks at spinner frequency while a run is active.
1352
- fallbackMs: effectiveRefreshMs,
1353
- onInvalidate: (payload: unknown) => {
1354
- // Invalidate only the specific run, not the entire cache.
1355
- // Full cache.clear() causes widget flicker — the widget component's
1356
- // render() may run before renderTick rebuilds the preloaded frame,
1357
- // seeing an empty cache and returning no agents.
1358
- const runId =
1359
- typeof payload === "object" &&
1360
- payload !== null &&
1361
- "runId" in payload &&
1362
- typeof (payload as { runId: unknown }).runId === "string"
1363
- ? (payload as { runId: string }).runId
1364
- : undefined;
1365
- getRunSnapshotCache(ctx.cwd).invalidate(runId);
1366
- },
1367
- });
1368
- // Fix D: bridge internal runEventBus events (task_started/completed/etc)
1369
- // to renderScheduler so the UI re-renders within debounceMs of any agent
1370
- // lifecycle event — not just every fallback tick. Without this, short-lived
1371
- // workers can appear and disappear before the user sees them.
1372
- const sched = renderScheduler;
1373
- const unsubscribeRunEvents = runEventBus.onAny((event) => {
1374
- sched.schedule({
1375
- runId: event.runId,
1376
- source: "runEventBus",
1377
- type: event.type,
1378
- });
1379
- });
1380
- renderSchedulerUnsubscribers.push(unsubscribeRunEvents);
1381
- // Start async preload loop — refreshes snapshot cache in background
1382
- startPreloadLoop(fallbackMs, effectiveRefreshMs);
1383
- // 1.3: BOUNDED run watcher (pts/2 hang fix 2026-06-16). Previously this was
1384
- // a RECURSIVE fs.watch(<state>, {recursive:true}) which on Linux expands to
1385
- // ONE inotify watch PER SUBDIR — with many historical runs under
1386
- // .crew/state/runs/ this ballooned to hundreds of watches (109→339 observed)
1387
- // and the event volume caused a permanent busy-loop (71% CPU, 400KB/s read).
1388
- // Now: a single non-recursive watcher on the runs/ ROOT (to detect new run
1389
- // dirs appearing — crew.run.created is never emitted) plus per-active-run
1390
- // watchers reconciled each preload tick in buildFrame. Total inotify cost is
1391
- // O(active runs), not O(total history). Falls back to poll-only (the preload
1392
- // loop already polls every effectiveRefreshMs) on systems where fs.watch
1393
- // errors or the runs dir is absent.
1394
- const crewRunWatcherOnChange = (runId: string): void => {
1395
- if (cleanedUp || sessionGeneration !== ownerGeneration) return;
1396
- getRunSnapshotCache(currentCtx?.cwd ?? process.cwd()).invalidate(runId);
1397
- renderScheduler?.schedule({ runId });
1398
- };
1399
- const crewRunWatcherOnError = (error: unknown): void => {
1400
- logInternalError("register.crewRunWatchers.error", error);
1401
- };
1402
- try {
1403
- crewRunWatchers?.closeAll();
1404
- crewRunWatchers = undefined;
1405
- const crewRunsDir = path.join(projectCrewRoot(ctx.cwd), "state", "runs");
1406
- if (fs.existsSync(crewRunsDir)) {
1407
- crewRunWatchers = new RunWatcherRegistry();
1408
- crewRunWatchers.setRootWatcher(crewRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
1409
- }
1410
- } catch (error) {
1411
- logInternalError("register.crewRunWatchers.start", error);
1412
- }
1413
- // Also watch user-level runs dir — fast-fix and other user-scoped runs
1414
- // write manifests there. Without this, runs completing in user-level
1415
- // state never trigger cache invalidation, causing ghost "running" entries.
1416
- try {
1417
- userCrewWatchers?.closeAll();
1418
- userCrewWatchers = undefined;
1419
- const userRunsDir = path.join(userCrewRoot(), "state", "runs");
1420
- if (fs.existsSync(userRunsDir)) {
1421
- userCrewWatchers = new RunWatcherRegistry();
1422
- userCrewWatchers.setRootWatcher(userRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
1423
- }
1424
- } catch (error) {
1425
- logInternalError("register.userCrewWatchers.start", error);
1426
- }
1427
- // Kick an immediate preload so the first buildFrame reconciles per-run
1428
- // watchers for any runs that are already active on session start.
1429
- backgroundPreload();
1430
- });
1431
- pi.on("session_before_switch", () => {
1432
- sessionGeneration++;
1433
- const pendingCount = lifecycleState.deliveryCoordinator?.getPendingCount() ?? 0;
1434
- try {
1435
- const activeRuns = currentCtx
1436
- ? getManifestCache(currentCtx.cwd)
1437
- .list(50)
1438
- .filter((run) => run.status === "running" || run.status === "queued" || run.status === "blocked")
1439
- : [];
1440
- const snapshot = createSessionSnapshot(activeRuns, pendingCount, sessionGeneration);
1441
- if (pendingCount > 0 || snapshot.activeRunIds.length > 0)
1442
- logInternalError("register.session-before-switch", undefined, JSON.stringify(snapshot));
1443
- } catch (error) {
1444
- logInternalError("register.session-before-switch.snapshot", error);
1445
- }
1446
- if (pendingCount > 0) {
1447
- logInternalError("register.session-before-switch", `Switching session with ${pendingCount} pending deliveries`);
1448
- }
1449
- lifecycleState.deliveryCoordinator?.deactivate();
1450
- resetPowerbarDedupState();
1451
- stopAsyncRunNotifier(notifierState);
1452
- stopSessionBoundSubagents();
1453
- });
1454
-
1455
- // Phase 11a: Dynamic resource discovery — inject pi-crew skill paths.
1456
- try {
1457
- pi.on("resources_discover", () => {
1458
- const sessionCwd = currentCtx?.cwd ?? process.cwd();
1459
- const skillDir = path.resolve(sessionCwd, "skills");
1460
- const extSkillDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
1461
- const paths: string[] = [];
1462
- if (fs.existsSync(extSkillDir)) paths.push(extSkillDir);
1463
- if (skillDir !== extSkillDir && fs.existsSync(skillDir)) {
1464
- // Validate skillDir is within sessionCwd to prevent path traversal
1465
- try {
1466
- resolveContainedPath(sessionCwd, "skills");
1467
- paths.push(skillDir);
1468
- } catch {
1469
- // skillDir outside sessionCwd boundary — skip
1470
- }
1471
- }
1472
- return paths.length > 0 ? { skillPaths: paths } : {};
1473
- });
1474
- } catch {
1475
- /* older Pi without resources_discover */
1476
- }
1477
-
1478
- const abortForegroundRun = (runId: string): boolean => {
1479
- const controller = foregroundTeamRunControllers.get(runId);
1480
- if (!controller) return false;
1481
- controller.abort();
1482
- return true;
1483
- };
77
+ registerCleanupHandler(pi);
1484
78
  registerCompactionGuard(pi, {
1485
- foregroundControllers,
1486
- foregroundTeamRunControllers,
1487
- });
1488
-
1489
- // Phase 1.4: Permission gate for destructive team actions.
1490
- // AGENTS.md requires confirm=true for management deletes.
1491
- pi.on("tool_call", async (event, ctx) => {
1492
- if (event.toolName !== "team") return;
1493
- const rawInput = event.input;
1494
- if (!rawInput || typeof rawInput !== "object") return;
1495
- const input = asRecord(rawInput);
1496
- if (!input) return;
1497
- const action = typeof input.action === "string" ? input.action : undefined;
1498
- const reason = shouldBlockDestructiveTeamAction(action, input);
1499
- if (!reason) return;
1500
- return {
1501
- block: true as const,
1502
- reason,
1503
- };
1504
- });
1505
-
1506
- // T5 (v0.8.5): per-write validation. On write/edit, run a zero-cost
1507
- // SYNCHRONOUS validator (v1: JSON.parse) and append a 🔴 blocker to the
1508
- // tool result on failure — catches malformed config the moment it's
1509
- // written, not at the next load. Latency-safe by construction: no process
1510
- // spawn, one disk read ONLY for validated extensions, dedup'd by content.
1511
- // Toggle via runtime.reliability.perWriteValidation (default true).
1512
- // Process-spawning validators (.js/.sh/.py) are a future opt-in.
1513
- pi.on("tool_result", (event, ctx) => {
1514
- try {
1515
- if (event.toolName !== "write" && event.toolName !== "edit") return;
1516
- if (loadConfig(ctx.cwd).config.reliability?.perWriteValidation === false) return;
1517
- const filePath = extractPathFromInput(event.input);
1518
- if (!filePath) return;
1519
- const result = validateWrittenFile(filePath);
1520
- if (!result || result.ok) return;
1521
- return {
1522
- content: [...event.content, buildValidationBlocker(filePath, result.error ?? "validation failed")],
1523
- };
1524
- } catch {
1525
- // best-effort: never break a tool result
1526
- }
79
+ foregroundControllers: ctx.foregroundControllers,
80
+ foregroundTeamRunControllers: ctx.foregroundTeamRunControllers,
1527
81
  });
1528
82
 
1529
- registerTeamTool(pi, {
1530
- foregroundControllers,
1531
- startForegroundRun,
1532
- abortForegroundRun,
1533
- openLiveSidebar,
1534
- getManifestCache,
1535
- getRunSnapshotCache,
1536
- getMetricRegistry: () => observabilityState.metricRegistry,
1537
- widgetState,
1538
- onJsonEvent: (taskId, runId, event) => {
1539
- const record = event as Record<string, unknown>;
1540
- const eventType = typeof record.type === "string" ? record.type : undefined;
1541
- if (eventType) lifecycleState.overflowTracker?.feedEvent(taskId, runId, eventType);
1542
- },
1543
- });
1544
- registerSubagentTools(pi, subagentManager, {
1545
- ownerSessionGeneration: captureSessionGeneration,
1546
- startForegroundRun: (ctx, runner, runId) => startForegroundRun(ctx as ExtensionContext, runner, runId),
1547
- batchBarrier,
1548
- });
1549
- time("register.tools");
1550
-
1551
- registerCleanupHandler(pi);
1552
-
1553
- // Resilient edit (Phase 2): OPT-IN via CREW_RESILIENT_EDIT=1.
1554
- // Wraps the native edit tool with the cascading replace() fallback so that
1555
- // slightly-wrong oldText (indentation drift, whitespace) still matches.
1556
- // Auto-disables if pi-diff is loaded (it has its own replace integration).
1557
83
  if (process.env.CREW_RESILIENT_EDIT === "1") {
84
+ // LAZY: resilient-edit only loads when the opt-in env var is set.
1558
85
  import("../runtime/resilient-edit.ts")
1559
- .then(({ wrapEditWithResilientReplace }) => {
1560
- wrapEditWithResilientReplace(pi);
1561
- })
86
+ .then(({ wrapEditWithResilientReplace }) => wrapEditWithResilientReplace(pi))
1562
87
  .catch(() => {
1563
88
  /* non-critical */
1564
89
  });
1565
90
  }
1566
91
 
1567
- registerTeamCommands(pi, {
1568
- startForegroundRun,
1569
- abortForegroundRun,
1570
- openLiveSidebar,
1571
- getManifestCache,
1572
- getRunSnapshotCache,
1573
- getMetricRegistry: () => observabilityState.metricRegistry,
1574
- dismissNotifications: () => {
1575
- widgetState.notificationCount = 0;
1576
- if (currentCtx) {
1577
- const uiConfig = loadConfig(currentCtx.cwd).config.ui;
1578
- updateCrewWidget(currentCtx, widgetState, uiConfig, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd));
1579
- updatePiCrewPowerbar(
1580
- pi.events,
1581
- currentCtx.cwd,
1582
- uiConfig,
1583
- getManifestCache(currentCtx.cwd),
1584
- getRunSnapshotCache(currentCtx.cwd),
1585
- currentCtx,
1586
- 0,
1587
- );
1588
- }
1589
- },
1590
- });
1591
-
1592
- // Round 13 UX: render pi-crew lifecycle entries (crew:run-started,
1593
- // crew:run-completed, crew:resume-directive) with a branded look instead
1594
- // of raw JSON. No-op on Pi versions without registerMessageRenderer.
1595
92
  registerCrewMessageRenderers(pi);
1596
-
1597
- // Round 13 UX: natural-language crew input routing. Lets users type
1598
- // "crew status" instead of remembering /team-status. Only transforms
1599
- // interactive input that starts with a crew/team keyword phrase; never
1600
- // shadows explicit slash commands.
1601
93
  registerCrewInputRouter(pi);
1602
-
1603
- // Round 13 UX: keyboard shortcuts. alt+s opens the settings overlay
1604
- // (config + theme picker). Keys chosen to avoid Pi's built-in keymap.
1605
- // (The crew autocomplete provider is registered from session_start once
1606
- // a UI context is available — see the session_start handler below.)
1607
94
  registerCrewShortcuts(pi);
1608
-
1609
- // GAP-2 (Round 11): ambient crew-status injection. Registers a `context`
1610
- // event handler that appends a compact in-flight-runs note to the agent
1611
- // context on every LLM call, so the agent never "forgets" active runs.
1612
- // Transient per-call (does not pollute history), and a no-op when no runs
1613
- // are in-flight. Toggle via runtime.reliability.ambientStatusInjection.
1614
95
  registerContextStatusInjection(pi, {
1615
96
  enabled: loadConfig(process.cwd()).config.reliability?.ambientStatusInjection !== false,
1616
97
  });
@@ -1621,3 +102,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1621
102
  console.warn("[pi-crew] crew-vibes initialization failed:", err instanceof Error ? err.message : err);
1622
103
  }
1623
104
  }
105
+
106
+ // Bottom-of-file import: keeps compaction-guard out of the top-level
107
+ // import surface to avoid pulling its heavy transitive deps.
108
+ import { registerCompactionGuard } from "./registration/compaction-guard.ts";