pi-crew 0.9.40 → 0.9.42

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 (150) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/build-meta.json +7089 -6728
  3. package/dist/index.mjs +45845 -45192
  4. package/dist/index.mjs.map +4 -4
  5. package/docs/migration/atomic-write-v2-migration.md +2 -0
  6. package/docs/optimization-plan-2026-07.md +366 -0
  7. package/docs/phase4-triage.md +58 -0
  8. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  9. package/package.json +4 -2
  10. package/scripts/README.md +6 -0
  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/config/config.ts +19 -0
  16. package/src/config/drift-detector.ts +97 -15
  17. package/src/config/resilient-parser.ts +4 -4
  18. package/src/extension/crew-cleanup.ts +17 -3
  19. package/src/extension/crew-vibes/index.ts +1 -1
  20. package/src/extension/cross-extension-rpc.ts +18 -5
  21. package/src/extension/management.ts +6 -5
  22. package/src/extension/register.ts +58 -1573
  23. package/src/extension/registration/command-registration.ts +57 -0
  24. package/src/extension/registration/commands.ts +2 -1
  25. package/src/extension/registration/context-builder.ts +131 -0
  26. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  27. package/src/extension/registration/foreground-run-controller.ts +210 -0
  28. package/src/extension/registration/hook-registration.ts +119 -0
  29. package/src/extension/registration/lazy-configurers.ts +124 -0
  30. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  31. package/src/extension/registration/observability.ts +33 -4
  32. package/src/extension/registration/registration-types.ts +149 -0
  33. package/src/extension/registration/runtime-cleanup.ts +230 -0
  34. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  35. package/src/extension/registration/tool-registration.ts +50 -0
  36. package/src/extension/registration/wire-cross-extension.ts +37 -0
  37. package/src/extension/rpc-hmac.ts +21 -1
  38. package/src/extension/run-import.ts +3 -3
  39. package/src/extension/team-tool/auto-summarize.ts +4 -4
  40. package/src/extension/team-tool/cancel.ts +5 -5
  41. package/src/extension/team-tool/chain-executor.ts +5 -4
  42. package/src/extension/team-tool/context.ts +11 -1
  43. package/src/extension/team-tool/doctor.ts +2 -1
  44. package/src/extension/team-tool/goal-wrap.ts +2 -2
  45. package/src/extension/team-tool/goal.ts +1 -1
  46. package/src/extension/team-tool/handle-schedule.ts +25 -36
  47. package/src/extension/team-tool/orchestrate.ts +0 -1
  48. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  49. package/src/extension/team-tool/run.ts +220 -214
  50. package/src/extension/team-tool/workflow-manage.ts +1 -1
  51. package/src/extension/team-tool.ts +64 -16
  52. package/src/hooks/registry.ts +7 -2
  53. package/src/observability/correlation.ts +4 -3
  54. package/src/observability/event-bus.ts +10 -1
  55. package/src/observability/event-to-metric.ts +8 -7
  56. package/src/observability/exporters/adapter.ts +6 -9
  57. package/src/observability/exporters/otlp-exporter.ts +81 -0
  58. package/src/observability/metric-retention.ts +4 -0
  59. package/src/observability/metric-sink.ts +14 -4
  60. package/src/runtime/adaptive-plan.ts +8 -8
  61. package/src/runtime/async-runner.ts +13 -4
  62. package/src/runtime/background-runner.ts +32 -23
  63. package/src/runtime/capability-inventory.ts +1 -1
  64. package/src/runtime/chain-runner.ts +5 -5
  65. package/src/runtime/checkpoint.ts +6 -16
  66. package/src/runtime/child-pi.ts +157 -71
  67. package/src/runtime/coalesce-tasks.ts +1 -1
  68. package/src/runtime/crash-recovery.ts +63 -47
  69. package/src/runtime/delivery-coordinator.ts +1 -1
  70. package/src/runtime/deterministic-ast.ts +2 -2
  71. package/src/runtime/diagnostic-export.ts +2 -1
  72. package/src/runtime/foreground-control.ts +3 -2
  73. package/src/runtime/goal-loop-runner.ts +6 -2
  74. package/src/runtime/goal-state-store.ts +1 -1
  75. package/src/runtime/handoff-manager.ts +0 -2
  76. package/src/runtime/heartbeat-watcher.ts +5 -0
  77. package/src/runtime/intercom-bridge.ts +0 -2
  78. package/src/runtime/live-agent-manager.ts +12 -32
  79. package/src/runtime/live-extension-bridge.ts +0 -2
  80. package/src/runtime/live-session-runtime.ts +65 -57
  81. package/src/runtime/manifest-cache.ts +59 -0
  82. package/src/runtime/mcp-proxy.ts +1 -1
  83. package/src/runtime/orphan-worker-registry.ts +3 -3
  84. package/src/runtime/output-validator.ts +1 -1
  85. package/src/runtime/path-overlap.ts +1 -1
  86. package/src/runtime/pi-args.ts +3 -3
  87. package/src/runtime/pi-json-output.ts +2 -2
  88. package/src/runtime/pipeline-runner.ts +1 -4
  89. package/src/runtime/post-checks.ts +0 -1
  90. package/src/runtime/retry-runner.ts +1 -1
  91. package/src/runtime/run-coalesced-task-group.ts +0 -13
  92. package/src/runtime/scheduler.ts +2 -0
  93. package/src/runtime/session-resources.ts +1 -1
  94. package/src/runtime/settings-store.ts +31 -1
  95. package/src/runtime/skill-effectiveness.ts +2 -2
  96. package/src/runtime/stale-reconciler.ts +23 -53
  97. package/src/runtime/subagent-manager.ts +19 -3
  98. package/src/runtime/task-output-context.ts +2 -1
  99. package/src/runtime/task-runner/prompt-builder.ts +1 -1
  100. package/src/runtime/task-runner/run-projection.ts +3 -1
  101. package/src/runtime/task-runner/state-helpers.ts +13 -1
  102. package/src/runtime/task-runner.ts +31 -6
  103. package/src/runtime/team-runner.ts +3 -2
  104. package/src/runtime/verification-gates.ts +54 -5
  105. package/src/schema/config-schema.ts +19 -1
  106. package/src/schema/team-tool-schema.ts +2 -0
  107. package/src/skills/discover-skills.ts +25 -15
  108. package/src/state/active-run-registry.ts +0 -1
  109. package/src/state/atomic-write.ts +37 -11
  110. package/src/state/blob-store.ts +3 -9
  111. package/src/state/contracts.ts +5 -1
  112. package/src/state/crew-init.ts +3 -2
  113. package/src/state/decision-ledger.ts +2 -2
  114. package/src/state/event-log-rotation.ts +1 -1
  115. package/src/state/event-log.ts +3 -3
  116. package/src/state/gitignore-manager.ts +2 -1
  117. package/src/state/health-store.ts +5 -2
  118. package/src/state/instinct-store.ts +11 -2
  119. package/src/state/mailbox.ts +3 -4
  120. package/src/state/observation-store.ts +1 -13
  121. package/src/state/run-cache.ts +12 -12
  122. package/src/state/run-graph.ts +2 -1
  123. package/src/state/run-metrics.ts +1 -1
  124. package/src/state/schedule.ts +7 -1
  125. package/src/state/state-store.ts +9 -3
  126. package/src/state/tiered-eval.ts +1 -1
  127. package/src/ui/dynamic-border.ts +13 -3
  128. package/src/ui/live-conversation-overlay.ts +7 -27
  129. package/src/ui/live-duration.ts +2 -2
  130. package/src/ui/live-run-sidebar.ts +56 -32
  131. package/src/ui/mascot.ts +19 -13
  132. package/src/ui/powerbar-publisher.ts +244 -178
  133. package/src/ui/render-coalescer.ts +44 -3
  134. package/src/ui/render-diff.ts +2 -2
  135. package/src/ui/run-dashboard.ts +122 -21
  136. package/src/ui/run-event-bus.ts +66 -0
  137. package/src/ui/run-snapshot-cache.ts +84 -81
  138. package/src/ui/settings-overlay.ts +3 -3
  139. package/src/ui/status-colors.ts +24 -0
  140. package/src/ui/tool-progress-formatter.ts +1 -1
  141. package/src/ui/tool-render.ts +3 -3
  142. package/src/ui/transcript-cache.ts +5 -10
  143. package/src/ui/transcript-entries.ts +68 -72
  144. package/src/ui/transcript-viewer.ts +54 -14
  145. package/src/ui/widget/index.ts +26 -15
  146. package/src/ui/widget/widget-model.ts +18 -9
  147. package/src/ui/widget/widget-renderer.ts +2 -5
  148. package/src/utils/fingerprint.ts +1 -1
  149. package/src/utils/project-detector.ts +1 -1
  150. package/src/state/atomic-write-v2.ts +0 -108
