pi-crew 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/docs/actions-reference.md +87 -0
- package/docs/commands-reference.md +5 -0
- package/docs/pi-crew-bugs.md +6 -0
- package/index.ts +1 -1
- package/package.json +18 -16
- package/src/benchmark/benchmark-runner.ts +245 -0
- package/src/benchmark/feedback-loop.ts +66 -0
- package/src/extension/async-notifier.ts +1 -1
- package/src/extension/autonomous-policy.ts +1 -1
- package/src/extension/cross-extension-rpc.ts +1 -1
- package/src/extension/plan-orchestrate.ts +322 -0
- package/src/extension/register.ts +31 -41
- package/src/extension/registration/command-utils.ts +1 -1
- package/src/extension/registration/commands.ts +1 -1
- package/src/extension/registration/compaction-guard.ts +1 -1
- package/src/extension/registration/subagent-helpers.ts +1 -1
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/registration/team-tool.ts +1 -1
- package/src/extension/registration/viewers.ts +1 -1
- package/src/extension/session-summary.ts +1 -1
- package/src/extension/team-manager-command.ts +1 -1
- package/src/extension/team-tool/context.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +183 -0
- package/src/extension/team-tool/orchestrate.ts +102 -0
- package/src/extension/team-tool/run.ts +215 -28
- package/src/extension/team-tool.ts +10 -0
- package/src/extension/tool-result.ts +1 -1
- package/src/i18n.ts +1 -1
- package/src/observability/event-to-metric.ts +1 -1
- package/src/prompt/prompt-runtime.ts +1 -1
- package/src/runtime/background-runner.ts +27 -5
- package/src/runtime/crash-recovery.ts +1 -1
- package/src/runtime/crew-hooks.ts +240 -0
- package/src/runtime/custom-tools/irc-tool.ts +1 -1
- package/src/runtime/custom-tools/submit-result-tool.ts +1 -1
- package/src/runtime/diagnostic-export.ts +38 -2
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/live-session-runtime.ts +1 -1
- package/src/runtime/mcp-proxy.ts +1 -1
- package/src/runtime/pi-spawn.ts +20 -4
- package/src/runtime/process-status.ts +15 -2
- package/src/runtime/runtime-resolver.ts +1 -1
- package/src/runtime/session-resources.ts +1 -1
- package/src/runtime/task-runner.ts +31 -1
- package/src/runtime/team-runner.ts +6 -0
- package/src/schema/team-tool-schema.ts +24 -1
- package/src/state/crew-init.ts +56 -38
- package/src/state/decision-ledger.ts +295 -0
- package/src/state/hook-instinct-bridge.ts +90 -0
- package/src/state/hook-integrations.ts +51 -0
- package/src/state/instinct-store.ts +249 -0
- package/src/state/run-metrics.ts +135 -0
- package/src/state/tiered-eval.ts +471 -0
- package/src/state/types-eval.ts +58 -0
- package/src/state/types.ts +3 -0
- package/src/tools/safe-bash-extension.ts +5 -5
- package/src/ui/crew-widget.ts +1 -1
- package/src/ui/pi-ui-compat.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +1 -1
- package/src/ui/tool-render.ts +2 -2
- package/src/utils/project-detector.ts +160 -0
- package/test-bugs-all.mjs +1 -1
- package/skills/.gitkeep +0 -0
- package/skills/REFERENCE.md +0 -136
|
@@ -19,6 +19,7 @@ async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Pro
|
|
|
19
19
|
return _cachedExecuteTeamRun(...args);
|
|
20
20
|
}
|
|
21
21
|
import { resolveCrewRuntime, runtimeResolutionState } from "./runtime-resolver.ts";
|
|
22
|
+
import { terminateActiveChildPiProcesses } from "./child-pi.ts";
|
|
22
23
|
import { directTeamAndWorkflowFromRun } from "./direct-run.ts";
|
|
23
24
|
import { expandParallelResearchWorkflow } from "./parallel-research.ts";
|
|
24
25
|
import { writeAsyncStartMarker } from "./async-marker.ts";
|
|
@@ -67,7 +68,7 @@ function argValue(name: string): string | undefined {
|
|
|
67
68
|
return process.argv[index + 1];
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function startInterruptGuard(manifest: { runId: string; stateRoot: string; eventsPath: string }): () => void {
|
|
71
|
+
function startInterruptGuard(manifest: { runId: string; stateRoot: string; eventsPath: string }, abortController: AbortController): () => void {
|
|
71
72
|
const controlPath = path.join(manifest.stateRoot, "foreground-control.json");
|
|
72
73
|
const interval = setInterval(() => {
|
|
73
74
|
try {
|
|
@@ -75,13 +76,21 @@ function startInterruptGuard(manifest: { runId: string; stateRoot: string; event
|
|
|
75
76
|
const parsed = JSON.parse(fs.readFileSync(controlPath, "utf-8")) as { requests?: Array<{ type: string; acknowledged?: boolean }> };
|
|
76
77
|
const last = parsed.requests?.at(-1);
|
|
77
78
|
if (last?.type === "interrupt" && last?.acknowledged !== true) {
|
|
78
|
-
appendEvent(manifest.eventsPath, { type: "async.interrupt_detected", runId: manifest.runId, message: "Background runner detected foreground interrupt
|
|
79
|
+
appendEvent(manifest.eventsPath, { type: "async.interrupt_detected", runId: manifest.runId, message: "Background runner detected foreground interrupt — killing child processes and exiting." });
|
|
80
|
+
// FIX: Terminate ALL child-pi processes IMMEDIATELY before exiting.
|
|
81
|
+
// Previously this was missing, causing orphaned child processes to run forever
|
|
82
|
+
// after the background-runner exited. terminateActiveChildPiProcesses sends
|
|
83
|
+
// SIGTERM then SIGKILL (after HARD_KILL_MS=3s) to every active child.
|
|
84
|
+
const killed = terminateActiveChildPiProcesses();
|
|
85
|
+
console.log(`[background-runner] interrupt: killed ${killed} child processes`);
|
|
86
|
+
// Also abort the run signal so executeTeamRun exits quickly via its signal check.
|
|
87
|
+
abortController.abort();
|
|
79
88
|
process.exit(130);
|
|
80
89
|
}
|
|
81
90
|
} catch {
|
|
82
91
|
/* ignore read/parse errors */
|
|
83
92
|
}
|
|
84
|
-
},
|
|
93
|
+
}, 500); // FIX: Reduced from 3000ms to 500ms for faster cancel response
|
|
85
94
|
interval.unref();
|
|
86
95
|
return () => clearInterval(interval);
|
|
87
96
|
}
|
|
@@ -238,8 +247,13 @@ async function main(): Promise<void> {
|
|
|
238
247
|
appendEvent(manifest.eventsPath, { type: "async.started", runId: manifest.runId, data: { pid: process.pid } });
|
|
239
248
|
console.log(`[background-runner] DEBUG: async.started written, pid=${process.pid}`);
|
|
240
249
|
writeAsyncStartMarker(manifest, { pid: process.pid, startedAt: new Date().toISOString() });
|
|
250
|
+
// FIX: Create AbortController EARLY so interrupt guard can use it.
|
|
251
|
+
// abortController.signal flows through: executeTeamRun → runTeamTask → runChildPi.
|
|
252
|
+
// When interrupt guard detects cancel, abortController.abort() fires the abort
|
|
253
|
+
// handler in runChildPi which kills child processes immediately.
|
|
254
|
+
const abortController = new AbortController();
|
|
241
255
|
const stopHeartbeat = startHeartbeat(manifest.stateRoot, manifest.eventsPath, manifest.runId);
|
|
242
|
-
const stopInterruptGuard = startInterruptGuard(manifest);
|
|
256
|
+
const stopInterruptGuard = startInterruptGuard(manifest, abortController);
|
|
243
257
|
console.log(`[background-runner] DEBUG: heartbeat+interrupt guard started`);
|
|
244
258
|
// BUG #17: Keep-alive interval prevents event loop from exiting during
|
|
245
259
|
// jiti compilation. Pure empty interval (no I/O to avoid io_uring issues).
|
|
@@ -278,10 +292,13 @@ async function main(): Promise<void> {
|
|
|
278
292
|
// BUG #17: Keep-alive interval (NOT unref'd) prevents event loop from exiting
|
|
279
293
|
// during jiti compilation of team-runner.ts. Without this, the event loop
|
|
280
294
|
// can drain when import() blocks, causing the process to exit prematurely.
|
|
295
|
+
// NOTE: abortController is already created above (before heartbeat/interrupt guard start)
|
|
296
|
+
// so it is available here and its signal is passed through to executeTeamRun → child-pi.
|
|
297
|
+
|
|
281
298
|
console.log(`[background-runner] DEBUG: calling executeTeamRun`);
|
|
282
299
|
let result;
|
|
283
300
|
try {
|
|
284
|
-
result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: runConfig.limits, runtime, runtimeConfig: runConfig.runtime, skillOverride: manifest.skillOverride, reliability: runConfig.reliability, workspaceId: manifest.ownerSessionId ?? manifest.cwd });
|
|
301
|
+
result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: runConfig.limits, runtime, runtimeConfig: runConfig.runtime, skillOverride: manifest.skillOverride, reliability: runConfig.reliability, workspaceId: manifest.ownerSessionId ?? manifest.cwd, signal: abortController.signal });
|
|
285
302
|
console.log(`[background-runner] DEBUG: executeTeamRun returned, status=${result.manifest.status}`);
|
|
286
303
|
} catch (execError) {
|
|
287
304
|
console.log(`[background-runner] DEBUG: executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`);
|
|
@@ -314,6 +331,11 @@ async function main(): Promise<void> {
|
|
|
314
331
|
stopParentGuard();
|
|
315
332
|
stopHeartbeat();
|
|
316
333
|
clearInterval(keepAlive);
|
|
334
|
+
// FIX: Always kill child processes on exit. executeTeamRun's terminateLiveAgentsForRun
|
|
335
|
+
// only handles live-session agents, not child-pi processes. Without this, child-pi
|
|
336
|
+
// processes can become orphaned if executeTeamRun throws before completing.
|
|
337
|
+
const killed = terminateActiveChildPiProcesses();
|
|
338
|
+
console.log(`[background-runner] finally: killed ${killed} child processes`);
|
|
317
339
|
}
|
|
318
340
|
}
|
|
319
341
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
4
4
|
import { appendEvent, scanSequence } from "../state/event-log.ts";
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew Hook System — a hook-based observation system for pi-crew runtime.
|
|
3
|
+
*
|
|
4
|
+
* Provides a reliable, fire-and-forget event system for observing crew lifecycle events.
|
|
5
|
+
* Hooks are executed synchronously without blocking the emitter.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { crewHooks } from './runtime/crew-hooks.ts';
|
|
10
|
+
*
|
|
11
|
+
* // Register a hook
|
|
12
|
+
* const myHook = (event) => {
|
|
13
|
+
* console.log(`Event: ${event.type}`, event);
|
|
14
|
+
* };
|
|
15
|
+
* crewHooks.register('task_started', myHook);
|
|
16
|
+
*
|
|
17
|
+
* // Emit an event
|
|
18
|
+
* crewHooks.emit({ type: 'task_started', timestamp: new Date().toISOString(), runId: 'run-123', taskId: 'task-1' });
|
|
19
|
+
*
|
|
20
|
+
* // Unregister when done
|
|
21
|
+
* crewHooks.unregister('task_started', myHook);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Valid hook event types in the crew lifecycle. */
|
|
26
|
+
export type CrewHookEventType =
|
|
27
|
+
| 'task_started'
|
|
28
|
+
| 'task_completed'
|
|
29
|
+
| 'task_failed'
|
|
30
|
+
| 'run_completed'
|
|
31
|
+
| 'run_failed';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A hook event emitted by the crew runtime.
|
|
35
|
+
*/
|
|
36
|
+
export interface CrewHookEvent {
|
|
37
|
+
/** The type of event being emitted. */
|
|
38
|
+
type: CrewHookEventType;
|
|
39
|
+
/** ISO timestamp of when the event occurred. */
|
|
40
|
+
timestamp: string;
|
|
41
|
+
/** The unique identifier of the run that generated this event. */
|
|
42
|
+
runId: string;
|
|
43
|
+
/** Optional task identifier (present for task-scoped events). */
|
|
44
|
+
taskId?: string;
|
|
45
|
+
/** Optional additional event data. */
|
|
46
|
+
data?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A hook function that can be registered to receive crew events.
|
|
51
|
+
* May be synchronous or return a Promise (async hooks are fire-and-forget).
|
|
52
|
+
*/
|
|
53
|
+
export type CrewHook = (event: CrewHookEvent) => void | Promise<void>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Type guard to check if a value is a valid CrewHookEventType.
|
|
57
|
+
*/
|
|
58
|
+
export function isValidEventType(type: string): type is CrewHookEventType {
|
|
59
|
+
return (
|
|
60
|
+
type === 'task_started' ||
|
|
61
|
+
type === 'task_completed' ||
|
|
62
|
+
type === 'task_failed' ||
|
|
63
|
+
type === 'run_completed' ||
|
|
64
|
+
type === 'run_failed'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Type guard to check if an object is a valid CrewHookEvent.
|
|
70
|
+
*/
|
|
71
|
+
export function isHookEvent(obj: unknown): obj is CrewHookEvent {
|
|
72
|
+
if (typeof obj !== 'object' || obj === null) return false;
|
|
73
|
+
const event = obj as Record<string, unknown>;
|
|
74
|
+
return (
|
|
75
|
+
typeof event.type === 'string' &&
|
|
76
|
+
isValidEventType(event.type) &&
|
|
77
|
+
typeof event.timestamp === 'string' &&
|
|
78
|
+
typeof event.runId === 'string' &&
|
|
79
|
+
(event.taskId === undefined || typeof event.taskId === 'string') &&
|
|
80
|
+
(event.data === undefined || typeof event.data === 'object')
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Registry for managing and emitting crew lifecycle hooks.
|
|
86
|
+
*
|
|
87
|
+
* Hooks are stored in Sets for efficient insertion, deletion, and iteration.
|
|
88
|
+
* The emit() method executes all registered hooks synchronously without awaiting
|
|
89
|
+
* async completions, ensuring 100% reliable event firing without blocking.
|
|
90
|
+
*/
|
|
91
|
+
export class HookRegistry {
|
|
92
|
+
private readonly hooks: Map<CrewHookEventType, Set<CrewHook>>;
|
|
93
|
+
|
|
94
|
+
constructor() {
|
|
95
|
+
this.hooks = new Map();
|
|
96
|
+
// Initialize with empty Sets for all event types
|
|
97
|
+
const eventTypes: CrewHookEventType[] = [
|
|
98
|
+
'task_started',
|
|
99
|
+
'task_completed',
|
|
100
|
+
'task_failed',
|
|
101
|
+
'run_completed',
|
|
102
|
+
'run_failed',
|
|
103
|
+
];
|
|
104
|
+
for (const type of eventTypes) {
|
|
105
|
+
this.hooks.set(type, new Set());
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Register a hook to be called when the specified event type is emitted.
|
|
111
|
+
*
|
|
112
|
+
* @param eventType - The type of event to listen for
|
|
113
|
+
* @param hook - The hook function to register
|
|
114
|
+
*/
|
|
115
|
+
register(eventType: CrewHookEventType, hook: CrewHook): void {
|
|
116
|
+
const hooksForType = this.hooks.get(eventType);
|
|
117
|
+
if (hooksForType) {
|
|
118
|
+
hooksForType.add(hook);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Unregister a previously registered hook.
|
|
124
|
+
*
|
|
125
|
+
* @param eventType - The type of event the hook was registered for
|
|
126
|
+
* @param hook - The hook function to remove
|
|
127
|
+
*/
|
|
128
|
+
unregister(eventType: CrewHookEventType, hook: CrewHook): void {
|
|
129
|
+
const hooksForType = this.hooks.get(eventType);
|
|
130
|
+
if (hooksForType) {
|
|
131
|
+
hooksForType.delete(hook);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Emit an event to all registered hooks for that event type.
|
|
137
|
+
*
|
|
138
|
+
* This method executes all hooks synchronously and does not await async hooks.
|
|
139
|
+
* Errors thrown by hooks are caught and logged but do not prevent other hooks
|
|
140
|
+
* from executing or block the caller.
|
|
141
|
+
*
|
|
142
|
+
* @param event - The event to emit
|
|
143
|
+
*/
|
|
144
|
+
emit(event: CrewHookEvent): void {
|
|
145
|
+
// Validate event type using type guard
|
|
146
|
+
if (!isValidEventType(event.type)) {
|
|
147
|
+
console.warn(`[crew-hooks] Unknown event type: ${event.type}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const hooksForType = this.hooks.get(event.type);
|
|
152
|
+
if (!hooksForType || hooksForType.size === 0) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Execute all hooks - fire-and-forget pattern
|
|
157
|
+
// We iterate over a snapshot to allow safe modification during iteration
|
|
158
|
+
const hooksSnapshot = Array.from(hooksForType);
|
|
159
|
+
for (const hook of hooksSnapshot) {
|
|
160
|
+
try {
|
|
161
|
+
const result = hook(event);
|
|
162
|
+
// If the hook returns a Promise, we intentionally do NOT await it.
|
|
163
|
+
// This is the "fire-and-forget" pattern - async hooks run in background.
|
|
164
|
+
if (result instanceof Promise) {
|
|
165
|
+
// Attach a silent catch to prevent unhandled rejection warnings
|
|
166
|
+
result.catch((err) => {
|
|
167
|
+
console.error(`[crew-hooks] Async hook error for ${event.type}:`, err);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// Catch synchronous errors but don't let them block other hooks
|
|
172
|
+
console.error(`[crew-hooks] Hook error for ${event.type}:`, err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get all hooks registered for a specific event type.
|
|
179
|
+
*
|
|
180
|
+
* Returns a snapshot of the current hooks. The returned array is a new copy,
|
|
181
|
+
* so modifications to it won't affect the registry.
|
|
182
|
+
*
|
|
183
|
+
* @param eventType - The event type to query
|
|
184
|
+
* @returns Array of registered hooks (may be empty)
|
|
185
|
+
*/
|
|
186
|
+
hooksFor(eventType: CrewHookEventType): CrewHook[] {
|
|
187
|
+
const hooksForType = this.hooks.get(eventType);
|
|
188
|
+
if (!hooksForType) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
return Array.from(hooksForType);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the count of hooks registered for a specific event type.
|
|
196
|
+
*
|
|
197
|
+
* @param eventType - The event type to query
|
|
198
|
+
* @returns Number of registered hooks
|
|
199
|
+
*/
|
|
200
|
+
count(eventType: CrewHookEventType): number {
|
|
201
|
+
const hooksForType = this.hooks.get(eventType);
|
|
202
|
+
return hooksForType?.size ?? 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Remove all hooks for a specific event type.
|
|
207
|
+
*
|
|
208
|
+
* @param eventType - The event type to clear
|
|
209
|
+
*/
|
|
210
|
+
clear(eventType: CrewHookEventType): void {
|
|
211
|
+
const hooksForType = this.hooks.get(eventType);
|
|
212
|
+
if (hooksForType) {
|
|
213
|
+
hooksForType.clear();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Remove all registered hooks across all event types.
|
|
219
|
+
*/
|
|
220
|
+
clearAll(): void {
|
|
221
|
+
for (const hooksForType of this.hooks.values()) {
|
|
222
|
+
hooksForType.clear();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Global singleton instance of HookRegistry for use throughout pi-crew.
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```typescript
|
|
232
|
+
* import { crewHooks } from './runtime/crew-hooks.ts';
|
|
233
|
+
*
|
|
234
|
+
* // Simple logging hook
|
|
235
|
+
* crewHooks.register('task_completed', (event) => {
|
|
236
|
+
* console.log(`Task ${event.taskId} completed in run ${event.runId}`);
|
|
237
|
+
* });
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
export const crewHooks = new HookRegistry();
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* for routing messages between in-process workers.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { defineTool, type ToolDefinition } from "@
|
|
15
|
+
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Type, type Static } from "@sinclair/typebox";
|
|
17
17
|
import { listLiveAgents, sendIrcMessage, broadcastIrcMessage } from "../live-agent-manager.ts";
|
|
18
18
|
import type { IrcMessage } from "../live-irc.ts";
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* and TypeBox schemas for validation.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { defineTool, type ToolDefinition } from "@
|
|
12
|
+
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { Type, type Static } from "@sinclair/typebox";
|
|
14
14
|
import type { YieldResult } from "../yield-handler.ts";
|
|
15
15
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
3
3
|
import type { MetricSnapshot } from "../observability/metrics-primitives.ts";
|
|
4
4
|
import * as fs from "node:fs";
|
|
@@ -10,6 +10,7 @@ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
|
10
10
|
import { summarizeHeartbeats, type HeartbeatSummary } from "../ui/heartbeat-aggregator.ts";
|
|
11
11
|
import type { RunUiSnapshot } from "../ui/snapshot-types.ts";
|
|
12
12
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
13
|
+
import { buildRecoveryLedger, type RecoveryLedgerEntry } from "./recovery-recipes.ts";
|
|
13
14
|
export { redactSecrets } from "../utils/redaction.ts";
|
|
14
15
|
|
|
15
16
|
export interface DiagnosticReport {
|
|
@@ -23,6 +24,17 @@ export interface DiagnosticReport {
|
|
|
23
24
|
agents: unknown[];
|
|
24
25
|
envRedacted: Record<string, string>;
|
|
25
26
|
metricsSnapshot?: MetricSnapshot[];
|
|
27
|
+
// Layer 8: task diagnostics
|
|
28
|
+
taskDiagnostics: Record<string, Record<string, unknown>>;
|
|
29
|
+
// Layer 9: terminal evidence
|
|
30
|
+
terminalEvidence: Record<string, TeamTaskState["terminalEvidence"]>;
|
|
31
|
+
// Layer 10: model attempts and routing
|
|
32
|
+
modelAttempts: { taskId: string; attempts: TeamTaskState["modelAttempts"]; routing: TeamTaskState["modelRouting"] }[];
|
|
33
|
+
// Layer 11: pending mailbox
|
|
34
|
+
pendingMailbox: { taskId: string; pendingSteers: TeamTaskState["pendingSteers"] }[];
|
|
35
|
+
runMailboxUnread: RunUiSnapshot["mailbox"];
|
|
36
|
+
// Layer 12: recovery ledger
|
|
37
|
+
recoveryLedger: RecoveryLedgerEntry[];
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
const SECRET_KEY_PATTERN = /(token|key|password|secret|credential|auth)/i;
|
|
@@ -69,6 +81,24 @@ export async function exportDiagnostic(ctx: Pick<ExtensionContext, "cwd">, runId
|
|
|
69
81
|
const safeTimestamp = exportedAt.replace(/[:.]/g, "-");
|
|
70
82
|
const recentEvents = readEvents(loaded.manifest.eventsPath).slice(-200);
|
|
71
83
|
const metricsSnapshot = options.registry?.snapshot();
|
|
84
|
+
const taskDiagnostics: Record<string, Record<string, unknown>> = {};
|
|
85
|
+
const terminalEvidence: Record<string, TeamTaskState["terminalEvidence"]> = {};
|
|
86
|
+
const modelAttempts: { taskId: string; attempts: TeamTaskState["modelAttempts"]; routing: TeamTaskState["modelRouting"] }[] = [];
|
|
87
|
+
const pendingMailbox: { taskId: string; pendingSteers: TeamTaskState["pendingSteers"] }[] = [];
|
|
88
|
+
for (const task of loaded.tasks) {
|
|
89
|
+
if (task.diagnostics) taskDiagnostics[task.id] = task.diagnostics;
|
|
90
|
+
if (task.terminalEvidence) terminalEvidence[task.id] = task.terminalEvidence;
|
|
91
|
+
if (task.modelAttempts || task.modelRouting) {
|
|
92
|
+
modelAttempts.push({ taskId: task.id, attempts: task.modelAttempts, routing: task.modelRouting });
|
|
93
|
+
}
|
|
94
|
+
if (task.pendingSteers) {
|
|
95
|
+
pendingMailbox.push({ taskId: task.id, pendingSteers: task.pendingSteers });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const recoveryLedger = loaded.manifest.policyDecisions
|
|
99
|
+
? buildRecoveryLedger(loaded.manifest.policyDecisions).entries
|
|
100
|
+
: [];
|
|
101
|
+
const snapshot = buildSnapshot(loaded.manifest, loaded.tasks);
|
|
72
102
|
const report: DiagnosticReport = {
|
|
73
103
|
...(metricsSnapshot ? { schemaVersion: 2 } : {}),
|
|
74
104
|
runId,
|
|
@@ -76,10 +106,16 @@ export async function exportDiagnostic(ctx: Pick<ExtensionContext, "cwd">, runId
|
|
|
76
106
|
manifest: redactSecrets(loaded.manifest) as TeamRunManifest,
|
|
77
107
|
tasks: redactSecrets(loaded.tasks) as TeamTaskState[],
|
|
78
108
|
recentEvents: redactSecrets(recentEvents) as TeamEvent[],
|
|
79
|
-
heartbeat: summarizeHeartbeats(
|
|
109
|
+
heartbeat: summarizeHeartbeats(snapshot),
|
|
80
110
|
agents: redactSecrets(readCrewAgents(loaded.manifest)) as unknown[],
|
|
81
111
|
envRedacted: envRedacted(),
|
|
82
112
|
...(metricsSnapshot ? { metricsSnapshot: redactSecrets(metricsSnapshot) as MetricSnapshot[] } : {}),
|
|
113
|
+
taskDiagnostics,
|
|
114
|
+
terminalEvidence,
|
|
115
|
+
modelAttempts,
|
|
116
|
+
pendingMailbox,
|
|
117
|
+
runMailboxUnread: snapshot.mailbox,
|
|
118
|
+
recoveryLedger,
|
|
83
119
|
};
|
|
84
120
|
const dir = path.join(loaded.manifest.artifactsRoot, "diagnostic");
|
|
85
121
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* is automatically notified — no manual sleep+check needed.
|
|
12
12
|
* 3. Cleans up after itself when the run completes or the session ends.
|
|
13
13
|
*/
|
|
14
|
-
import type { ExtensionAPI } from "@
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { loadRunManifestById } from "../state/state-store.ts";
|
|
16
16
|
import { readCrewAgents } from "./crew-agent-records.ts";
|
|
17
17
|
import { isActiveRunStatus, isLikelyOrphanedActiveRun } from "./process-status.ts";
|
|
@@ -336,7 +336,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
336
336
|
const availability = await isLiveSessionRuntimeAvailable();
|
|
337
337
|
if (!availability.available) return { available: true, exitCode: 1, stdout: "", stderr: availability.reason ?? "Live-session runtime unavailable.", jsonEvents: 0, error: availability.reason };
|
|
338
338
|
// LAZY: optional peer dependency — only loaded when live-session runtime is chosen.
|
|
339
|
-
const mod = await import("@
|
|
339
|
+
const mod = await import("@earendil-works/pi-coding-agent") as unknown as LiveSessionModule;
|
|
340
340
|
if (typeof mod.createAgentSession !== "function") return { available: true, exitCode: 1, stdout: "", stderr: "createAgentSession export is unavailable.", jsonEvents: 0, error: "createAgentSession export is unavailable." };
|
|
341
341
|
let session: LiveSessionLike | undefined;
|
|
342
342
|
let unsubscribe: (() => void) | undefined;
|
package/src/runtime/mcp-proxy.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* when proxying from the parent.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { defineTool, type ToolDefinition } from "@
|
|
19
|
+
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Type, type Static, type TSchema } from "@sinclair/typebox";
|
|
21
21
|
|
|
22
22
|
export interface McpProxyConfig {
|
package/src/runtime/pi-spawn.ts
CHANGED
|
@@ -8,6 +8,11 @@ export interface PiSpawnCommand {
|
|
|
8
8
|
args: string[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
const PI_PACKAGE_NAMES = [
|
|
12
|
+
"@earendil-works/pi-coding-agent",
|
|
13
|
+
"@mariozechner/pi-coding-agent",
|
|
14
|
+
];
|
|
15
|
+
|
|
11
16
|
function isRunnableNodeScript(filePath: string): boolean {
|
|
12
17
|
return fs.existsSync(filePath) && /\.(?:mjs|cjs|js)$/i.test(filePath);
|
|
13
18
|
}
|
|
@@ -26,6 +31,7 @@ function isWithinAllowedPrefixes(resolvedPath: string): boolean {
|
|
|
26
31
|
try {
|
|
27
32
|
const execDir = path.dirname(fs.realpathSync.native(process.execPath));
|
|
28
33
|
allowedPrefixes.push(execDir.toLowerCase());
|
|
34
|
+
allowedPrefixes.push(path.join(path.dirname(execDir), "lib", "node_modules").toLowerCase());
|
|
29
35
|
} catch { /* ignore */ }
|
|
30
36
|
|
|
31
37
|
// npm global bin via APPDATA
|
|
@@ -33,6 +39,12 @@ function isWithinAllowedPrefixes(resolvedPath: string): boolean {
|
|
|
33
39
|
allowedPrefixes.push(path.join(process.env.APPDATA, "npm").toLowerCase());
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
const npmPrefix = process.env.npm_config_prefix ?? process.env.NPM_CONFIG_PREFIX;
|
|
43
|
+
if (npmPrefix) {
|
|
44
|
+
allowedPrefixes.push(path.resolve(npmPrefix).toLowerCase());
|
|
45
|
+
allowedPrefixes.push(path.join(path.resolve(npmPrefix), "lib", "node_modules").toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
// Project-local node_modules/.bin
|
|
37
49
|
try {
|
|
38
50
|
const projectBin = path.resolve("node_modules", ".bin");
|
|
@@ -62,7 +74,7 @@ function resolvePiPackageRoot(): string | undefined {
|
|
|
62
74
|
while (dir !== path.dirname(dir)) {
|
|
63
75
|
try {
|
|
64
76
|
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")) as { name?: string };
|
|
65
|
-
if (pkg.name
|
|
77
|
+
if (pkg.name && PI_PACKAGE_NAMES.includes(pkg.name)) return dir;
|
|
66
78
|
} catch {
|
|
67
79
|
// Continue walking upward.
|
|
68
80
|
}
|
|
@@ -92,12 +104,15 @@ function findPiPackageJsonFrom(startDir: string): string | undefined {
|
|
|
92
104
|
const direct = path.join(dir, "package.json");
|
|
93
105
|
try {
|
|
94
106
|
const pkg = JSON.parse(fs.readFileSync(direct, "utf-8")) as { name?: string };
|
|
95
|
-
if (pkg.name
|
|
107
|
+
if (pkg.name && PI_PACKAGE_NAMES.includes(pkg.name)) return direct;
|
|
96
108
|
} catch {
|
|
97
109
|
// Continue searching upward and in node_modules.
|
|
98
110
|
}
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
for (const pkgName of PI_PACKAGE_NAMES) {
|
|
112
|
+
const [scope, name] = pkgName.replace("@", "").split("/");
|
|
113
|
+
const dependency = path.join(dir, "node_modules", `@${scope}`, name, "package.json");
|
|
114
|
+
if (fs.existsSync(dependency)) return dependency;
|
|
115
|
+
}
|
|
101
116
|
dir = path.dirname(dir);
|
|
102
117
|
}
|
|
103
118
|
return undefined;
|
|
@@ -112,6 +127,7 @@ function resolvePiCliScript(): string | undefined {
|
|
|
112
127
|
|
|
113
128
|
const roots = [
|
|
114
129
|
resolvePiPackageRoot(),
|
|
130
|
+
process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@earendil-works", "pi-coding-agent") : undefined,
|
|
115
131
|
process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@mariozechner", "pi-coding-agent") : undefined,
|
|
116
132
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
117
133
|
process.cwd(),
|
|
@@ -77,8 +77,21 @@ export function isLikelyOrphanedActiveRun(run: TeamRunManifest, agents: CrewAgen
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
function hasDurableActiveAgentEvidence(agent: CrewAgentRecord): boolean {
|
|
80
|
-
if (agent.status
|
|
81
|
-
|
|
80
|
+
if (agent.status === "running") {
|
|
81
|
+
// Running agents are actively executing — trust them.
|
|
82
|
+
// Activity evidence is only required for queued agents (zombie prevention).
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
if (agent.status === "queued") {
|
|
86
|
+
// Queued agents need actual activity evidence to distinguish from zombies:
|
|
87
|
+
// spawned-but-never-executed agents should not appear as active.
|
|
88
|
+
return Boolean(
|
|
89
|
+
(agent.progress && (agent.progress.toolCount > 0 || agent.progress.recentOutput.length > 0)) ||
|
|
90
|
+
(agent.jsonEvents && agent.jsonEvents > 0) ||
|
|
91
|
+
(agent.toolUses && agent.toolUses > 0),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
export function hasStaleAsyncProcess(run: TeamRunManifest, now = Date.now()): boolean {
|
|
@@ -38,7 +38,7 @@ export async function isLiveSessionRuntimeAvailable(timeoutMs = 1500, env: NodeJ
|
|
|
38
38
|
const probe = async (): Promise<{ available: boolean; reason?: string }> => {
|
|
39
39
|
try {
|
|
40
40
|
// LAZY: optional peer dependency — probe at runtime to avoid hard dependency.
|
|
41
|
-
const mod = await import("@
|
|
41
|
+
const mod = await import("@earendil-works/pi-coding-agent");
|
|
42
42
|
const api = mod as Record<string, unknown>;
|
|
43
43
|
const required = ["createAgentSession", "DefaultResourceLoader", "SessionManager", "SettingsManager"];
|
|
44
44
|
const missing = required.filter((name) => typeof api[name] === "undefined");
|
|
@@ -40,6 +40,7 @@ import { executeHook, appendHookEvent } from "../hooks/registry.ts";
|
|
|
40
40
|
import { createVerificationEvidence } from "./green-contract.ts";
|
|
41
41
|
import { createStartupEvidence } from "./worker-startup.ts";
|
|
42
42
|
import { permissionForRole } from "./role-permission.ts";
|
|
43
|
+
import { crewHooks } from "./crew-hooks.ts";
|
|
43
44
|
import {
|
|
44
45
|
collectDependencyOutputContext,
|
|
45
46
|
renderDependencyOutputContext,
|
|
@@ -401,6 +402,7 @@ export async function runTeamTask(
|
|
|
401
402
|
modelAttempts: [...modelAttempts, pendingAttempt],
|
|
402
403
|
};
|
|
403
404
|
tasks = updateTask(tasks, task);
|
|
405
|
+
crewHooks.emit({ type: "task_started", timestamp: new Date().toISOString(), runId: manifest.runId, taskId: task.id, data: { role: task.role, model: model ?? "default" } });
|
|
404
406
|
upsertCrewAgent(
|
|
405
407
|
manifest,
|
|
406
408
|
recordFromTask(manifest, task, "child-process"),
|
|
@@ -808,7 +810,22 @@ export async function runTeamTask(
|
|
|
808
810
|
exitCode = live.exitCode;
|
|
809
811
|
error = live.error;
|
|
810
812
|
parsedOutput = live.parsedOutput;
|
|
811
|
-
|
|
813
|
+
// Bug #21 fix: live-session may not produce structured output via submit_result,
|
|
814
|
+
// leaving finalText empty. Re-write resultArtifact with parsedOutput.finalText
|
|
815
|
+
// so downstream tasks that depend on this task can read meaningful output.
|
|
816
|
+
const liveText = cleanResultText(parsedOutput?.finalText);
|
|
817
|
+
if (liveText) {
|
|
818
|
+
// Re-write the artifact with the captured stdout — this is the content
|
|
819
|
+
// downstream tasks will read via task.resultArtifact.path.
|
|
820
|
+
resultArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
821
|
+
kind: "result",
|
|
822
|
+
relativePath: `results/${task.id}.txt`,
|
|
823
|
+
content: liveText,
|
|
824
|
+
producer: task.id,
|
|
825
|
+
});
|
|
826
|
+
} else {
|
|
827
|
+
resultArtifact = live.resultArtifact;
|
|
828
|
+
}
|
|
812
829
|
logArtifact = live.logArtifact;
|
|
813
830
|
transcriptArtifact = live.transcriptArtifact;
|
|
814
831
|
} else {
|
|
@@ -855,6 +872,8 @@ export async function runTeamTask(
|
|
|
855
872
|
data: {
|
|
856
873
|
activityState: "needs_attention",
|
|
857
874
|
reason: "no_yield",
|
|
875
|
+
// Bug #21 fix: include result path so downstream tasks can read the output
|
|
876
|
+
resultPath: resultArtifact?.path,
|
|
858
877
|
},
|
|
859
878
|
});
|
|
860
879
|
}
|
|
@@ -1004,6 +1023,17 @@ export async function runTeamTask(
|
|
|
1004
1023
|
...(transcriptArtifact ? { transcriptArtifact } : {}),
|
|
1005
1024
|
};
|
|
1006
1025
|
tasks = updateTask(tasks, task);
|
|
1026
|
+
|
|
1027
|
+
// Emit task completion hooks (100% reliable, fire-and-forget)
|
|
1028
|
+
const hookType = task.status === "completed" ? "task_completed" : task.status === "failed" ? "task_failed" : "task_started";
|
|
1029
|
+
crewHooks.emit({
|
|
1030
|
+
type: hookType,
|
|
1031
|
+
timestamp: task.finishedAt ?? new Date().toISOString(),
|
|
1032
|
+
runId: manifest.runId,
|
|
1033
|
+
taskId: task.id,
|
|
1034
|
+
data: { status: task.status, role: task.role, error: task.error, exitCode: task.exitCode, usage: task.usage },
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1007
1037
|
const packetArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
1008
1038
|
kind: "metadata",
|
|
1009
1039
|
relativePath: `metadata/${task.id}.task-packet.json`,
|
|
@@ -28,6 +28,7 @@ import { executeWithRetry, DEFAULT_RETRY_POLICY, type RetryPolicy } from "./retr
|
|
|
28
28
|
import { appendDeadletter } from "./deadletter.ts";
|
|
29
29
|
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
30
30
|
import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
|
|
31
|
+
import { crewHooks } from "./crew-hooks.ts";
|
|
31
32
|
import { resolveBatchConcurrency } from "./concurrency.ts";
|
|
32
33
|
import { mapConcurrent } from "./parallel-utils.ts";
|
|
33
34
|
import { permissionForRole } from "./role-permission.ts";
|
|
@@ -279,6 +280,10 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
279
280
|
cleanupUsage();
|
|
280
281
|
// Terminate live agents for this run — agents are done when the run ends.
|
|
281
282
|
void terminateLiveAgentsForRun(manifest.runId, "completed", appendEvent, manifest.eventsPath).catch(() => {});
|
|
283
|
+
|
|
284
|
+
// Emit run completion hook (100% reliable, fire-and-forget)
|
|
285
|
+
crewHooks.emit({ type: "run_completed", timestamp: new Date().toISOString(), runId: manifest.runId, data: { status: result.manifest.status, taskCount: result.tasks.length } });
|
|
286
|
+
|
|
282
287
|
return result;
|
|
283
288
|
} catch (error) {
|
|
284
289
|
// P1: Catch unhandled errors — ensure manifest/tasks/agents are terminal so they don't stay "running" forever.
|
|
@@ -310,6 +315,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
310
315
|
}
|
|
311
316
|
const result = { manifest, tasks };
|
|
312
317
|
rejectRunPromise(manifest.runId, error instanceof Error ? error : new Error(message));
|
|
318
|
+
crewHooks.emit({ type: "run_failed", timestamp: new Date().toISOString(), runId: manifest.runId, data: { status: manifest.status, error: message } });
|
|
313
319
|
cleanupUsage();
|
|
314
320
|
return result;
|
|
315
321
|
}
|