pi-crew 0.11.1 → 0.11.2

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 (121) hide show
  1. package/CHANGELOG.md +39 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +193 -36
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +23 -0
  20. package/src/extension/help.ts +21 -10
  21. package/src/extension/knowledge-injection.ts +2 -1
  22. package/src/extension/management.ts +8 -3
  23. package/src/extension/notification-sink.ts +17 -0
  24. package/src/extension/registration/command-utils.ts +28 -2
  25. package/src/extension/registration/commands/dashboard.ts +11 -1
  26. package/src/extension/registration/commands/manage.ts +31 -15
  27. package/src/extension/registration/commands/run.ts +24 -2
  28. package/src/extension/registration/commands/shared.ts +23 -0
  29. package/src/extension/registration/commands/status.ts +25 -2
  30. package/src/extension/registration/context-builder.ts +8 -2
  31. package/src/extension/registration/health-notify-policy.ts +100 -0
  32. package/src/extension/registration/lazy-configurers.ts +35 -0
  33. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  34. package/src/extension/registration/lifecycle.ts +75 -10
  35. package/src/extension/registration/observability.ts +98 -35
  36. package/src/extension/registration/registration-types.ts +7 -5
  37. package/src/extension/registration/runtime-cleanup.ts +9 -3
  38. package/src/extension/registration/subagent-helpers.ts +38 -0
  39. package/src/extension/registration/wire-cross-extension.ts +28 -0
  40. package/src/extension/run-compare.ts +220 -0
  41. package/src/extension/run-export.ts +37 -5
  42. package/src/extension/run-maintenance.ts +155 -5
  43. package/src/extension/team-tool/dispatch/index.ts +3 -2
  44. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  45. package/src/extension/team-tool/goal.ts +4 -1
  46. package/src/extension/team-tool/handle-settings.ts +19 -2
  47. package/src/extension/team-tool/health-monitor.ts +21 -7
  48. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  49. package/src/extension/team-tool/plan.ts +10 -0
  50. package/src/extension/team-tool/routing-hint.ts +63 -0
  51. package/src/extension/team-tool/status.ts +4 -0
  52. package/src/extension/team-tool.ts +52 -6
  53. package/src/extension/webhook-notify.ts +382 -0
  54. package/src/observability/metric-sink.ts +12 -2
  55. package/src/prompt/prompt-runtime.ts +82 -31
  56. package/src/prompt/worker-events-channel.ts +12 -0
  57. package/src/runtime/README.md +1 -1
  58. package/src/runtime/async-runner.ts +87 -1
  59. package/src/runtime/background-runner.ts +313 -234
  60. package/src/runtime/broker/crew-broker.ts +17 -11
  61. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  62. package/src/runtime/broker/wait-status-cache.ts +1 -1
  63. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  64. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  65. package/src/runtime/crew-agent-records.ts +337 -45
  66. package/src/runtime/deadletter.ts +43 -1
  67. package/src/runtime/delegate-spawn.ts +5 -1
  68. package/src/runtime/dispatch-batch.ts +72 -5
  69. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  70. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  71. package/src/runtime/model/model-fallback.ts +21 -1
  72. package/src/runtime/model/pi-args.ts +8 -10
  73. package/src/runtime/recovery/crash-recovery.ts +25 -1
  74. package/src/runtime/run-worker.ts +12 -1
  75. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  76. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  77. package/src/runtime/scheduling/scheduler.ts +49 -13
  78. package/src/runtime/scheduling/semaphore.ts +148 -20
  79. package/src/runtime/scratchpad/README.md +1 -1
  80. package/src/runtime/scratchpad/protocol.ts +1 -1
  81. package/src/runtime/settings-store.ts +1 -1
  82. package/src/runtime/skill-instructions.ts +22 -0
  83. package/src/runtime/stale-reconciler.ts +85 -13
  84. package/src/runtime/task-runner/pre-execution.ts +26 -2
  85. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  86. package/src/runtime/task-runner.ts +21 -1
  87. package/src/runtime/team-runner.ts +38 -1
  88. package/src/runtime/workspace-lock.ts +4 -1
  89. package/src/schema/config-schema.ts +14 -0
  90. package/src/schema/team-tool-schema.ts +17 -0
  91. package/src/state/atomic-write.ts +53 -0
  92. package/src/state/contracts.ts +109 -0
  93. package/src/state/coordination/locks.ts +191 -33
  94. package/src/state/coordination/mailbox.ts +140 -15
  95. package/src/state/crew-init.ts +87 -12
  96. package/src/state/event-log/cursor.ts +37 -1
  97. package/src/state/event-log/event-log-rotation.ts +72 -7
  98. package/src/state/stores/active-run-registry.ts +13 -1
  99. package/src/state/stores/state-store.ts +112 -22
  100. package/src/state/types.ts +4 -0
  101. package/src/ui/dashboard-panes/agents-pane.ts +11 -2
  102. package/src/ui/heartbeat-aggregator.ts +34 -0
  103. package/src/ui/keybinding-map.ts +22 -4
  104. package/src/ui/live-conversation-overlay.ts +6 -3
  105. package/src/ui/run-dashboard.ts +98 -5
  106. package/src/ui/run-snapshot-cache.ts +18 -1
  107. package/src/ui/spinner.ts +26 -2
  108. package/src/ui/tool-progress-formatter.ts +2 -1
  109. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  110. package/src/ui/tool-renderers/index.ts +3 -3
  111. package/src/utils/incremental-reader.ts +11 -3
  112. package/src/utils/paths.ts +94 -12
  113. package/src/utils/project-markers.ts +40 -0
  114. package/src/worktree/worktree-manager.ts +206 -26
  115. package/workflows/distill.workflow.md +3 -3
  116. package/workflows/fast-fix.workflow.md +1 -1
  117. package/workflows/plan-execute.workflow.md +1 -1
  118. package/workflows/review.workflow.md +1 -1
  119. package/workflows/strict-fast-fix.workflow.md +1 -1
  120. package/docs/migration-v0.4-v0.5.md +0 -208
  121. package/docs/runtime-flow.md +0 -148
