pi-crew 0.1.45 → 0.1.49

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 (178) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -5
  3. package/agents/analyst.md +11 -11
  4. package/agents/critic.md +11 -11
  5. package/agents/executor.md +11 -11
  6. package/agents/explorer.md +11 -11
  7. package/agents/planner.md +11 -11
  8. package/agents/reviewer.md +11 -11
  9. package/agents/security-reviewer.md +11 -11
  10. package/agents/test-engineer.md +11 -11
  11. package/agents/verifier.md +11 -11
  12. package/agents/writer.md +11 -11
  13. package/docs/next-upgrade-roadmap.md +808 -0
  14. package/docs/research/AGENT-EXECUTION-ARCHITECTURE.md +261 -0
  15. package/docs/research/AGENT-LIFECYCLE-COMPARISON.md +111 -0
  16. package/docs/research/AUDIT_OH_MY_PI.md +261 -0
  17. package/docs/research/AUDIT_PI_CREW.md +457 -0
  18. package/docs/research/CAVEMAN-DEEP-RESEARCH.md +281 -0
  19. package/docs/research/COMPARISON_OH_MY_PI_VS_PI_CREW.md +264 -0
  20. package/docs/research/DEEP-RESEARCH-PI-POWERBAR.md +343 -0
  21. package/docs/research/DEEP_RESEARCH_SUBAGENT_ARCHITECTURE.md +480 -0
  22. package/docs/research/GAP_CLOSURE_IMPLEMENTATION_PLAN.md +354 -0
  23. package/docs/research/IMPLEMENTATION_PLAN.md +385 -0
  24. package/docs/research/LIVE-SESSION-PRODUCTION-READY-PLAN.md +502 -0
  25. package/docs/research/OH-MY-PI-DEEP-RESEARCH-v14.7.6.md +266 -0
  26. package/docs/research/REMAINING-GAPS-PLAN.md +363 -0
  27. package/docs/research/SESSION-SUMMARY-2026-05-08.md +146 -0
  28. package/docs/research/UI-RESPONSIVENESS-AUDIT.md +173 -0
  29. package/docs/research-awesome-agent-skills-distillation.md +100 -0
  30. package/docs/research-oh-my-pi-distillation.md +369 -0
  31. package/docs/source-runtime-refactor-map.md +24 -0
  32. package/docs/usage.md +3 -3
  33. package/install.mjs +52 -8
  34. package/package.json +99 -98
  35. package/schema.json +10 -1
  36. package/skills/async-worker-recovery/SKILL.md +42 -0
  37. package/skills/context-artifact-hygiene/SKILL.md +52 -0
  38. package/skills/delegation-patterns/SKILL.md +54 -0
  39. package/skills/mailbox-interactive/SKILL.md +40 -0
  40. package/skills/model-routing-context/SKILL.md +39 -0
  41. package/skills/multi-perspective-review/SKILL.md +58 -0
  42. package/skills/observability-reliability/SKILL.md +41 -0
  43. package/skills/orchestration/SKILL.md +157 -0
  44. package/skills/ownership-session-security/SKILL.md +41 -0
  45. package/skills/pi-extension-lifecycle/SKILL.md +39 -0
  46. package/skills/requirements-to-task-packet/SKILL.md +63 -0
  47. package/skills/resource-discovery-config/SKILL.md +41 -0
  48. package/skills/runtime-state-reader/SKILL.md +44 -0
  49. package/skills/secure-agent-orchestration-review/SKILL.md +45 -0
  50. package/skills/state-mutation-locking/SKILL.md +42 -0
  51. package/skills/systematic-debugging/SKILL.md +67 -0
  52. package/skills/ui-render-performance/SKILL.md +39 -0
  53. package/skills/verification-before-done/SKILL.md +57 -0
  54. package/skills/worktree-isolation/SKILL.md +39 -0
  55. package/src/agents/agent-config.ts +6 -0
  56. package/src/agents/agent-search.ts +98 -0
  57. package/src/agents/agent-serializer.ts +38 -34
  58. package/src/agents/discover-agents.ts +29 -15
  59. package/src/config/config.ts +72 -24
  60. package/src/config/defaults.ts +25 -0
  61. package/src/extension/autonomous-policy.ts +26 -33
  62. package/src/extension/help.ts +1 -0
  63. package/src/extension/management.ts +5 -0
  64. package/src/extension/project-init.ts +62 -2
  65. package/src/extension/register.ts +69 -22
  66. package/src/extension/registration/commands.ts +64 -25
  67. package/src/extension/registration/compaction-guard.ts +1 -1
  68. package/src/extension/registration/subagent-helpers.ts +8 -0
  69. package/src/extension/registration/subagent-tools.ts +149 -148
  70. package/src/extension/registration/team-tool.ts +14 -10
  71. package/src/extension/run-index.ts +35 -21
  72. package/src/extension/run-maintenance.ts +30 -5
  73. package/src/extension/team-tool/api.ts +47 -9
  74. package/src/extension/team-tool/cancel.ts +109 -5
  75. package/src/extension/team-tool/context.ts +8 -0
  76. package/src/extension/team-tool/intent-policy.ts +42 -0
  77. package/src/extension/team-tool/lifecycle-actions.ts +120 -79
  78. package/src/extension/team-tool/parallel-dispatch.ts +156 -0
  79. package/src/extension/team-tool/respond.ts +46 -18
  80. package/src/extension/team-tool/run.ts +55 -12
  81. package/src/extension/team-tool/status.ts +13 -2
  82. package/src/extension/team-tool-types.ts +3 -0
  83. package/src/extension/team-tool.ts +45 -14
  84. package/src/hooks/registry.ts +61 -0
  85. package/src/hooks/types.ts +41 -0
  86. package/src/observability/event-to-metric.ts +8 -1
  87. package/src/runtime/agent-control.ts +169 -63
  88. package/src/runtime/async-runner.ts +3 -1
  89. package/src/runtime/background-runner.ts +78 -53
  90. package/src/runtime/cancellation-token.ts +89 -0
  91. package/src/runtime/cancellation.ts +61 -0
  92. package/src/runtime/capability-inventory.ts +116 -0
  93. package/src/runtime/child-pi.ts +458 -444
  94. package/src/runtime/code-summary.ts +247 -0
  95. package/src/runtime/crash-recovery.ts +182 -0
  96. package/src/runtime/crew-agent-records.ts +70 -10
  97. package/src/runtime/crew-agent-runtime.ts +1 -0
  98. package/src/runtime/custom-tools/irc-tool.ts +201 -0
  99. package/src/runtime/custom-tools/submit-result-tool.ts +90 -0
  100. package/src/runtime/deadletter.ts +1 -0
  101. package/src/runtime/delivery-coordinator.ts +48 -25
  102. package/src/runtime/effectiveness.ts +81 -0
  103. package/src/runtime/event-stream-bridge.ts +90 -0
  104. package/src/runtime/live-agent-control.ts +2 -1
  105. package/src/runtime/live-agent-manager.ts +179 -85
  106. package/src/runtime/live-control-realtime.ts +1 -1
  107. package/src/runtime/live-extension-bridge.ts +150 -0
  108. package/src/runtime/live-irc.ts +92 -0
  109. package/src/runtime/live-session-health.ts +100 -0
  110. package/src/runtime/live-session-runtime.ts +599 -305
  111. package/src/runtime/manifest-cache.ts +17 -2
  112. package/src/runtime/mcp-proxy.ts +113 -0
  113. package/src/runtime/model-fallback.ts +6 -4
  114. package/src/runtime/notebook-helpers.ts +90 -0
  115. package/src/runtime/orphan-sentinel.ts +7 -0
  116. package/src/runtime/output-validator.ts +187 -0
  117. package/src/runtime/parallel-utils.ts +57 -0
  118. package/src/runtime/parent-guard.ts +80 -0
  119. package/src/runtime/pi-args.ts +18 -3
  120. package/src/runtime/process-status.ts +5 -1
  121. package/src/runtime/prose-compressor.ts +164 -0
  122. package/src/runtime/result-extractor.ts +121 -0
  123. package/src/runtime/retry-executor.ts +81 -64
  124. package/src/runtime/runtime-resolver.ts +23 -10
  125. package/src/runtime/semaphore.ts +131 -0
  126. package/src/runtime/sensitive-paths.ts +92 -0
  127. package/src/runtime/skill-instructions.ts +222 -0
  128. package/src/runtime/stale-reconciler.ts +4 -14
  129. package/src/runtime/stream-preview.ts +177 -0
  130. package/src/runtime/subagent-manager.ts +6 -2
  131. package/src/runtime/subprocess-tool-registry.ts +67 -0
  132. package/src/runtime/task-output-context.ts +177 -127
  133. package/src/runtime/task-runner/capabilities.ts +78 -0
  134. package/src/runtime/task-runner/live-executor.ts +107 -101
  135. package/src/runtime/task-runner/prompt-builder.ts +72 -8
  136. package/src/runtime/task-runner/prompt-pipeline.ts +64 -0
  137. package/src/runtime/task-runner/run-projection.ts +104 -0
  138. package/src/runtime/task-runner.ts +115 -5
  139. package/src/runtime/team-runner.ts +134 -19
  140. package/src/runtime/workspace-tree.ts +298 -0
  141. package/src/runtime/yield-handler.ts +189 -0
  142. package/src/schema/config-schema.ts +7 -0
  143. package/src/schema/team-tool-schema.ts +14 -4
  144. package/src/skills/discover-skills.ts +67 -0
  145. package/src/state/active-run-registry.ts +167 -0
  146. package/src/state/artifact-store.ts +4 -1
  147. package/src/state/atomic-write.ts +50 -1
  148. package/src/state/blob-store.ts +117 -0
  149. package/src/state/contracts.ts +2 -1
  150. package/src/state/event-log-rotation.ts +158 -0
  151. package/src/state/event-log.ts +52 -2
  152. package/src/state/mailbox.ts +129 -9
  153. package/src/state/state-store.ts +32 -5
  154. package/src/state/types.ts +64 -2
  155. package/src/teams/team-config.ts +1 -0
  156. package/src/ui/agent-management-overlay.ts +144 -0
  157. package/src/ui/crew-widget.ts +15 -5
  158. package/src/ui/dashboard-panes/cancellation-pane.ts +43 -0
  159. package/src/ui/dashboard-panes/capability-pane.ts +60 -0
  160. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -11
  161. package/src/ui/dashboard-panes/progress-pane.ts +2 -0
  162. package/src/ui/live-run-sidebar.ts +4 -0
  163. package/src/ui/powerbar-publisher.ts +77 -15
  164. package/src/ui/render-coalescer.ts +51 -0
  165. package/src/ui/run-dashboard.ts +4 -0
  166. package/src/ui/run-event-bus.ts +209 -0
  167. package/src/ui/run-snapshot-cache.ts +78 -18
  168. package/src/ui/snapshot-types.ts +10 -0
  169. package/src/ui/transcript-entries.ts +258 -0
  170. package/src/utils/ids.ts +5 -0
  171. package/src/utils/incremental-reader.ts +104 -0
  172. package/src/utils/paths.ts +4 -2
  173. package/src/utils/scan-cache.ts +137 -0
  174. package/src/utils/sse-parser.ts +134 -0
  175. package/src/utils/task-name-generator.ts +337 -0
  176. package/src/utils/visual.ts +33 -2
  177. package/src/workflows/workflow-config.ts +1 -0
  178. package/src/worktree/cleanup.ts +2 -1
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Structural code summary — regex-based summarizer that elides function bodies,
3
+ * long arrays, block comments, and import groups, keeping signatures.
4
+ * Pure TypeScript fallback (no tree-sitter / Rust native dependency).
5
+ */
6
+
7
+ // ── Public types ──
8
+
9
+ export interface SummarySegment {
10
+ kind: "kept" | "elided";
11
+ startLine: number;
12
+ endLine: number;
13
+ /** Verbatim text for kept segments; absent for elided */
14
+ text?: string;
15
+ }
16
+
17
+ export interface SummaryResult {
18
+ language: string | null;
19
+ totalLines: number;
20
+ elided: boolean;
21
+ segments: SummarySegment[];
22
+ rendered: string;
23
+ }
24
+
25
+ export interface SummaryOptions {
26
+ minBodyLines?: number; // default 4
27
+ minCommentLines?: number; // default 6
28
+ }
29
+
30
+ // ── Language detection ──
31
+
32
+ const EXT_MAP: ReadonlyMap<string, string> = new Map([
33
+ [".ts", "typescript"], [".tsx", "typescript"],
34
+ [".js", "javascript"], [".jsx", "javascript"],
35
+ [".mjs", "javascript"], [".cjs", "javascript"],
36
+ [".py", "python"], [".rs", "rust"],
37
+ ]);
38
+
39
+ export function detectLanguage(filePath: string): string | null {
40
+ const dot = filePath.lastIndexOf(".");
41
+ if (dot === -1) return null;
42
+ return EXT_MAP.get(filePath.slice(dot).toLowerCase()) ?? null;
43
+ }
44
+
45
+ // ── Internal range helpers ──
46
+
47
+ interface Range { start: number; end: number; }
48
+
49
+ function mergeRanges(ranges: Range[]): Range[] {
50
+ if (ranges.length === 0) return [];
51
+ const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end);
52
+ const merged: Range[] = [sorted[0]];
53
+ for (let i = 1; i < sorted.length; i++) {
54
+ const last = merged[merged.length - 1];
55
+ if (sorted[i].start <= last.end + 1) last.end = Math.max(last.end, sorted[i].end);
56
+ else merged.push({ ...sorted[i] });
57
+ }
58
+ return merged;
59
+ }
60
+
61
+ // ── Brace-based elision (TS/JS/Rust) ──
62
+ // NOTE: This is a regex heuristic, not a parser. Braces inside string literals,
63
+ // template strings, regex, and comments are counted, which can produce incorrect
64
+ // elision for edge cases like `const s = "{...}"` or `${expr}`. Acceptable for
65
+ // summaries; do not use for correctness-sensitive parsing.
66
+
67
+ function findBraceRanges(lines: string[], openPattern: RegExp, minBody: number): Range[] {
68
+ const ranges: Range[] = [];
69
+ for (let i = 0; i < lines.length; i++) {
70
+ if (!openPattern.test(lines[i])) continue;
71
+ let depth = 0;
72
+ let foundOpen = false;
73
+ const start = i;
74
+ for (let j = i; j < lines.length; j++) {
75
+ for (const ch of lines[j]) {
76
+ if (ch === "{") { depth++; foundOpen = true; }
77
+ else if (ch === "}") { depth--; }
78
+ }
79
+ if (foundOpen && depth <= 0) {
80
+ if (j - start - 1 >= minBody) ranges.push({ start: start + 1, end: j - 1 });
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ return ranges;
86
+ }
87
+
88
+ // ── TypeScript / JavaScript ──
89
+
90
+ const TS_FN_SIG =
91
+ /^\s*(export\s+)?(async\s+)?function\s|^\s*(export\s+)?(static\s+|get\s+|set\s+|private\s+|public\s+|protected\s+|readonly\s+)*\*?\s*\w+\s*[\(<]/;
92
+ const TS_CLASS_SIG = /^\s*(export\s+)?(default\s+)?(abstract\s+)?class\s/;
93
+ const TS_STRUCT_SIG = /^\s*(export\s+)?(default\s+)?(const|let|var)\s+\w+\s*=\s*(\[[\s]*$|\{[\s]*$)/;
94
+
95
+ function tsRanges(lines: string[], minBody: number): Range[] {
96
+ return [
97
+ ...findBraceRanges(lines, TS_FN_SIG, minBody),
98
+ ...findBraceRanges(lines, TS_CLASS_SIG, minBody),
99
+ ...findBraceRanges(lines, TS_STRUCT_SIG, minBody),
100
+ ];
101
+ }
102
+
103
+ // ── Block comments ──
104
+
105
+ function blockCommentRanges(lines: string[], minComment: number): Range[] {
106
+ const ranges: Range[] = [];
107
+ let i = 0;
108
+ while (i < lines.length) {
109
+ const idx = lines[i].indexOf("/*");
110
+ if (idx === -1 || lines[i].includes("*/", idx + 2)) { i++; continue; }
111
+ const openLine = i;
112
+ let j = i + 1;
113
+ while (j < lines.length && !lines[j].includes("*/")) j++;
114
+ if (j < lines.length && j - openLine - 1 >= minComment)
115
+ ranges.push({ start: openLine + 1, end: j - 1 });
116
+ i = j + 1;
117
+ }
118
+ return ranges;
119
+ }
120
+
121
+ // ── Import groups ──
122
+
123
+ const IMPORT_RE = /^\s*import\s/;
124
+ const PY_IMPORT_RE = /^\s*(import\s|from\s+\S+\s+import\s)/;
125
+
126
+ function importGroupRanges(lines: string[], pattern: RegExp): Range[] {
127
+ const groups: Array<{ start: number; end: number }> = [];
128
+ let gs = -1, last = -1;
129
+ for (let i = 0; i < lines.length; i++) {
130
+ if (pattern.test(lines[i])) { if (gs === -1) gs = i; last = i; }
131
+ else if (gs !== -1 && i > last) { groups.push({ start: gs, end: last }); gs = -1; last = -1; }
132
+ }
133
+ if (gs !== -1) groups.push({ start: gs, end: last });
134
+ const ranges: Range[] = [];
135
+ for (const g of groups) {
136
+ if (g.end - g.start >= 2) ranges.push({ start: g.start + 1, end: g.end - 1 });
137
+ }
138
+ return ranges;
139
+ }
140
+
141
+ // ── Python ──
142
+
143
+ function pythonRanges(lines: string[], minBody: number): Range[] {
144
+ const ranges: Range[] = [];
145
+ for (let i = 0; i < lines.length; i++) {
146
+ const m = /^(\s*)(async\s+)?def\s/.exec(lines[i]) || /^(\s*)class\s/.exec(lines[i]);
147
+ if (!m) continue;
148
+ const base = m[1].length;
149
+ let bs = -1, be = -1;
150
+ for (let j = i + 1; j < lines.length; j++) {
151
+ if (lines[j].trim() === "") continue;
152
+ const indent = lines[j].length - lines[j].trimStart().length;
153
+ if (indent <= base) break;
154
+ if (bs === -1) bs = j;
155
+ be = j;
156
+ }
157
+ if (bs !== -1 && be - bs + 1 >= minBody) ranges.push({ start: bs, end: be });
158
+ }
159
+ ranges.push(...importGroupRanges(lines, PY_IMPORT_RE));
160
+ return ranges;
161
+ }
162
+
163
+ // ── Rust ──
164
+
165
+ const RS_FN_SIG = /^\s*(pub\s+)?(async\s+)?(unsafe\s+)?fn\s/;
166
+ const RS_STRUCT_SIG = /^\s*(pub\s+)?struct\s+\w+.*\{$/;
167
+ const RS_ENUM_SIG = /^\s*(pub\s+)?enum\s+\w+.*\{$/;
168
+ const RS_MOD_SIG = /^\s*(pub\s+)?mod\s+\w+.*\{$/;
169
+
170
+ function rustRanges(lines: string[], minBody: number): Range[] {
171
+ return [
172
+ ...findBraceRanges(lines, RS_FN_SIG, minBody),
173
+ ...findBraceRanges(lines, RS_STRUCT_SIG, minBody),
174
+ ...findBraceRanges(lines, RS_ENUM_SIG, minBody),
175
+ ...findBraceRanges(lines, RS_MOD_SIG, minBody),
176
+ ];
177
+ }
178
+
179
+ // ── Main entry ──
180
+
181
+ function fullResult(language: string | null, totalLines: number, code: string): SummaryResult {
182
+ return {
183
+ language, totalLines, elided: false,
184
+ segments: [{ kind: "kept", startLine: 1, endLine: totalLines, text: code }],
185
+ rendered: code,
186
+ };
187
+ }
188
+
189
+ export function summarizeCode(
190
+ code: string,
191
+ language: string | null,
192
+ options?: SummaryOptions,
193
+ ): SummaryResult {
194
+ const minBody = options?.minBodyLines ?? 4;
195
+ const minComment = options?.minCommentLines ?? 6;
196
+
197
+ if (!code || code.trim() === "") {
198
+ return { language, totalLines: 0, elided: false, segments: [], rendered: "" };
199
+ }
200
+
201
+ const lines = code.split("\n");
202
+ const totalLines = lines.length;
203
+
204
+ if (!language) return fullResult(null, totalLines, code);
205
+
206
+ const rawRanges: Range[] = [];
207
+ switch (language) {
208
+ case "typescript":
209
+ case "javascript":
210
+ rawRanges.push(...tsRanges(lines, minBody), ...blockCommentRanges(lines, minComment), ...importGroupRanges(lines, IMPORT_RE));
211
+ break;
212
+ case "python":
213
+ rawRanges.push(...pythonRanges(lines, minBody));
214
+ break;
215
+ case "rust":
216
+ rawRanges.push(...rustRanges(lines, minBody), ...blockCommentRanges(lines, minComment));
217
+ break;
218
+ default:
219
+ return fullResult(language, totalLines, code);
220
+ }
221
+
222
+ const ranges = mergeRanges(rawRanges);
223
+ if (ranges.length === 0) return fullResult(language, totalLines, code);
224
+
225
+ // Build segments
226
+ const segments: SummarySegment[] = [];
227
+ let cursor = 0;
228
+ for (const r of ranges) {
229
+ if (cursor < r.start) {
230
+ segments.push({ kind: "kept", startLine: cursor + 1, endLine: r.start, text: lines.slice(cursor, r.start).join("\n") });
231
+ }
232
+ segments.push({ kind: "elided", startLine: r.start + 1, endLine: r.end + 1 });
233
+ cursor = r.end + 1;
234
+ }
235
+ if (cursor < totalLines) {
236
+ segments.push({ kind: "kept", startLine: cursor + 1, endLine: totalLines, text: lines.slice(cursor).join("\n") });
237
+ }
238
+
239
+ // Render
240
+ const parts: string[] = [];
241
+ for (const seg of segments) {
242
+ if (seg.kind === "kept") parts.push(seg.text ?? "");
243
+ else parts.push(` ... ${seg.endLine - seg.startLine + 1} lines elided ...`);
244
+ }
245
+
246
+ return { language, totalLines, elided: true, segments, rendered: parts.join("\n") };
247
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import * as fs from "node:fs";
2
3
  import type { MetricRegistry } from "../observability/metric-registry.ts";
3
4
  import { appendEvent, scanSequence } from "../state/event-log.ts";
4
5
  import { withRunLockSync } from "../state/locks.ts";
@@ -8,6 +9,8 @@ import { isWorkerHeartbeatStale } from "./worker-heartbeat.ts";
8
9
  import type { ManifestCache } from "./manifest-cache.ts";
9
10
  import { checkProcessLiveness } from "./process-status.ts";
10
11
  import { reconcileStaleRun, type ReconcileResult } from "./stale-reconciler.ts";
12
+ import { executeHook, appendHookEvent } from "../hooks/registry.ts";
13
+ import { activeRunEntries, unregisterActiveRun, readActiveRunRegistry } from "../state/active-run-registry.ts";
11
14
 
12
15
  export interface RecoveryPlan {
13
16
  runId: string;
@@ -43,6 +46,14 @@ export function detectInterruptedRuns(cwd: string, manifestCache: ManifestCache,
43
46
  export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionContext, "cwd">, registry?: MetricRegistry): Promise<void> {
44
47
  const loaded = loadRunManifestById(ctx.cwd, plan.runId);
45
48
  if (!loaded) throw new Error(`Run '${plan.runId}' not found.`);
49
+
50
+ const hookReport = await executeHook("run_recovery", { runId: plan.runId, cwd: ctx.cwd });
51
+ appendHookEvent(loaded.manifest, hookReport);
52
+ if (hookReport.outcome === "block") {
53
+ appendEvent(loaded.manifest.eventsPath, { type: "crew.run.recovery_blocked", runId: plan.runId, message: `Recovery blocked by hook: ${hookReport.reason ?? "run_recovery hook blocked the operation."}`, data: { hookOutcome: "block", reason: hookReport.reason } });
54
+ return;
55
+ }
56
+
46
57
  const reset = new Set(plan.resumableTasks);
47
58
  const tasks = loaded.tasks.map((task) => reset.has(task.id) ? { ...task, status: "queued" as const, startedAt: undefined, finishedAt: undefined, error: undefined, heartbeat: undefined } : task);
48
59
  saveRunTasks(loaded.manifest, tasks);
@@ -62,6 +73,176 @@ export function declineRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionConte
62
73
  * Run 3-phase stale reconciliation on all active runs.
63
74
  * Returns results for each reconciled run.
64
75
  */
76
+ /**
77
+ * Auto-cancel orphaned runs whose owner session no longer exists.
78
+ *
79
+ * When a Pi session dies (crash, force-close, Ctrl+C), `session_shutdown`
80
+ * does not fire and child workers are not terminated. The next Pi session
81
+ * must detect these orphaned runs and cancel them.
82
+ *
83
+ * Criteria for orphan detection:
84
+ * 1. Manifest status is "running"
85
+ * 2. Manifest has an `ownerSessionId` that is NOT the current session
86
+ * 3. The owner session's process is no longer alive (PID check)
87
+ * 4. No recent heartbeat activity (task heartbeat or agent progress within threshold)
88
+ *
89
+ * Returns the number of runs cancelled.
90
+ */
91
+ export function cancelOrphanedRuns(
92
+ cwd: string,
93
+ manifestCache: ManifestCache,
94
+ currentSessionId: string,
95
+ staleThresholdMs = 300_000,
96
+ now = Date.now(),
97
+ ): { cancelled: string[]; skipped: string[] } {
98
+ const cancelled: string[] = [];
99
+ const skipped: string[] = [];
100
+
101
+ // Phase 1: Scan project-level manifests via manifestCache
102
+ for (const manifest of manifestCache.list(50)) {
103
+ if (manifest.status !== "running") continue;
104
+
105
+ // Only consider runs owned by a different session
106
+ const ownerId = manifest.ownerSessionId;
107
+ if (!ownerId || ownerId === currentSessionId) continue;
108
+
109
+ // Check if the owner process is still alive
110
+ const ownerPid = manifest.async?.pid;
111
+ if (ownerPid !== undefined && checkProcessLiveness(ownerPid).alive) {
112
+ skipped.push(manifest.runId);
113
+ continue;
114
+ }
115
+
116
+ // Check for recent heartbeat activity
117
+ const loaded = loadRunManifestById(cwd, manifest.runId);
118
+ if (!loaded) continue;
119
+
120
+ const hasRecentActivity = loaded.tasks.some((task) => {
121
+ if (task.status !== "running" && task.status !== "waiting") return false;
122
+ const heartbeatAt = task.heartbeat?.lastSeenAt ? new Date(task.heartbeat.lastSeenAt).getTime() : Number.NaN;
123
+ if (task.heartbeat?.alive !== false && Number.isFinite(heartbeatAt) && now - heartbeatAt <= staleThresholdMs) return true;
124
+ const activityAt = task.agentProgress?.lastActivityAt ? new Date(task.agentProgress.lastActivityAt).getTime() : Number.NaN;
125
+ return Number.isFinite(activityAt) && now - activityAt <= staleThresholdMs;
126
+ });
127
+
128
+ if (hasRecentActivity) {
129
+ skipped.push(manifest.runId);
130
+ continue;
131
+ }
132
+
133
+ // Orphan confirmed — cancel all running tasks
134
+ withRunLockSync(loaded.manifest, () => {
135
+ const fresh = loadRunManifestById(cwd, manifest.runId);
136
+ if (!fresh || fresh.manifest.status !== "running") return;
137
+
138
+ const now_iso = new Date(now).toISOString();
139
+ const repairedTasks = fresh.tasks.map((task) => {
140
+ if (task.status === "running" || task.status === "queued" || task.status === "waiting") {
141
+ return { ...task, status: "cancelled" as const, finishedAt: now_iso, error: `Orphaned run: owner session ${ownerId} no longer exists` };
142
+ }
143
+ return task;
144
+ });
145
+
146
+ saveRunTasks(fresh.manifest, repairedTasks);
147
+ updateRunStatus(fresh.manifest, "cancelled", `Orphaned run: owner session ${ownerId} no longer exists`);
148
+ appendEvent(fresh.manifest.eventsPath, { type: "crew.run.orphan_cancelled", runId: manifest.runId, message: `Auto-cancelled orphaned run (owner: ${ownerId})`, data: { ownerSessionId: ownerId, cancelledTasks: repairedTasks.filter((t) => t.status === "cancelled").length } });
149
+ cancelled.push(manifest.runId);
150
+ });
151
+ }
152
+
153
+ return { cancelled, skipped };
154
+ }
155
+
156
+ /**
157
+ * Purge the global active-run-index of entries whose manifest is no longer active.
158
+ *
159
+ * This scans every entry in active-run-index.json and removes any whose:
160
+ * - manifest file no longer exists, OR
161
+ * - manifest status is terminal (completed/failed/cancelled/blocked), OR
162
+ * - manifest cwd directory no longer exists (e.g. temp test dirs)
163
+ *
164
+ * Also removes entries where the manifest is still "running" but:
165
+ * - The cwd has been deleted (temp dir cleanup)
166
+ * - The async worker PID is dead AND no heartbeat for > threshold
167
+ *
168
+ * This is the **global** cleanup that cancelOrphanedRuns (project-scoped)
169
+ * cannot reach.
170
+ */
171
+ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.now()): { purged: string[]; kept: string[] } {
172
+ const purged: string[] = [];
173
+ const kept: string[] = [];
174
+ const entries = readActiveRunRegistry();
175
+
176
+ for (const entry of entries) {
177
+ // 1. Manifest file gone → definitely stale
178
+ if (!fs.existsSync(entry.manifestPath)) {
179
+ unregisterActiveRun(entry.runId);
180
+ purged.push(entry.runId);
181
+ continue;
182
+ }
183
+
184
+ // 2. CWD gone → temp dir cleaned up
185
+ if (!fs.existsSync(entry.cwd)) {
186
+ unregisterActiveRun(entry.runId);
187
+ purged.push(entry.runId);
188
+ continue;
189
+ }
190
+
191
+ // 3. Read manifest status
192
+ let manifest: { status?: string; async?: { pid?: number }; ownerSessionId?: string } | undefined;
193
+ try {
194
+ manifest = JSON.parse(fs.readFileSync(entry.manifestPath, "utf-8"));
195
+ } catch {
196
+ unregisterActiveRun(entry.runId);
197
+ purged.push(entry.runId);
198
+ continue;
199
+ }
200
+
201
+ // 4. Terminal status → no longer active
202
+ const terminalStatuses = new Set(["completed", "failed", "cancelled", "blocked"]);
203
+ if (manifest && terminalStatuses.has(manifest.status ?? "")) {
204
+ unregisterActiveRun(entry.runId);
205
+ purged.push(entry.runId);
206
+ continue;
207
+ }
208
+
209
+ // 5. Still "running" — check if worker PID is dead and no heartbeat
210
+ if (manifest?.status === "running" && manifest.async?.pid !== undefined) {
211
+ const pidAlive = checkProcessLiveness(manifest.async.pid).alive;
212
+ if (!pidAlive) {
213
+ // Check age — if manifest hasn't been updated in > threshold, it's stale
214
+ const updatedAt = new Date(entry.updatedAt).getTime();
215
+ if (Number.isFinite(updatedAt) && now - updatedAt > staleThresholdMs) {
216
+ // Dead PID + stale update → cancel the manifest and unregister
217
+ try {
218
+ const fullLoaded = loadRunManifestById(entry.cwd, entry.runId);
219
+ if (fullLoaded) {
220
+ const now_iso = new Date(now).toISOString();
221
+ const repairedTasks = fullLoaded.tasks.map((task) => {
222
+ if (task.status === "running" || task.status === "queued" || task.status === "waiting") {
223
+ return { ...task, status: "cancelled" as const, finishedAt: now_iso, error: "Orphaned run: worker process dead and no recent activity" };
224
+ }
225
+ return task;
226
+ });
227
+ saveRunTasks(fullLoaded.manifest, repairedTasks);
228
+ updateRunStatus(fullLoaded.manifest, "cancelled", "Orphaned run: worker process dead and no recent activity");
229
+ }
230
+ } catch {
231
+ // Best-effort manifest cleanup
232
+ }
233
+ unregisterActiveRun(entry.runId);
234
+ purged.push(entry.runId);
235
+ continue;
236
+ }
237
+ }
238
+ }
239
+
240
+ kept.push(entry.runId);
241
+ }
242
+
243
+ return { purged, kept };
244
+ }
245
+
65
246
  export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache, now = Date.now()): ReconcileResult[] {
66
247
  const results: ReconcileResult[] = [];
67
248
  for (const manifest of manifestCache.list(50)) {
@@ -75,6 +256,7 @@ export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache,
75
256
  if (!fresh || fresh.manifest.status !== "running") return;
76
257
  const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
77
258
  if (result.repaired) {
259
+ if (result.repairedTasks) saveRunTasks(fresh.manifest, result.repairedTasks);
78
260
  updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
79
261
  appendEvent(fresh.manifest.eventsPath, { type: "crew.run.reconciled_stale", runId: manifest.runId, message: result.detail, data: { verdict: result.verdict } });
80
262
  }
@@ -61,33 +61,93 @@ export function agentOutputPath(manifest: TeamRunManifest, taskId: string): stri
61
61
  }
62
62
 
63
63
  const AGENT_READER_TTL_MS = 200;
64
+ const ASYNC_AGENT_READER_CACHE_MAX_ENTRIES = 128;
65
+
66
+ const asyncAgentReaderCache = new Map<string, { expiresAt: number; records: CrewAgentRecord[]; inFlight?: Promise<CrewAgentRecord[]> }>();
67
+
68
+ function setAsyncAgentReaderCache(filePath: string, entry: { expiresAt: number; records: CrewAgentRecord[]; inFlight?: Promise<CrewAgentRecord[]> }): void {
69
+ const now = Date.now();
70
+ for (const [key, cached] of asyncAgentReaderCache) {
71
+ if (cached.expiresAt <= now && !cached.inFlight) asyncAgentReaderCache.delete(key);
72
+ }
73
+ if (asyncAgentReaderCache.has(filePath)) asyncAgentReaderCache.delete(filePath);
74
+ asyncAgentReaderCache.set(filePath, entry);
75
+ while (asyncAgentReaderCache.size > ASYNC_AGENT_READER_CACHE_MAX_ENTRIES) {
76
+ const oldest = asyncAgentReaderCache.keys().next().value;
77
+ if (!oldest) break;
78
+ asyncAgentReaderCache.delete(oldest);
79
+ }
80
+ }
64
81
 
65
82
  export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
66
83
  try {
67
- return readJsonFileCoalesced(agentsPath(manifest), AGENT_READER_TTL_MS, () => readJsonFile<CrewAgentRecord[]>(agentsPath(manifest)) ?? []);
84
+ const records = readJsonFileCoalesced(agentsPath(manifest), AGENT_READER_TTL_MS, () => readJsonFile<CrewAgentRecord[]>(agentsPath(manifest)) ?? []);
85
+ // Validate schema and deduplicate by id to handle concurrent write conflicts
86
+ const seen = new Set<string>();
87
+ const deduped = records.filter((r) => {
88
+ if (!r || typeof r.id !== "string" || typeof r.taskId !== "string") return false;
89
+ if (seen.has(r.id)) return false;
90
+ seen.add(r.id);
91
+ return true;
92
+ });
93
+ if (deduped.length !== records.length) {
94
+ // Schema mismatch or duplicates detected — save corrected state
95
+ saveCrewAgents(manifest, deduped);
96
+ }
97
+ return deduped;
68
98
  } catch {
69
99
  return [];
70
100
  }
71
101
  }
72
102
 
73
103
  export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise<CrewAgentRecord[]> {
74
- try {
75
- return JSON.parse(await fs.promises.readFile(agentsPath(manifest), "utf-8")) as CrewAgentRecord[];
76
- } catch {
77
- return [];
78
- }
104
+ const filePath = agentsPath(manifest);
105
+ const now = Date.now();
106
+ const cached = asyncAgentReaderCache.get(filePath);
107
+ if (cached && cached.expiresAt > now) return cached.records;
108
+ if (cached?.inFlight) return cached.inFlight;
109
+ const inFlight = (async (): Promise<CrewAgentRecord[]> => {
110
+ try {
111
+ const parsed = JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as unknown;
112
+ const raw = Array.isArray(parsed) ? redactSecrets(parsed) as CrewAgentRecord[] : [];
113
+ // Deduplicate by id to handle concurrent write conflicts
114
+ const seen = new Set<string>();
115
+ const deduped = raw.filter((r) => {
116
+ if (!r || typeof r.id !== "string" || typeof r.taskId !== "string") return false;
117
+ if (seen.has(r.id)) return false;
118
+ seen.add(r.id);
119
+ return true;
120
+ });
121
+ if (deduped.length !== raw.length) {
122
+ try { saveCrewAgents(manifest, deduped); } catch { /* best-effort */ }
123
+ }
124
+ setAsyncAgentReaderCache(filePath, { expiresAt: Date.now() + AGENT_READER_TTL_MS, records: deduped });
125
+ return deduped;
126
+ } catch {
127
+ setAsyncAgentReaderCache(filePath, { expiresAt: Date.now() + AGENT_READER_TTL_MS, records: [] });
128
+ return [];
129
+ }
130
+ })();
131
+ setAsyncAgentReaderCache(filePath, { expiresAt: now + AGENT_READER_TTL_MS, records: cached?.records ?? [], inFlight });
132
+ return inFlight;
79
133
  }
80
134
 
81
135
  export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
82
136
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
83
- atomicWriteJson(agentsPath(manifest), redactSecrets(records));
137
+ const filePath = agentsPath(manifest);
138
+ atomicWriteJson(filePath, redactSecrets(records));
139
+ asyncAgentReaderCache.delete(filePath);
84
140
  for (const record of records) writeCrewAgentStatus(manifest, record);
85
141
  }
86
142
 
87
143
  export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentRecord): void {
88
- const records = readCrewAgents(manifest).filter((item) => item.id !== record.id);
89
- records.push(record);
90
- saveCrewAgents(manifest, records);
144
+ // Read current state
145
+ const existing = readCrewAgents(manifest);
146
+ // Deduplicate by id: keep newer record when same id appears
147
+ const idIndex = new Map(existing.map((item, i) => [item.id, i]));
148
+ const merged: CrewAgentRecord[] = existing.map((item) => item.id === record.id ? record : item);
149
+ if (!idIndex.has(record.id)) merged.push(record);
150
+ saveCrewAgents(manifest, merged);
91
151
  writeCrewAgentStatus(manifest, record);
92
152
  }
93
153
 
@@ -23,6 +23,7 @@ export interface CrewAgentProgress {
23
23
  lastActivityAt?: string;
24
24
  activityState?: CrewActivityState;
25
25
  failedTool?: string;
26
+ consecutiveFailures?: number;
26
27
  }
27
28
 
28
29
  export interface CrewAgentRecord {