@@ -2,6 +2,7 @@ import type { appendEvent } from "../state/event-log.ts";
2
2
  import { logInternalError } from "../utils/internal-error.ts";
3
3
  import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
4
4
  import type { IrcMessage } from "./live-irc.ts";
5
+ import { checkProcessLiveness } from "./process-status.ts";
5
6
 
6
7
  const MAX_PENDING_MESSAGES = 1000;
7
8
 
@@ -83,14 +84,6 @@ export function listLiveAgentsByWorkspace(workspaceId: string): LiveAgentHandle[
83
84
  return listLiveAgents().filter((a) => a.workspaceId === workspaceId);
84
85
  }
85
86
 
86
- /**
87
- * List only active agents (running/queued/waiting) for a specific workspace.
88
- */
89
- /** @internal */
90
- function listActiveLiveAgentsByWorkspace(workspaceId: string): LiveAgentHandle[] {
91
- return listActiveLiveAgents().filter((a) => a.workspaceId === workspaceId);
92
- }
93
-
94
87
  export function registerLiveAgent(
95
88
  input: Omit<LiveAgentHandle, "createdAt" | "updatedAt" | "pendingSteers" | "pendingFollowUps" | "pendingMessages" | "activity"> & {
96
89
  workspaceId: string;
@@ -189,15 +182,6 @@ function safeDisposeLiveSession(handle: LiveAgentHandle): void {
189
182
  }
190
183
  }
191
184
 
192
- /** @internal */
193
- function removeLiveAgentHandle(agentId: string): LiveAgentHandle | undefined {
194
- const handle = liveAgents.get(agentId);
195
- if (!handle) return undefined;
196
- liveAgents.delete(agentId);
197
- safeDisposeLiveSession(handle);
198
- return handle;
199
- }
200
-
201
185
  export function disposeLiveAgentSession(agentIdOrTaskId: string): void {
202
186
  const handle = getLiveAgent(agentIdOrTaskId);
203
187
  if (!handle) return;
@@ -280,10 +264,16 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
280
264
  evicted++;
281
265
  }
282
266
  } else if (age > STALE_RUNNING_HANDLE_MS) {
283
- // Active-status handle with no update for 30min — presumed dead
284
- liveAgents.delete(agentId);
285
- safeDisposeLiveSession(handle);
286
- evicted++;
267
+ // Active-status handle with no update for 30min — check process liveness
268
+ // before evicting. Only evict if the process is dead, to avoid evicting
269
+ // slow-but-alive agents.
270
+ const sessionPid = (handle.session as Record<string, unknown>)?.pid as number | undefined;
271
+ const liveness = checkProcessLiveness(sessionPid);
272
+ if (!liveness.alive) {
273
+ liveAgents.delete(agentId);
274
+ safeDisposeLiveSession(handle);
275
+ evicted++;
276
+ }
287
277
  }
