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.
- package/CHANGELOG.md +97 -0
- package/README.md +5 -5
- package/agents/analyst.md +11 -11
- package/agents/critic.md +11 -11
- package/agents/executor.md +11 -11
- package/agents/explorer.md +11 -11
- package/agents/planner.md +11 -11
- package/agents/reviewer.md +11 -11
- package/agents/security-reviewer.md +11 -11
- package/agents/test-engineer.md +11 -11
- package/agents/verifier.md +11 -11
- package/agents/writer.md +11 -11
- package/docs/next-upgrade-roadmap.md +808 -0
- package/docs/research/AGENT-EXECUTION-ARCHITECTURE.md +261 -0
- package/docs/research/AGENT-LIFECYCLE-COMPARISON.md +111 -0
- package/docs/research/AUDIT_OH_MY_PI.md +261 -0
- package/docs/research/AUDIT_PI_CREW.md +457 -0
- package/docs/research/CAVEMAN-DEEP-RESEARCH.md +281 -0
- package/docs/research/COMPARISON_OH_MY_PI_VS_PI_CREW.md +264 -0
- package/docs/research/DEEP-RESEARCH-PI-POWERBAR.md +343 -0
- package/docs/research/DEEP_RESEARCH_SUBAGENT_ARCHITECTURE.md +480 -0
- package/docs/research/GAP_CLOSURE_IMPLEMENTATION_PLAN.md +354 -0
- package/docs/research/IMPLEMENTATION_PLAN.md +385 -0
- package/docs/research/LIVE-SESSION-PRODUCTION-READY-PLAN.md +502 -0
- package/docs/research/OH-MY-PI-DEEP-RESEARCH-v14.7.6.md +266 -0
- package/docs/research/REMAINING-GAPS-PLAN.md +363 -0
- package/docs/research/SESSION-SUMMARY-2026-05-08.md +146 -0
- package/docs/research/UI-RESPONSIVENESS-AUDIT.md +173 -0
- package/docs/research-awesome-agent-skills-distillation.md +100 -0
- package/docs/research-oh-my-pi-distillation.md +369 -0
- package/docs/source-runtime-refactor-map.md +24 -0
- package/docs/usage.md +3 -3
- package/install.mjs +52 -8
- package/package.json +99 -98
- package/schema.json +10 -1
- package/skills/async-worker-recovery/SKILL.md +42 -0
- package/skills/context-artifact-hygiene/SKILL.md +52 -0
- package/skills/delegation-patterns/SKILL.md +54 -0
- package/skills/mailbox-interactive/SKILL.md +40 -0
- package/skills/model-routing-context/SKILL.md +39 -0
- package/skills/multi-perspective-review/SKILL.md +58 -0
- package/skills/observability-reliability/SKILL.md +41 -0
- package/skills/orchestration/SKILL.md +157 -0
- package/skills/ownership-session-security/SKILL.md +41 -0
- package/skills/pi-extension-lifecycle/SKILL.md +39 -0
- package/skills/requirements-to-task-packet/SKILL.md +63 -0
- package/skills/resource-discovery-config/SKILL.md +41 -0
- package/skills/runtime-state-reader/SKILL.md +44 -0
- package/skills/secure-agent-orchestration-review/SKILL.md +45 -0
- package/skills/state-mutation-locking/SKILL.md +42 -0
- package/skills/systematic-debugging/SKILL.md +67 -0
- package/skills/ui-render-performance/SKILL.md +39 -0
- package/skills/verification-before-done/SKILL.md +57 -0
- package/skills/worktree-isolation/SKILL.md +39 -0
- package/src/agents/agent-config.ts +6 -0
- package/src/agents/agent-search.ts +98 -0
- package/src/agents/agent-serializer.ts +38 -34
- package/src/agents/discover-agents.ts +29 -15
- package/src/config/config.ts +72 -24
- package/src/config/defaults.ts +25 -0
- package/src/extension/autonomous-policy.ts +26 -33
- package/src/extension/help.ts +1 -0
- package/src/extension/management.ts +5 -0
- package/src/extension/project-init.ts +62 -2
- package/src/extension/register.ts +69 -22
- package/src/extension/registration/commands.ts +64 -25
- package/src/extension/registration/compaction-guard.ts +1 -1
- package/src/extension/registration/subagent-helpers.ts +8 -0
- package/src/extension/registration/subagent-tools.ts +149 -148
- package/src/extension/registration/team-tool.ts +14 -10
- package/src/extension/run-index.ts +35 -21
- package/src/extension/run-maintenance.ts +30 -5
- package/src/extension/team-tool/api.ts +47 -9
- package/src/extension/team-tool/cancel.ts +109 -5
- package/src/extension/team-tool/context.ts +8 -0
- package/src/extension/team-tool/intent-policy.ts +42 -0
- package/src/extension/team-tool/lifecycle-actions.ts +120 -79
- package/src/extension/team-tool/parallel-dispatch.ts +156 -0
- package/src/extension/team-tool/respond.ts +46 -18
- package/src/extension/team-tool/run.ts +55 -12
- package/src/extension/team-tool/status.ts +13 -2
- package/src/extension/team-tool-types.ts +3 -0
- package/src/extension/team-tool.ts +45 -14
- package/src/hooks/registry.ts +61 -0
- package/src/hooks/types.ts +41 -0
- package/src/observability/event-to-metric.ts +8 -1
- package/src/runtime/agent-control.ts +169 -63
- package/src/runtime/async-runner.ts +3 -1
- package/src/runtime/background-runner.ts +78 -53
- package/src/runtime/cancellation-token.ts +89 -0
- package/src/runtime/cancellation.ts +61 -0
- package/src/runtime/capability-inventory.ts +116 -0
- package/src/runtime/child-pi.ts +458 -444
- package/src/runtime/code-summary.ts +247 -0
- package/src/runtime/crash-recovery.ts +182 -0
- package/src/runtime/crew-agent-records.ts +70 -10
- package/src/runtime/crew-agent-runtime.ts +1 -0
- package/src/runtime/custom-tools/irc-tool.ts +201 -0
- package/src/runtime/custom-tools/submit-result-tool.ts +90 -0
- package/src/runtime/deadletter.ts +1 -0
- package/src/runtime/delivery-coordinator.ts +48 -25
- package/src/runtime/effectiveness.ts +81 -0
- package/src/runtime/event-stream-bridge.ts +90 -0
- package/src/runtime/live-agent-control.ts +2 -1
- package/src/runtime/live-agent-manager.ts +179 -85
- package/src/runtime/live-control-realtime.ts +1 -1
- package/src/runtime/live-extension-bridge.ts +150 -0
- package/src/runtime/live-irc.ts +92 -0
- package/src/runtime/live-session-health.ts +100 -0
- package/src/runtime/live-session-runtime.ts +599 -305
- package/src/runtime/manifest-cache.ts +17 -2
- package/src/runtime/mcp-proxy.ts +113 -0
- package/src/runtime/model-fallback.ts +6 -4
- package/src/runtime/notebook-helpers.ts +90 -0
- package/src/runtime/orphan-sentinel.ts +7 -0
- package/src/runtime/output-validator.ts +187 -0
- package/src/runtime/parallel-utils.ts +57 -0
- package/src/runtime/parent-guard.ts +80 -0
- package/src/runtime/pi-args.ts +18 -3
- package/src/runtime/process-status.ts +5 -1
- package/src/runtime/prose-compressor.ts +164 -0
- package/src/runtime/result-extractor.ts +121 -0
- package/src/runtime/retry-executor.ts +81 -64
- package/src/runtime/runtime-resolver.ts +23 -10
- package/src/runtime/semaphore.ts +131 -0
- package/src/runtime/sensitive-paths.ts +92 -0
- package/src/runtime/skill-instructions.ts +222 -0
- package/src/runtime/stale-reconciler.ts +4 -14
- package/src/runtime/stream-preview.ts +177 -0
- package/src/runtime/subagent-manager.ts +6 -2
- package/src/runtime/subprocess-tool-registry.ts +67 -0
- package/src/runtime/task-output-context.ts +177 -127
- package/src/runtime/task-runner/capabilities.ts +78 -0
- package/src/runtime/task-runner/live-executor.ts +107 -101
- package/src/runtime/task-runner/prompt-builder.ts +72 -8
- package/src/runtime/task-runner/prompt-pipeline.ts +64 -0
- package/src/runtime/task-runner/run-projection.ts +104 -0
- package/src/runtime/task-runner.ts +115 -5
- package/src/runtime/team-runner.ts +134 -19
- package/src/runtime/workspace-tree.ts +298 -0
- package/src/runtime/yield-handler.ts +189 -0
- package/src/schema/config-schema.ts +7 -0
- package/src/schema/team-tool-schema.ts +14 -4
- package/src/skills/discover-skills.ts +67 -0
- package/src/state/active-run-registry.ts +167 -0
- package/src/state/artifact-store.ts +4 -1
- package/src/state/atomic-write.ts +50 -1
- package/src/state/blob-store.ts +117 -0
- package/src/state/contracts.ts +2 -1
- package/src/state/event-log-rotation.ts +158 -0
- package/src/state/event-log.ts +52 -2
- package/src/state/mailbox.ts +129 -9
- package/src/state/state-store.ts +32 -5
- package/src/state/types.ts +64 -2
- package/src/teams/team-config.ts +1 -0
- package/src/ui/agent-management-overlay.ts +144 -0
- package/src/ui/crew-widget.ts +15 -5
- package/src/ui/dashboard-panes/cancellation-pane.ts +43 -0
- package/src/ui/dashboard-panes/capability-pane.ts +60 -0
- package/src/ui/dashboard-panes/mailbox-pane.ts +35 -11
- package/src/ui/dashboard-panes/progress-pane.ts +2 -0
- package/src/ui/live-run-sidebar.ts +4 -0
- package/src/ui/powerbar-publisher.ts +77 -15
- package/src/ui/render-coalescer.ts +51 -0
- package/src/ui/run-dashboard.ts +4 -0
- package/src/ui/run-event-bus.ts +209 -0
- package/src/ui/run-snapshot-cache.ts +78 -18
- package/src/ui/snapshot-types.ts +10 -0
- package/src/ui/transcript-entries.ts +258 -0
- package/src/utils/ids.ts +5 -0
- package/src/utils/incremental-reader.ts +104 -0
- package/src/utils/paths.ts +4 -2
- package/src/utils/scan-cache.ts +137 -0
- package/src/utils/sse-parser.ts +134 -0
- package/src/utils/task-name-generator.ts +337 -0
- package/src/utils/visual.ts +33 -2
- package/src/workflows/workflow-config.ts +1 -0
- 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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|