@@ -49,6 +49,13 @@ export interface ObservabilityState {
49
49
  autoRepairTimer: ReturnType<typeof setInterval> | undefined;
50
50
  tempReconcileTimer: ReturnType<typeof setInterval> | undefined;
51
51
  otlpExporter: OTLPExporterInstance | undefined;
52
+ /**
53
+ * F11 (RR-018): promise of the most recent `configureObservability` run.
54
+ * `disposeObservability` settles it deliberately so teardown never races a
55
+ * still-suspended init continuation (which re-checks ownership after every
56
+ * await and self-disposes its locals when ownership was lost).
57
+ */
58
+ initPromise: Promise<void> | undefined;
52
59
  }
53
60
 
54
61
  /** Dependencies passed in by register.ts so this module stays decoupled. */
@@ -57,6 +64,13 @@ export interface ObservabilityDeps {
57
64
  getManifestCache: (cwd: string) => ReturnType<typeof import("../../runtime/manifest-cache.ts").createManifestCache>;
58
65
  notifyOperator: (notification: NotificationDescriptor) => void;
59
66
  isCleanedUp: () => boolean;
67
+ /**
68
+ * F11 (RR-018): read the RegistrationContext's `sessionGeneration`. The
69
+ * generation is bumped by cleanup AND by every session_start/before_switch,
70
+ * so unlike `isCleanedUp` it can never be reset back to "still owner" by a
71
+ * newer session. Reuse this counter — do not invent a parallel one.
72
+ */
73
+ getSessionGeneration: () => number;
60
74
  reconcileStaleRuns: (cwd: string, cache: ReturnType<ObservabilityDeps["getManifestCache"]>, currentSessionId?: string) => unknown[];
61
75
  reconcileOrphanedTempWorkspaces: (now: number, opts: { cleanupOrphanedTempDirs?: boolean }) => unknown;
62
76
  cleanupOrphanTempDirs: () => { cleaned: number; scanned: number; failed: number };
@@ -94,43 +108,77 @@ async function importOTLPExporter(): Promise<OTLPExporterCtor> {
94
108
  * - `config.reliability?.autoRecover === true` → lazy-imports crash-recovery
95
109
  * on a deferred setTimeout to avoid blocking session_start.
96
110
  */
97
- export async function configureObservability(ctx: ExtensionContext, state: ObservabilityState, deps: ObservabilityDeps): Promise<void> {
111
+ export function configureObservability(ctx: ExtensionContext, state: ObservabilityState, deps: ObservabilityDeps): Promise<void> {
112
+ // F11: track the init promise so `disposeObservability` can settle it
113
+ // deliberately instead of racing it. The internal run first awaits any
114
+ // PREVIOUS init (via disposeObservability), so this assignment can never
115
+ // self-deadlock — state.initPromise still referenced the old run when the
116
+ // internal run suspended.
117
+ const run = configureObservabilityInternal(ctx, state, deps);
118
+ state.initPromise = run;
119
+ return run;
120
+ }
121
+
122
+ async function configureObservabilityInternal(ctx: ExtensionContext, state: ObservabilityState, deps: ObservabilityDeps): Promise<void> {
123
+ // F11 (RR-018): capture the session generation at configure time. Every
124
+ // await boundary below re-verifies ownership before touching shared state;
125
+ // a continuation whose generation no longer matches can never publish —
126
+ // even when a newer session has already reset `cleanedUp` back to false
127
+ // (the real ordering: cleanup sets it true, session_start resets it false).
128
+ const ownerGeneration = deps.getSessionGeneration();
129
+ const stillOwns = (): boolean => deps.getSessionGeneration() === ownerGeneration && !deps.isCleanedUp();
130
+
98
131
  // Always start from a clean slate: dispose any prior-session state first.
99
132
  await disposeObservability(state, deps.isCleanedUp());
133
+ if (!stillOwns()) return;
100
134
 
101
135
  const config = loadConfig(ctx.cwd).config;
102
136
  if (config.observability?.enabled === false) return;
103
137
 
104
138
  // LAZY: observability stack — only paid for when observability is actually enabled
105
139
  const { createMetricRegistry } = await import("../../observability/metric-registry.ts");
140
+ if (!stillOwns()) return;
106
141
  // LAZY: event→metric bridge
107
142
  const { wireEventToMetrics } = await import("../../observability/event-to-metric.ts");
143
+ if (!stillOwns()) return;
108
144
  // LAZY: file-backed metric sink
109
145
  const { createMetricFileSink } = await import("../../observability/metric-sink.ts");
146
+ if (!stillOwns()) return;
110
147
 
111
- state.metricRegistry = createMetricRegistry();
112
- if (deps.pi.events) {
113
- state.eventMetricSub = wireEventToMetrics(deps.pi.events, state.metricRegistry);
114
- }
115
- if (config.telemetry?.enabled !== false) {
116
- state.metricSink = createMetricFileSink({
117
- crewRoot: projectCrewRoot(ctx.cwd),
118
- registry: state.metricRegistry,
119
- retentionDays: config.observability?.metricRetentionDays ?? 7,
120
- });
148
+ // F11: create into LOCALS — shared state is only touched after ownership is
149
+ // re-verified. If ownership was lost in the gap, everything just created is
150
+ // disposed right here in the continuation instead of leaking (0 orphans).
151
+ const metricRegistry = createMetricRegistry();
152
+ const eventMetricSub = deps.pi.events ? wireEventToMetrics(deps.pi.events, metricRegistry) : undefined;
153
+ const metricSink =
154
+ config.telemetry?.enabled !== false
155
+ ? createMetricFileSink({
156
+ crewRoot: projectCrewRoot(ctx.cwd),
157
+ registry: metricRegistry,
158
+ retentionDays: config.observability?.metricRetentionDays ?? 7,
159
+ })
160
+ : undefined;
161
+ if (!stillOwns()) {
162
+ eventMetricSub?.dispose();
163
+ metricSink?.dispose();
164
+ metricRegistry.dispose();
165
+ return;
121
166
  }
167
+ state.metricRegistry = metricRegistry;
168
+ state.eventMetricSub = eventMetricSub;
169
+ state.metricSink = metricSink;
122
170
 
123
171
  // OTLP export is opt-in. Lazy-loaded via dynamic import.
124
172
  if (config.otlp?.enabled === true && config.otlp.endpoint) {
125
173
  const otlpEndpoint = config.otlp.endpoint;
126
174
  const otlpHeaders = config.otlp.headers;
127
175
  const otlpInterval = config.otlp.intervalMs;
128
- const owningRegistry = state.metricRegistry;
176
+ const owningRegistry = metricRegistry;
129
177
  // LAZY: opt-in OTLP export — load the exporter module on first enable.
130
178
  void importOTLPExporter()
131
179
  .then((Ctor) => {
132
- if (deps.isCleanedUp() || state.metricRegistry !== owningRegistry || !owningRegistry) return;
133
- state.otlpExporter = new Ctor(
180
+ if (!stillOwns() || state.metricRegistry !== owningRegistry || !owningRegistry) return;
181
+ const otlpExporter = new Ctor(
134
182
  {
135
183
  endpoint: otlpEndpoint,
136
184
  headers: otlpHeaders,
@@ -138,18 +186,20 @@ export async function configureObservability(ctx: ExtensionContext, state: Obser
138
186
  },
139
187
  owningRegistry,
140
188
  );
141
- state.otlpExporter?.start();
189
+ state.otlpExporter = otlpExporter;
190
+ otlpExporter.start();
142
191
  })
143
192
  .catch((error: unknown) => logInternalError("register.otlp-lazy-import", error));
144
193
  }
145
194
 
146
195
  // LAZY: heartbeat watcher — polled per-session, wires deadletter + metric events.
147
196
  const { HeartbeatWatcher } = await import("../../runtime/heartbeat/heartbeat-watcher.ts");
148
- state.heartbeatWatcher = new HeartbeatWatcher({
197
+ if (!stillOwns()) return;
198
+ const heartbeatWatcher = new HeartbeatWatcher({
149
199
  cwd: ctx.cwd,
150
200
  pollIntervalMs: config.observability?.pollIntervalMs ?? 5000,
151
201
  manifestCache: deps.getManifestCache(ctx.cwd),
152
- registry: state.metricRegistry,
202
+ registry: metricRegistry,
153
203
  router: {
154
204
  enqueue: (notification) => {
155
205
  deps.notifyOperator(notification);
@@ -173,25 +223,24 @@ export async function configureObservability(ctx: ExtensionContext, state: Obser
173
223
  });
174
224
  },
175
225
  });
176
- state.heartbeatWatcher.start();
226
+ // F11: the watcher is created into a local and only published once
227
+ // ownership is re-verified — a lost-ownership continuation disposes it
228
+ // instead of publishing a live poller nobody will tear down.
229
+ if (!stillOwns()) {
230
+ heartbeatWatcher.dispose();
231
+ return;
232
+ }
233
+ state.heartbeatWatcher = heartbeatWatcher;
234
+ heartbeatWatcher.start();
177
235
 
178
236
  // RT-F2: opportunistic stale-run reconcile hook.
179
- // The .unref()'d setInterval below never fires while the event loop is idle
180
- // (e.g. user has the editor open but hasn't sent a turn in an hour). Pair it
181
- // with a before_agent_start hook so reconcileAllStaleRuns also fires when
182
- // the user actually drives the session — bounded by user activity.
183
- try {
184
- deps.pi.on?.("before_agent_start", () => {
185
- if (deps.isCleanedUp()) return;
186
- try {
187
- deps.reconcileStaleRuns(ctx.cwd, deps.getManifestCache(ctx.cwd), extractSessionId(ctx));
188
- } catch (error) {
189
- logInternalError("register.autoRepair.turnHook", error);
190
- }
191
- });
192
- } catch {
193
- /* older Pi without before_agent_start — rely on the interval alone */
194
- }
237
+ // F12 (RR-018): the `before_agent_start` hook is NO LONGER registered here.
238
+ // It is registered exactly ONCE per extension in `lazy-configurers.ts`
239
+ // (installTurnReconcileHook) and resolves the CURRENT session context at
240
+ // fire time — per-session registration accumulated one dead hook per
241
+ // switch (there is no pi.off), and the stale hook re-activated with the
242
+ // old session's cwd, flipping the shared manifest cache back. The
243
+ // .unref()'d setInterval below remains the idle-time safety net.
195
244
 
196
245
  // Auto-repair timers: stale-run reconcile + orphan-temp cleanup.
197
246
  // RT-F2: default raised from 60_000ms to 5 minutes (300_000ms). The previous
@@ -273,7 +322,7 @@ export async function configureObservability(ctx: ExtensionContext, state: Obser
273
322
  void deps
274
323
  .importCrashRecovery()
275
324
  .then(({ detectInterruptedRuns }) => {
276
- if (deps.isCleanedUp()) return;
325
+ if (!stillOwns()) return;
277
326
  const sid = extractSessionId(ctx);
278
327
  for (const plan of detectInterruptedRuns(cwdSnapshot, cacheSnapshot, 300_000, sid)) {
279
328
  deps.notifyOperator({
@@ -296,6 +345,20 @@ export async function configureObservability(ctx: ExtensionContext, state: Obser
296
345
  * gated by the orchestrator's overall cleanup state.
297
346
  */
298
347
  export async function disposeObservability(state: ObservabilityState, _isCleanedUp: boolean): Promise<void> {
348
+ // F11 (RR-018): settle any in-flight init BEFORE disposing shared state, so
349
+ // teardown never races a still-suspended continuation. The continuation
350
+ // itself re-checks ownership after every await and self-disposes its
351
+ // locals, so this await only bounds the race window — it never starts new
352
+ // work and cannot deadlock (the previous init completes on its own).
353
+ const initPromise = state.initPromise;
354
+ state.initPromise = undefined;
355
+ if (initPromise) {
356
+ try {
357
+ await initPromise;
358
+ } catch {
359
+ /* configureObservabilityImpl logs its own errors */
360
+ }
361
+ }
299
362
  state.heartbeatWatcher?.dispose();
300
363
  state.heartbeatWatcher = undefined;
301
364
  if (state.autoRepairTimer) {
@@ -37,12 +37,14 @@ export type AbortControllerMap = Map<AbortKey, AbortController>;
37
37
  /**
38
38
  * Single auto-recovery cooldown gate entry. Tracked by the render-tick
39
39
  * health notifier so the same warning doesn't fire repeatedly within a
40
- * 5-minute cooldown. Uses LRU-like semantics — see register.ts.
40
+ * 5-minute cooldown, and (FINDING 6) at most MAX_HEALTH_NOTIFY_FIRES times
41
+ * per unchanged fingerprint. LRU-like semantics — see register.ts and
42
+ * health-notify-policy.ts (the authoritative definition; re-exported here
43
+ * so existing imports keep working).
41
44
  */
42
- export interface AutoRecoveryEntry {
43
- insertedAt: number;
44
- lastAccessAt: number;
45
- }
45
+ export type { AutoRecoveryEntry } from "./health-notify-policy.ts";
46
+
47
+ import type { AutoRecoveryEntry } from "./health-notify-policy.ts";
46
48
 
47
49
  /**
48
50
  * Mutable context bag passed from the orchestrator to every
@@ -93,7 +93,14 @@ function buildCleanupSessionResourcesOnly(ctx: RegistrationContext): () => void
93
93
  void disposeObservability(ctx.observabilityState, ctx.cleanedUp);
94
94
  ctx.lifecycleState.deliveryCoordinator?.dispose();
95
95
  clearHooksScoped();
96
- uninstallCrewGlobalRegistry();
96
+ // F13 (RR-018): RPC + the crew global registry are EXTENSION-lifetime
97
+ // resources — they are NOT torn down on a session switch. The RPC
98
+ // handle resolves the current session context at request time
99
+ // (wire-cross-extension.ts), and session_start rebinds the registry to
100
+ // the current session's manifest cache (refreshCrossExtensionWiringForSession).
101
+ // Removing them here used to kill RPC permanently for the process
102
+ // lifetime (4 pi-crew:rpc:* subscriptions → 0 after the first switch,
103
+ // never reinstalled). Full teardown still happens in cleanupRuntime.
97
104
  ctx.lifecycleState.overflowTracker?.dispose();
98
105
  ctx.lifecycleState.deliveryCoordinator = undefined;
99
106
  ctx.lifecycleState.overflowTracker = undefined;
@@ -104,8 +111,7 @@ function buildCleanupSessionResourcesOnly(ctx: RegistrationContext): () => void
104
111
  ctx.renderScheduler = undefined;
105
112
  ctx.autoRecoveryLast.clear();
106
113
  disposeNotifications(ctx.lifecycleState);
107
- ctx.rpcHandle?.unsubscribe();
108
- ctx.rpcHandle = undefined;
114
+ // F13: ctx.rpcHandle stays installed here (extension lifetime).
109
115
  ctx.disposeI18n();
110
116
  ctx.sessionGeneration += 1;
111
117
  ctx.currentCtx = undefined;
@@ -123,3 +123,41 @@ export function __test__subagentSpawnParams(params: Record<string, unknown>, ctx
123
123
  batchId: typeof params.batch_id === "string" && params.batch_id.trim() ? params.batch_id.trim() : undefined,
124
124
  };
125
125
  }
126
+
127
+ /**
128
+ * FINDING 6 (2026-09-23 battery): when a notification is cleared (e.g. the
129
+ * health monitor resolved a false/stale alert), copies of the ORIGINAL
130
+ * warning may still sit in the host session's follow-up queue — the host
131
+ * drains queued follow-ups one per turn boundary, so stale copies kept
132
+ * dripping into the parent conversation for hours after the clear.
133
+ *
134
+ * Feature-detected purge: newer pi runtimes expose
135
+ * `clearQueuedUserMessagesMatching(predicate)` (agent-session), which removes
136
+ * still-QUEUED messages whose text matches. Duck-typed here because the
137
+ * installed runtime surface (0.87.0) does not export it yet — on such hosts
138
+ * this is a documented no-op (returns false); the fire-cap policy in
139
+ * health-notify-policy.ts is the defense that works on all hosts.
140
+ */
141
+ interface QueuePurgeCapablePi {
142
+ clearQueuedUserMessagesMatching?: (predicate: (text: string) => boolean) => { steering: string[]; followUp: string[] };
143
+ session?: {
144
+ clearQueuedUserMessagesMatching?: (predicate: (text: string) => boolean) => { steering: string[]; followUp: string[] };
145
+ };
146
+ }
147
+
148
+ export function purgeQueuedAmbientNotifications(pi: ExtensionAPI, match: (text: string) => boolean): boolean {
149
+ const api = pi as unknown as QueuePurgeCapablePi;
150
+ const surface =
151
+ typeof api.clearQueuedUserMessagesMatching === "function"
152
+ ? api.clearQueuedUserMessagesMatching
153
+ : typeof api.session?.clearQueuedUserMessagesMatching === "function"
154
+ ? api.session.clearQueuedUserMessagesMatching
155
+ : undefined;
156
+ if (!surface) return false;
157
+ try {
158
+ const removed = surface.call(api.session ?? api, match);
159
+ return (removed?.followUp?.length ?? 0) + (removed?.steering?.length ?? 0) > 0;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
@@ -35,3 +35,31 @@ export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationC
35
35
  });
36
36
  });
37
37
  }
38
+
39
+ /**
40
+ * F13 (RR-018): refresh the extension-lifetime cross-extension wiring for a
41
+ * new session. Called on every `session_start`.
42
+ *
43
+ * The RPC handle is installed ONCE at registration and survives session
44
+ * switches — its `getCtx` closure resolves the CURRENT session context at
45
+ * request time, so nothing needs reinstalling (re-registering would double
46
+ * the pi-crew:rpc:* subscriptions). The crew global registry IS rebound to
47
+ * the current session's manifest cache so a cross-project switch never
48
+ * leaves it reading a stale project's runs. Idempotent and cheap (a plain
49
+ * object construction — installCrewGlobalRegistry swaps the module-scoped
50
+ * singleton atomically).
51
+ */
52
+ export function refreshCrossExtensionWiringForSession(pi: ExtensionAPI, ctx: RegistrationContext): void {
53
+ if (!ctx.rpcHandle) {
54
+ // Edge: registration never installed the handle (or a full shutdown
55
+ // removed it) — install it once, idempotently.
56
+ installCrossExtensionWiring(pi, ctx);
57
+ return;
58
+ }
59
+ void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
60
+ installCrewGlobalRegistry({
61
+ manifestCache: ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd()),
62
+ cwdProvider: () => ctx.currentCtx?.cwd ?? process.cwd(),
63
+ });
64
+ });
65
+ }
@@ -0,0 +1,220 @@
1
+ import { readEvents } from "../state/event-log/event-log.ts";
2
+ import { writeArtifact } from "../state/stores/artifact-store.ts";
3
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
4
+ import { formatCost, formatTokens } from "../state/usage.ts";
5
+ import { formatDuration } from "../ui/format-helpers.ts";
6
+
7
+ /**
8
+ * US-021 (2026-09-22) — run comparison (before/after diff of two runs).
9
+ *
10
+ * Scope per spec docs/specs/US-021.md: status delta, per-task
11
+ * status/duration/tokens/cost deltas, event-type set diff, model routing diff.
12
+ * NOT in scope: visual UI, git integration.
13
+ *
14
+ * Determinism: the markdown body contains NO wall-clock (the spec pins this so
15
+ * comparisons are byte-diffable — same pattern as US-022's injectable `now`).
16
+ * Everything rendered derives from run data; the artifact's own write time is
17
+ * filesystem metadata, not content. The output also deliberately contains NO
18
+ * absolute paths (ids, statuses and deltas only), so the run-export
19
+ * `redactHomePaths` pass is unnecessary here — noted because the spec asked.
20
+ */
21
+
22
+ export interface TaskDelta {
23
+ id: string;
24
+ statusA?: string;
25
+ statusB?: string;
26
+ /** b − a; undefined when either side lacks the data (task missing, or no timestamps). */
27
+ durationDeltaMs?: number;
28
+ /** b − a; undefined when either side lacks usage. */
29
+ tokensDelta?: number;
30
+ /** b − a; undefined when either side lacks cost. */
31
+ costDelta?: number;
32
+ }
33
+
34
+ export interface ModelRoutingDiff {
35
+ taskId: string;
36
+ modelA?: string;
37
+ modelB?: string;
38
+ }
39
+
40
+ export interface RunComparison {
41
+ runA: string;
42
+ runB: string;
43
+ status: { a: string; b: string };
44
+ /** True when every compared field matches (the self-diff invariant). */
45
+ identical: boolean;
46
+ /** One row per task id present in EITHER run. */
47
+ tasks: TaskDelta[];
48
+ tasksOnlyInA: string[];
49
+ tasksOnlyInB: string[];
50
+ events: { addedTypes: string[]; removedTypes: string[] };
51
+ models: ModelRoutingDiff[];
52
+ usage: { tokensDelta: number; costDelta: number; tokensA: number; tokensB: number; costA: number; costB: number };
53
+ }
54
+
55
+ function taskDurationMs(task: TeamTaskState): number | undefined {
56
+ if (!task.startedAt || !task.finishedAt) return undefined;
57
+ const ms = new Date(task.finishedAt).getTime() - new Date(task.startedAt).getTime();
58
+ return Number.isFinite(ms) ? ms : undefined;
59
+ }
60
+
61
+ function taskTokens(task: TeamTaskState): number | undefined {
62
+ if (!task.usage) return undefined;
63
+ const { input, output } = task.usage;
64
+ if (input === undefined && output === undefined) return undefined;
65
+ return (input ?? 0) + (output ?? 0);
66
+ }
67
+
68
+ function taskCost(task: TeamTaskState): number | undefined {
69
+ const cost = task.usage?.cost;
70
+ return typeof cost === "number" && Number.isFinite(cost) ? cost : undefined;
71
+ }
72
+
73
+ function sumTokens(tasks: TeamTaskState[]): number {
74
+ return tasks.reduce((acc, task) => acc + (taskTokens(task) ?? 0), 0);
75
+ }
76
+
77
+ function sumCost(tasks: TeamTaskState[]): number {
78
+ return tasks.reduce((acc, task) => acc + (taskCost(task) ?? 0), 0);
79
+ }
80
+
81
+ function eventTypeSet(eventsPath: string): Set<string> {
82
+ const types = new Set<string>();
83
+ for (const event of readEvents(eventsPath)) {
84
+ if (event.type) types.add(event.type);
85
+ }
86
+ return types;
87
+ }
88
+
89
+ export function compareRuns(
90
+ manifestA: TeamRunManifest,
91
+ tasksA: TeamTaskState[],
92
+ manifestB: TeamRunManifest,
93
+ tasksB: TeamTaskState[],
94
+ ): RunComparison {
95
+ const byIdA = new Map(tasksA.map((task) => [task.id, task] as const));
96
+ const byIdB = new Map(tasksB.map((task) => [task.id, task] as const));
97
+ const allIds = [...new Set([...byIdA.keys(), ...byIdB.keys()])].sort();
98
+
99
+ const tasks: TaskDelta[] = allIds.map((id) => {
100
+ const a = byIdA.get(id);
101
+ const b = byIdB.get(id);
102
+ const delta: TaskDelta = { id };
103
+ if (a) delta.statusA = a.status;
104
+ if (b) delta.statusB = b.status;
105
+ const durA = a ? taskDurationMs(a) : undefined;
106
+ const durB = b ? taskDurationMs(b) : undefined;
107
+ if (durA !== undefined && durB !== undefined) delta.durationDeltaMs = durB - durA;
108
+ const tokA = a ? taskTokens(a) : undefined;
109
+ const tokB = b ? taskTokens(b) : undefined;
110
+ if (tokA !== undefined && tokB !== undefined) delta.tokensDelta = tokB - tokA;
111
+ const costA = a ? taskCost(a) : undefined;
112
+ const costB = b ? taskCost(b) : undefined;
113
+ if (costA !== undefined && costB !== undefined) delta.costDelta = costB - costA;
114
+ return delta;
115
+ });
116
+
117
+ const typesA = eventTypeSet(manifestA.eventsPath);
118
+ const typesB = eventTypeSet(manifestB.eventsPath);
119
+
120
+ const models: ModelRoutingDiff[] = allIds
121
+ .filter((id) => {
122
+ const a = byIdA.get(id)?.model;
123
+ const b = byIdB.get(id)?.model;
124
+ return a !== undefined || b !== undefined;
125
+ })
126
+ .map((id) => ({ taskId: id, modelA: byIdA.get(id)?.model, modelB: byIdB.get(id)?.model }))
127
+ .filter((diff) => diff.modelA !== diff.modelB);
128
+
129
+ const statusEqual = manifestA.status === manifestB.status;
130
+ const noTaskDeltas = tasks.every(
131
+ (delta) =>
132
+ delta.statusA === delta.statusB &&
133
+ (delta.durationDeltaMs ?? 0) === 0 &&
134
+ (delta.tokensDelta ?? 0) === 0 &&
135
+ (delta.costDelta ?? 0) === 0,
136
+ );
137
+ const tokensA = sumTokens(tasksA);
138
+ const tokensB = sumTokens(tasksB);
139
+ const costA = sumCost(tasksA);
140
+ const costB = sumCost(tasksB);
141
+
142
+ return {
143
+ runA: manifestA.runId,
144
+ runB: manifestB.runId,
145
+ status: { a: manifestA.status, b: manifestB.status },
146
+ identical:
147
+ statusEqual && noTaskDeltas && tasksA.length === tasksB.length && tokensA === tokensB && costA === costB && models.length === 0,
148
+ tasks,
149
+ tasksOnlyInA: tasksA.map((t) => t.id).filter((id) => !byIdB.has(id)),
150
+ tasksOnlyInB: tasksB.map((t) => t.id).filter((id) => !byIdA.has(id)),
151
+ events: {
152
+ addedTypes: [...typesB].filter((t) => !typesA.has(t)).sort(),
153
+ removedTypes: [...typesA].filter((t) => !typesB.has(t)).sort(),
154
+ },
155
+ models,
156
+ usage: { tokensDelta: tokensB - tokensA, costDelta: costB - costA, tokensA, tokensB, costA, costB },
157
+ };
158
+ }
159
+
160
+ function signed(value: number, format: (abs: number) => string): string {
161
+ if (value === 0) return "0";
162
+ return value > 0 ? `+${format(value)}` : `-${format(-value)}`;
163
+ }
164
+
165
+ export function renderComparisonMarkdown(comparison: RunComparison): string {
166
+ const lines: string[] = [
167
+ `# pi-crew comparison ${comparison.runA} vs ${comparison.runB}`,
168
+ "",
169
+ `Status: a=${comparison.status.a} b=${comparison.status.b}`,
170
+ `Comparison: ${comparison.identical ? "identical" : "different"}`,
171
+ "",
172
+ "## Tasks",
173
+ ...(comparison.tasks.length
174
+ ? comparison.tasks.map((delta) => {
175
+ const parts = [`- ${delta.id}: a=${delta.statusA ?? "(absent)"} b=${delta.statusB ?? "(absent)"}`];
176
+ if (delta.durationDeltaMs !== undefined) parts.push(`Δdur=${signed(delta.durationDeltaMs, formatDuration)}`);
177
+ if (delta.tokensDelta !== undefined) parts.push(`Δtok=${signed(delta.tokensDelta, formatTokens)}`);
178
+ if (delta.costDelta !== undefined) parts.push(`Δcost=${signed(delta.costDelta, formatCost)}`);
179
+ return parts.join(" ");
180
+ })
181
+ : ["- (none)"]),
182
+ `Only in a: ${comparison.tasksOnlyInA.length ? comparison.tasksOnlyInA.join(", ") : "(none)"}`,
183
+ `Only in b: ${comparison.tasksOnlyInB.length ? comparison.tasksOnlyInB.join(", ") : "(none)"}`,
184
+ "",
185
+ "## Models",
186
+ ...(comparison.models.length
187
+ ? comparison.models.map((m) => `- ${m.taskId}: ${m.modelA ?? "(none)"} -> ${m.modelB ?? "(none)"}`)
188
+ : ["- (no routing differences)"]),
189
+ "",
190
+ "## Events",
191
+ `Added types: ${comparison.events.addedTypes.length ? comparison.events.addedTypes.join(", ") : "(none)"}`,
192
+ `Removed types: ${comparison.events.removedTypes.length ? comparison.events.removedTypes.join(", ") : "(none)"}`,
193
+ "",
194
+ "## Totals",
195
+ `Tokens: a=${formatTokens(comparison.usage.tokensA)} b=${formatTokens(comparison.usage.tokensB)} Δ=${signed(comparison.usage.tokensDelta, formatTokens)}`,
196
+ `Cost: a=${formatCost(comparison.usage.costA)} b=${formatCost(comparison.usage.costB)} Δ=${signed(comparison.usage.costDelta, formatCost)}`,
197
+ "",
198
+ ];
199
+ return lines.join("\n");
200
+ }
201
+
202
+ /**
203
+ * Compare two runs and write the markdown artifact under the FIRST run's
204
+ * artifactsRoot (spec AC-5) at `compare/<runA>__<runB>.md`.
205
+ */
206
+ export function runCompareBundle(
207
+ manifestA: TeamRunManifest,
208
+ tasksA: TeamTaskState[],
209
+ manifestB: TeamRunManifest,
210
+ tasksB: TeamTaskState[],
211
+ ): { comparison: RunComparison; markdownPath: string } {
212
+ const comparison = compareRuns(manifestA, tasksA, manifestB, tasksB);
213
+ const artifact = writeArtifact(manifestA.artifactsRoot, {
214
+ kind: "summary",
215
+ relativePath: `compare/${manifestA.runId}__${manifestB.runId}.md`,
216
+ producer: "run-compare",
217
+ content: renderComparisonMarkdown(comparison),
218
+ });
219
+ return { comparison, markdownPath: artifact.path };
220
+ }
@@ -5,6 +5,8 @@ import * as path from "node:path";
5
5
  import { readEvents, type TeamEvent } from "../state/event-log/event-log.ts";
6
6
  import { writeArtifact } from "../state/stores/artifact-store.ts";
7
7
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
8
+ import { aggregateUsage, formatCost, formatTokens } from "../state/usage.ts";
9
+ import { formatDuration } from "../ui/format-helpers.ts";
8
10
  import { redactSecrets } from "../utils/redaction.ts";
9
11
 
10
12
  /** Replace absolute paths containing home directory with ~/ */
@@ -36,14 +38,30 @@ export interface ExportedRunBundle {
36
38
  artifactPaths: string[];
37
39
  }
38
40
 
39
- export function exportRunBundle(manifest: TeamRunManifest, tasks: TeamTaskState[]): { jsonPath: string; markdownPath: string } {
41
+ /**
42
+ * Export a run bundle (JSON + markdown report).
43
+ *
44
+ * US-022 (2026-09-22): `now` is injectable so repeated exports of the same run
45
+ * are byte-identical. `exportedAt` sits INSIDE the hashed payload, so a
46
+ * wall-clock stamp made both the JSON and its sha256 (and the markdown
47
+ * `Exported:` line) drift on every export — harmless for import (run-import
48
+ * recomputes the hash minus `sha256`) but fatal for byte-diffing and for
49
+ * reproducing a bundle in tests. Mirrors `createRunManifest`'s `now?: () => Date`.
50
+ */
51
+ export function exportRunBundle(
52
+ manifest: TeamRunManifest,
53
+ tasks: TeamTaskState[],
54
+ now?: () => Date,
55
+ ): { jsonPath: string; markdownPath: string } {
40
56
  const events = readEvents(manifest.eventsPath);
41
57
  const safeManifest = redactHomePaths(manifest);
42
58
  const safeTasks = redactHomePaths(tasks);
43
59
  const safeEvents = redactHomePaths(events);
60
+ // US-022: aggregate usage once (tasks already in hand) for the Cost section.
61
+ const usageTotal = aggregateUsage(safeTasks as TeamTaskState[]);
44
62
  const bundle: ExportedRunBundle = {
45
63
  schemaVersion: 1,
46
- exportedAt: new Date().toISOString(),
64
+ exportedAt: (now ? now() : new Date()).toISOString(),
47
65
  manifest: safeManifest as TeamRunManifest,
48
66
  tasks: safeTasks as TeamTaskState[],
49
67
  events: safeEvents as TeamEvent[],
@@ -71,10 +89,24 @@ export function exportRunBundle(manifest: TeamRunManifest, tasks: TeamTaskState[
71
89
  `Workflow: ${safeManifest.workflow ?? "(none)"}`,
72
90
  `Goal: ${safeManifest.goal}`,
73
91
  "",
92
+ "## Cost",
93
+ ...(usageTotal
94
+ ? [
95
+ `- tokens: ${formatTokens((usageTotal.input ?? 0) + (usageTotal.output ?? 0))} (in ${formatTokens(usageTotal.input ?? 0)} / out ${formatTokens(usageTotal.output ?? 0)})`,
96
+ `- cost: ${formatCost(usageTotal.cost)}`,
97
+ ]
98
+ : ["- (no usage recorded)"]),
99
+ "",
74
100
  "## Tasks",
75
- ...safeTasks.map(
76
- (task) => `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.error ? ` - ${task.error}` : ""}`,
77
- ),
101
+ ...safeTasks.map((task) => {
102
+ // US-022: surface model + duration inline (data was already in hand).
103
+ const duration =
104
+ task.startedAt && task.finishedAt
105
+ ? ` [${formatDuration(new Date(task.finishedAt).getTime() - new Date(task.startedAt).getTime())}]`
106
+ : "";
107
+ const model = task.model ? ` <${task.model}>` : "";
108
+ return `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${model}${duration}${task.error ? ` - ${task.error}` : ""}`;
109
+ }),
78
110
  "",
79
111
  "## Artifacts",
80
112
  ...(safeManifest.artifacts.length