288
278
  }
289
279
  return evicted;
@@ -299,7 +289,7 @@ export function listActiveLiveAgents(): LiveAgentHandle[] {
299
289
 
300
290
  export function getLiveAgentContextPercent(agentIdOrTaskId: string): number | null {
301
291
  const handle = getLiveAgent(agentIdOrTaskId);
302
- if (!handle || handle.status !== "running") return null;
292
+ if (handle?.status !== "running") return null;
303
293
  try {
304
294
  return handle.session.getSessionStats?.().contextUsage?.percent ?? null;
305
295
  } catch {
@@ -492,16 +482,6 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
492
482
  return recipients;
493
483
  }
494
484
 
495
- /** Phase 7: Get pending IRC messages for an agent (and clear them). */
496
- /** @internal */
497
- function drainIrcMessages(agentIdOrTaskId: string): IrcMessage[] {
498
- const handle = getLiveAgent(agentIdOrTaskId);
499
- if (!handle) return [];
500
- const messages = [...handle.pendingMessages];
501
- handle.pendingMessages.length = 0;
502
- return messages;
503
- }
504
-
505
485
  /* ── IRC reply support (side-channel Q&A) ─────────────────────────── */
506
486
 
507
487
  /** Default timeout for awaiting a side-channel reply (60s). */
@@ -29,8 +29,6 @@
29
29
  * - setActiveTools / getActiveTools / getAllTools
30
30
  */
31
31
 
32
- import type { YieldResult } from "./yield-handler.ts";
33
-
34
32
  export interface ExtensionBridgeApis {
35
33
  sendMessage: (message: unknown, options?: Record<string, unknown>) => void;
36
34
  sendUserMessage: (content: unknown, options?: Record<string, unknown>) => void;
@@ -27,7 +27,7 @@ import {
27
27
  } from "./live-agent-manager.ts";
28
28
  import { subscribeLiveControlRealtime } from "./live-control-realtime.ts";
29
29
  import { buildExtensionBridge } from "./live-extension-bridge.ts";
30
- import { collectLiveSessionHealth, formatLiveSessionDiagnostics, type LiveSessionHealth } from "./live-session-health.ts";
30
+ import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-session-health.ts";
31
31
  import { buildMcpProxyFromSession } from "./mcp-proxy.ts";
32
32
  import { buildConfiguredModelRouting } from "./model-fallback.ts";
33
33
  import { readEnabledModelsPatterns } from "./model-scope.ts";
@@ -231,7 +231,7 @@ function eventText(event: unknown): string[] {
231
231
 
232
232
  function finalAssistantText(event: unknown): string[] {
233
233
  const obj = asRecord(event);
234
- if (!obj || obj.type !== "message_end") return [];
234
+ if (obj?.type !== "message_end") return [];
235
235
  const message = asRecord(obj.message);
236
236
  if (message?.role !== "assistant") return [];
237
237
  return textFromContent(message.content);
@@ -266,7 +266,7 @@ async function resolveScopeModelsPatterns(cwd: string, agentDir?: string): Promi
266
266
  }
267
267
 
268
268
  function modelFromRegistry(modelRegistry: unknown, modelId: string | undefined): unknown {
269
- if (!modelId || !modelId.includes("/")) return undefined;
269
+ if (!modelId?.includes("/")) return undefined;
270
270
  const registry = asRecord(modelRegistry);
271
271
  const find = registry?.find;
272
272
  if (typeof find !== "function") return undefined;
@@ -450,7 +450,7 @@ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?:
450
450
  const allowed = policy.tools?.length ? new Set(policy.tools) : undefined;
451
451
  const active = session
452
452
  .getActiveToolNames()
453
- .filter((name) => !recursiveTools.has(name) && (!disallowed || !disallowed.has(name)) && (!allowed || allowed.has(name)));
453
+ .filter((name) => !recursiveTools.has(name) && !disallowed?.has(name) && (!allowed || allowed.has(name)));
454
454
  session.setActiveToolsByName(active);
455
455
  }
456
456
 
@@ -822,61 +822,69 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
822
822
  });
823
823
  if (typeof session.subscribe === "function") {
824
824
  unsubscribe = session.subscribe((event) => {
825
- if (!isCurrent()) return;
826
- jsonEvents += 1;
827
- appendTranscript(input.transcriptPath, event);
828
- const sidechainType = eventToSidechainType(event);
829
- if (sidechainType)
830
- writeSidechainEntry(sidechainPath, {
831
- agentId,
832
- type: sidechainType,
833
- message: event,
834
- cwd: input.task.cwd,
835
- });
836
- const obj = asRecord(event);
837
- if (obj?.type === "turn_end") {
838
- turnCount += 1;
839
- trackLiveAgentTurnEnd(agentId);
840
- if (maxTurns !== undefined && !softLimitReached && turnCount >= maxTurns) {
841
- softLimitReached = true;
842
- void session?.steer?.("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
843
- } else if (maxTurns !== undefined && softLimitReached && turnCount >= maxTurns + graceTurns) {
844
- void session?.abort?.();
845
- }
846
- }
847
- // Accumulate lifetime usage that survives compaction
848
- if (obj?.type === "message_end" && extractMessageRole(obj) === "assistant") {
849
- const u = extractMessageUsage(obj);
850
- if (u) {
851
- trackTaskUsage(input.task.id, {
852
- input: typeof u.input === "number" ? u.input : 0,
853
- output: typeof u.output === "number" ? u.output : 0,
854
- cacheWrite: typeof u.cacheWrite === "number" ? u.cacheWrite : 0,
825
+ // Wrap callback body in try/catch to prevent unguarded exceptions from
826
+ // propagating into the session internals and crashing the live session.
827
+ try {
828
+ if (!isCurrent()) return;
829
+ jsonEvents += 1;
830
+ appendTranscript(input.transcriptPath, event);
831
+ const sidechainType = eventToSidechainType(event);
832
+ if (sidechainType)
833
+ writeSidechainEntry(sidechainPath, {
834
+ agentId,
835
+ type: sidechainType,
836
+ message: event,
837
+ cwd: input.task.cwd,
855
838
  });
839
+ const obj = asRecord(event);
840
+ if (obj?.type === "turn_end") {
841
+ turnCount += 1;
842
+ trackLiveAgentTurnEnd(agentId);
843
+ if (maxTurns !== undefined && !softLimitReached && turnCount >= maxTurns) {
844
+ softLimitReached = true;
845
+ void session?.steer?.("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
846
+ } else if (maxTurns !== undefined && softLimitReached && turnCount >= maxTurns + graceTurns) {
847
+ void session?.abort?.();
848
+ }
856
849
  }
857
- }
858
- input.onEvent?.(event);
859
- const text = [...eventText(event), ...finalAssistantText(event)].join("\n");
860
- if (text.trim()) {
861
- stdout += `${text}\n`;
862
- streamOut?.write(text + "\n");
863
- trackLiveAgentResponseText(agentId, text);
864
- input.onOutput?.(text);
865
- }
866
- // G2: Track tool start/end for activity display
867
- if (obj?.type === "tool_use" || obj?.type === "tool_execution_start") {
868
- const toolName = extractToolName(obj) ?? "unknown";
869
- trackLiveAgentToolStart(agentId, toolName);
870
- }
871
- if (obj?.type === "tool_result" || obj?.type === "tool_execution_end") {
872
- const toolName = extractToolName(obj) ?? "unknown";
873
- trackLiveAgentToolEnd(agentId, toolName);
874
- }
875
- // Phase 1: collect events for yield detection
876
- if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event)) {
877
- collectedJsonEvents.push(event as Record<string, unknown>);
878
- if (collectedJsonEvents.length > maxCollectedJsonEvents)
879
- collectedJsonEvents.splice(0, collectedJsonEvents.length - maxCollectedJsonEvents);
850
+ // Accumulate lifetime usage that survives compaction
851
+ if (obj?.type === "message_end" && extractMessageRole(obj) === "assistant") {
852
+ const u = extractMessageUsage(obj);
853
+ if (u) {
854
+ trackTaskUsage(input.task.id, {
855
+ input: typeof u.input === "number" ? u.input : 0,
856
+ output: typeof u.output === "number" ? u.output : 0,
857
+ cacheWrite: typeof u.cacheWrite === "number" ? u.cacheWrite : 0,
858
+ });
859
+ }
860
+ }
861
+ input.onEvent?.(event);
862
+ const text = [...eventText(event), ...finalAssistantText(event)].join("\n");
863
+ if (text.trim()) {
864
+ stdout += `${text}\n`;
865
+ streamOut?.write(text + "\n");
866
+ trackLiveAgentResponseText(agentId, text);
867
+ input.onOutput?.(text);
868
+ }
869
+ // G2: Track tool start/end for activity display
870
+ if (obj?.type === "tool_use" || obj?.type === "tool_execution_start") {
871
+ const toolName = extractToolName(obj) ?? "unknown";
872
+ trackLiveAgentToolStart(agentId, toolName);
873
+ }
874
+ if (obj?.type === "tool_result" || obj?.type === "tool_execution_end") {
875
+ const toolName = extractToolName(obj) ?? "unknown";
876
+ trackLiveAgentToolEnd(agentId, toolName);
877
+ }
878
+ // Phase 1: collect events for yield detection
879
+ if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event)) {
880
+ collectedJsonEvents.push(event as Record<string, unknown>);
881
+ if (collectedJsonEvents.length > maxCollectedJsonEvents)
882
+ collectedJsonEvents.splice(0, collectedJsonEvents.length - maxCollectedJsonEvents);
883
+ }
884
+ } catch (error) {
885
+ // Prevent unguarded exceptions in the subscribe callback from crashing
886
+ // the live session or propagating into the session internals.
887
+ logInternalError("live-session.subscribe-callback", error, `agentId=${agentId}`);
880
888
  }
881
889
  });
882
890
  }
@@ -9,6 +9,15 @@ import { isSafePathId, resolveContainedRelativePath, resolveRealContainedPath }
9
9
 
10
10
  export interface ManifestCache {
11
11
  list(limit?: number): TeamRunManifest[];
12
+ /**
13
+ * Return ONLY running manifests, capped only by `limit`.
14
+ *
15
+ * RT-F3: unlike `list(limit)`, this filters `status === "running"` BEFORE
16
+ * the limit is applied so stale orphaned runs that have been pushed past
17
+ * the top-N by recent activity are not silently hidden from crash-recovery
18
+ * and zombie-detection scans.
19
+ */
20
+ listActive(limit: number): TeamRunManifest[];
12
21
  get(runId: string): TeamRunManifest | undefined;
13
22
  clear(runId?: string): void;
14
23
  dispose(): void;
@@ -203,6 +212,15 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
203
212
  return undefined;
204
213
  }
205
214
 
215
+ /**
216
+ * NOTE (RT-F10): This function performs a full FS scan + JSON.parse of all
217
+ * manifests on every TTL expiry (default 500ms). This is a known performance
218
+ * tradeoff: fs.watch provides real-time invalidation (see constructor above)
219
+ * so the TTL cache is only consulted when the watcher fires, but the list()
220
+ * call itself still re-reads all entries to pick up changes. Incremental
221
+ * updates via fs.watch-driven delta tracking are planned for a future
222
+ * iteration to avoid the full scan on each expiry.
223
+ */
206
224
  function list(limit = DEFAULT_CACHE.manifestMaxEntries): TeamRunManifest[] {
207
225
  const now = Date.now();
208
226
  const cached = listCache.get(limit);
@@ -248,6 +266,46 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
248
266
  return undefined;
249
267
  }
250
268
 
269
+ /**
270
+ * RT-F3: filter to `status === "running"` BEFORE applying the limit so an
271
+ * orphaned run that has been pushed past the top-N by recent successful
272
+ * runs is still surfaced to crash-recovery. The cap is the LIMIT, not the
273
+ * "top-N most recent"; callers (e.g. zombie detection) explicitly want
274
+ * EVERY currently-running run, in any order.
275
+ *
276
+ * Internally performs a full scan (NOT via list()) so the cap doesn't
277
+ * silently drop "running" runs that fell past the top-N createdAt cutoff.
278
+ * Still goes through parseManifestIfChanged for stat+size memoization, so
279
+ * the per-run I/O cost is the same as list().
280
+ */
281
+ function listActive(limit: number): TeamRunManifest[] {
282
+ const cap = Math.max(0, limit);
283
+ const parsedEntries = [
284
+ ...roots.flatMap((root) => collectRoots(root)),
285
+ ...activeRunEntries().map((entry) => ({
286
+ runId: entry.runId,
287
+ path: entry.manifestPath,
288
+ })),
289
+ ];
290
+ const unique = new Map<string, CachedManifest | undefined>();
291
+ for (const entry of parsedEntries) {
292
+ if (entry.runId.length === 0) continue;
293
+ let cached = manifestIndex.get(entry.runId);
294
+ const root = path.dirname(path.dirname(entry.path));
295
+ const parsed = parseManifestIfChanged(root, entry.runId, entry.path, cached);
296
+ if (parsed) {
297
+ cached = parsed;
298
+ manifestIndex.set(entry.runId, cached);
299
+ }
300
+ if (cached) unique.set(entry.runId, cached);
301
+ }
302
+ const running = [...unique.values()]
303
+ .filter((value): value is CachedManifest => value !== undefined)
304
+ .map((value) => value.manifest)
305
+ .filter((manifest) => manifest.status === "running");
306
+ return running.slice(0, cap);
307
+ }
308
+
251
309
  if (options.watch ?? true) {
252
310
  for (const root of roots) {
253
311
  const watcher = watchWithErrorHandler(
@@ -268,6 +326,7 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
268
326
 
269
327
  return {
270
328
  list,
329
+ listActive,
271
330
  get,
272
331
  clear(runId) {
273
332
  invalidate(runId);
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
20
- import type { Static, TSchema } from "@sinclair/typebox";
20
+ import type { TSchema } from "@sinclair/typebox";
21
21
 
22
22
  export interface McpProxyConfig {
23
23
  /** Whether to enable MCP in the child session. */
@@ -22,10 +22,10 @@
22
22
  * writes from causing lost updates.
23
23
  */
24
24
 
25
- import { execFileSync, execSync } from "node:child_process";
25
+ import { execFileSync } from "node:child_process";
26
26
  import * as fs from "node:fs";
27
27
  import * as path from "node:path";
28
- import { isSymlinkSafePath } from "../state/atomic-write.ts";
28
+ import { atomicWriteFile, isSymlinkSafePath } from "../state/atomic-write.ts";
29
29
  import { withFileLockSync } from "../state/locks.ts";
30
30
  import { logInternalError } from "../utils/internal-error.ts";
31
31
  import { userPiRoot } from "../utils/paths.ts";
@@ -252,7 +252,7 @@ function writeRegistry(entries: OrphanWorkerEntry[]): void {
252
252
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
253
253
  }
254
254
  try {
255
- fs.writeFileSync(p, JSON.stringify(entries, null, 2), { mode: 0o600 });
255
+ atomicWriteFile(p, JSON.stringify(entries, null, 2), { mode: 0o600 });
256
256
  } catch (error) {
257
257
  logInternalError("orphan-worker-registry.write", error, `path=${p} entries=${entries.length}`);
258
258
  }
@@ -42,7 +42,7 @@ export function validateWorkerOutput(role: string, output: string): OutputValida
42
42
  const issues: string[] = [];
43
43
 
44
44
  // Empty output always fails
45
- if (!output || !output.trim()) {
45
+ if (!output?.trim()) {
46
46
  return {
47
47
  valid: false,
48
48
  formatMatch: false,
@@ -124,7 +124,7 @@ export function filterReadyByWriteOverlap(
124
124
  for (const taskId of readyTaskIds) {
125
125
  if (pickedTaskIds.length >= maxCount) break;
126
126
  const task = taskById.get(taskId);
127
- if (!task || !task.stepId) {
127
+ if (!task?.stepId) {
128
128
  // No step reference: treat as no declared write path → never conflicts.
129
129
  pickedTaskIds.push(taskId);
130
130
  continue;
@@ -1,9 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
4
  import type { AgentConfig } from "../agents/agent-config.ts";
6
5
  import { resolveToolPolicy } from "../agents/agent-config.ts";
6
+ import { atomicWriteFile } from "../state/atomic-write.ts";
7
7
  import { packageRoot, userPiRoot } from "../utils/paths.ts";
8
8
 
9
9
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
@@ -323,7 +323,7 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
323
323
  const tmpBase = getPiTempBase();
324
324
  tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`);
325
325
  const promptPath = path.join(tempDir, `${input.agent.name.replace(/[^\w.-]/g, "_")}.md`);
326
- fs.writeFileSync(promptPath, input.agent.systemPrompt, { mode: 0o600 });
326
+ atomicWriteFile(promptPath, input.agent.systemPrompt, { mode: 0o600 });
327
327
  args.push(input.agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptPath);
328
328
  }
329
329
 
@@ -333,7 +333,7 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
333
333
  tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`);
334
334
  }
335
335
  const taskPath = path.join(tempDir, "task.md");
336
- fs.writeFileSync(taskPath, input.task, { mode: 0o600 });
336
+ atomicWriteFile(taskPath, input.task, { mode: 0o600 });
337
337
  args.push(`@${taskPath}`);
338
338
  } else {
339
339
  args.push(`Task: ${input.task}`);
@@ -151,14 +151,14 @@ function extractErrorMessage(event: unknown): string | undefined {
151
151
  */
152
152
  function extractPatch(event: unknown, patches: string[]): void {
153
153
  const obj = asRecord(event);
154
- if (!obj || obj.type !== "tool_result") return;
154
+ if (obj?.type !== "tool_result") return;
155
155
 
156
156
  const content = obj.content;
157
157
  if (!Array.isArray(content)) return;
158
158
 
159
159
  for (const item of content) {
160
160
  const part = asRecord(item);
161
- if (!part || part.type !== "text") continue;
161
+ if (part?.type !== "text") continue;
162
162
  const text = typeof part.text === "string" ? part.text : "";
163
163
 
164
164
  // Check if this looks like a unified patch (starts with "---" or "+++")
@@ -1,9 +1,6 @@
1
- import type { AgentConfig } from "../agents/agent-config.ts";
2
1
  import { errors } from "../errors.ts";
3
2
  import { appendEventAsync, flushEventLogBuffer } from "../state/event-log.ts";
4
- import type { TeamTaskState } from "../state/types.ts";
5
- import type { TeamConfig } from "../teams/team-config.ts";
6
- import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
3
+ import type { WorkflowConfig } from "../workflows/workflow-config.ts";
7
4
  import { mapConcurrent } from "./parallel-utils.ts";
8
5
 
9
6
  /**
@@ -5,7 +5,6 @@
5
5
  * Distilled from pi-autoresearch's post-check / backpressure pattern.
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
- import * as path from "node:path";
9
8
  import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
10
9
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
11
10
  import { resolveShellForScript } from "../utils/resolve-shell.ts";
@@ -70,10 +70,10 @@ export interface TaskRunnerLike {
70
70
  */
71
71
  export class RetryRunner {
72
72
  private _disposed = false;
73
- private _handoffs: HandoffSummary[] = [];
74
73
 
75
74
  private taskRunner: TaskRunnerLike;
76
75
  private handoffManager: HandoffManager;
76
+ private _handoffs: HandoffSummary[] = [];
77
77
 
78
78
  constructor(taskRunner: TaskRunnerLike, handoffManager: HandoffManager) {
79
79
  this.taskRunner = taskRunner;
@@ -1,16 +1,3 @@
1
- /**
2
- * run-coalesced-task-group.ts — M6 real-dispatch MVP worker spawn.
3
- *
4
- * Spawns ONE child Pi process for an entire coalesced group of N tasks
5
- * (sharing role + cwd per `planCoalescedGroups`). The combined prompt
6
- * instructs the worker to wrap each task's result in `<<<TASK_RESULT:id>>>`
7
- * ... `<<<END_TASK_RESULT>>>` delimiters. Output is split back into N
8
- * per-task results via `splitCoalescedOutput`, each written to its own
9
- * result artifact + state update.
10
- */
11
-
12
- import { writeFile } from "node:fs/promises";
13
- import path from "node:path";
14
1
  import type { AgentConfig } from "../agents/agent-config.ts";
15
2
  import { writeArtifact } from "../state/artifact-store.ts";
16
3
  import { appendEventAsync } from "../state/event-log.ts";
@@ -111,6 +111,7 @@ export class CrewScheduler {
111
111
  if (this.timers.has(job.id)) return;
112
112
  if (job.scheduleType === "interval" && job.intervalMs) {
113
113
  const t = setInterval(() => this.fire(job.id), job.intervalMs);
114
+ t.unref();
114
115
  this.timers.set(job.id, t);
115
116
  } else if (job.scheduleType === "once") {
116
117
  const target = new Date(job.schedule).getTime();
@@ -120,6 +121,7 @@ export class CrewScheduler {
120
121
  this.fire(job.id);
121
122
  this.update(job.id, { enabled: false });
122
123
  }, delay);
124
+ t.unref();
123
125
  this.timers.set(job.id, t);
124
126
  } else {
125
127
  this.update(job.id, { enabled: false, lastStatus: "error" });
@@ -9,7 +9,7 @@ import { logInternalError } from "../utils/internal-error.ts";
9
9
  */
10
10
  export function tryRegisterSessionCleanup(pi: ExtensionAPI, cleanup: () => void): (() => void) | undefined {
11
11
  const api = pi as unknown as Record<string, unknown>;
12
- const registerFn = api["registerSessionResourceCleanup"];
12
+ const registerFn = api.registerSessionResourceCleanup;
13
13
  if (typeof registerFn === "function") {
14
14
  try {
15
15
  const unregister = (registerFn as (fn: () => void) => (() => void) | void)(cleanup);
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import * as path from "node:path";
4
+ import { atomicWriteJson } from "../state/atomic-write.ts";
5
+ import { withFileLockSync } from "../state/locks.ts";
4
6
  import { logInternalError } from "../utils/internal-error.ts";
5
7
  import type { JoinMode } from "./group-join.ts";
6
8
 
@@ -98,13 +100,41 @@ export function saveCrewSettings(s: CrewSettings, cwd: string = process.cwd()):
98
100
  const p = projectPath(cwd);
99
101
  try {
100
102
  fs.mkdirSync(path.dirname(p), { recursive: true });
101
- fs.writeFileSync(p, JSON.stringify(s, null, 2), "utf-8");
103
+ // Atomic (temp + rename + fsync) instead of raw writeFileSync: a crash/SIGKILL
104
+ // mid-write would otherwise truncate <cwd>/.pi/crew-settings.json, and the
105
+ // reader silently resets to {} on parse failure — losing ALL crew settings +
106
+ // scheduledJobs.
107
+ atomicWriteJson(p, s);
102
108
  return true;
103
109
  } catch {
104
110
  return false;
105
111
  }
106
112
  }
107
113
 
114
+ /**
115
+ * Atomically read-modify-write crew settings under a file lock.
116
+ *
117
+ * Fixes two issues vs a plain loadCrewSettings → saveCrewSettings sequence:
118
+ * 1. Cross-session lost-update race: <cwd>/.pi/crew-settings.json is project-scoped,
119
+ * so two Pi sessions on the same project concurrently scheduling jobs would each
120
+ * load-modify-save and the second save clobbers the first (lost job). The lock
121
+ * serializes the whole transaction.
122
+ * 2. Crash truncation: the write uses atomicWriteJson (temp + rename + fsync).
123
+ *
124
+ * `mutator` receives the freshly-loaded (merged) settings and returns the new value
125
+ * to persist. Returns the persisted settings.
126
+ */
127
+ export function updateCrewSettings(cwd: string, mutator: (settings: CrewSettings) => CrewSettings): CrewSettings {
128
+ const p = projectPath(cwd);
129
+ fs.mkdirSync(path.dirname(p), { recursive: true });
130
+ return withFileLockSync(p, () => {
131
+ const fresh = loadCrewSettings(cwd); // re-read inside the lock (TOCTOU-safe vs other updaters)
132
+ const next = mutator(fresh);
133
+ atomicWriteJson(p, next);
134
+ return next;
135
+ });
136
+ }
137
+
108
138
  export function applyCrewSettingsToConfig(
109
139
  config: {
110
140
  limits?: { maxConcurrentWorkers?: number };
@@ -10,8 +10,8 @@
10
10
  * @module skill-effectiveness
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
14
- import { dirname, join } from "path";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
15
  import { projectCrewRoot } from "../utils/paths.ts";
16
16
  import { crewHooks } from "./crew-hooks.ts";
17
17