pi-crew 0.5.5 → 0.5.7
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 +153 -0
- package/README.md +17 -1
- package/docs/architecture.md +2 -0
- package/docs/migration-v0.4-v0.5.md +19 -2
- package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
- package/package.json +7 -5
- package/src/benchmark/benchmark-runner.ts +45 -0
- package/src/benchmark/feedback-loop.ts +5 -0
- package/src/config/config.ts +38 -4
- package/src/config/defaults.ts +5 -0
- package/src/config/suggestions.ts +8 -0
- package/src/extension/async-notifier.ts +10 -1
- package/src/extension/cross-extension-rpc.ts +1 -1
- package/src/extension/notification-router.ts +18 -0
- package/src/extension/register.ts +13 -17
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/team-tool/anchor.ts +201 -0
- package/src/extension/team-tool/api.ts +2 -1
- package/src/extension/team-tool/auto-summarize.ts +154 -0
- package/src/extension/team-tool/run.ts +37 -2
- package/src/extension/team-tool.ts +44 -2
- package/src/hooks/registry.ts +1 -3
- package/src/observability/event-bus.ts +13 -4
- package/src/observability/event-to-metric.ts +0 -2
- package/src/runtime/anchor-manager.ts +473 -0
- package/src/runtime/async-runner.ts +8 -4
- package/src/runtime/auto-summarize.ts +350 -0
- package/src/runtime/background-runner.ts +2 -1
- package/src/runtime/budget-tracker.ts +354 -0
- package/src/runtime/chain-runner.ts +507 -0
- package/src/runtime/child-pi.ts +24 -6
- package/src/runtime/crash-recovery.ts +5 -4
- package/src/runtime/crew-agent-records.ts +32 -1
- package/src/runtime/custom-tools/irc-tool.ts +13 -0
- package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
- package/src/runtime/delivery-coordinator.ts +10 -3
- package/src/runtime/dynamic-script-runner.ts +482 -0
- package/src/runtime/handoff-manager.ts +589 -0
- package/src/runtime/hidden-handoff.ts +424 -0
- package/src/runtime/live-agent-manager.ts +20 -4
- package/src/runtime/live-session-runtime.ts +39 -4
- package/src/runtime/manifest-cache.ts +2 -1
- package/src/runtime/model-resolver.ts +16 -4
- package/src/runtime/phase-tracker.ts +373 -0
- package/src/runtime/pipeline-runner.ts +514 -0
- package/src/runtime/retry-runner.ts +354 -0
- package/src/runtime/sandbox.ts +252 -0
- package/src/runtime/scheduler.ts +7 -2
- package/src/runtime/subagent-manager.ts +1 -1
- package/src/runtime/task-graph.ts +11 -1
- package/src/runtime/task-runner.ts +15 -1
- package/src/runtime/team-runner.ts +4 -3
- package/src/schema/team-tool-schema.ts +31 -0
- package/src/skills/discover-skills.ts +5 -0
- package/src/state/active-run-registry.ts +19 -3
- package/src/state/contracts.ts +9 -0
- package/src/state/crew-init.ts +3 -3
- package/src/state/decision-ledger.ts +26 -32
- package/src/state/event-log-rotation.ts +2 -2
- package/src/state/event-log.ts +17 -4
- package/src/state/mailbox.ts +35 -1
- package/src/state/run-cache.ts +18 -8
- package/src/tools/safe-bash-extension.ts +1 -0
- package/src/tools/safe-bash.ts +153 -20
- package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
- package/src/ui/powerbar-publisher.ts +1 -0
- package/src/ui/transcript-cache.ts +13 -0
- package/src/utils/bm25-search.ts +16 -8
- package/src/utils/env-filter.ts +8 -5
- package/src/utils/redaction.ts +169 -15
- package/src/utils/sse-parser.ts +10 -1
- package/src/worktree/cleanup.ts +6 -1
- package/workflows/chain.workflow.md +252 -0
- package/workflows/pipeline.workflow.md +27 -0
package/src/hooks/registry.ts
CHANGED
|
@@ -30,9 +30,7 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
|
|
|
30
30
|
// SECURITY: If ctx contains a workspaceId, filter hooks to only those scoped to
|
|
31
31
|
// this workspace. This prevents globally-registered hooks from operating on runs
|
|
32
32
|
// they weren't designed for.
|
|
33
|
-
const scopedHooks = ctx.workspaceId
|
|
34
|
-
? hooks.filter((h) => !h.workspaceId || h.workspaceId === ctx.workspaceId)
|
|
35
|
-
: hooks;
|
|
33
|
+
const scopedHooks = hooks.filter((h) => !h.workspaceId || h.workspaceId === ctx.workspaceId);
|
|
36
34
|
if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
|
|
37
35
|
const start = Date.now();
|
|
38
36
|
const diagnostics: string[] = [];
|
|
@@ -19,13 +19,22 @@ type CrewEventListener = (event: CrewEvent) => void;
|
|
|
19
19
|
|
|
20
20
|
class EventBus {
|
|
21
21
|
private listeners = new Map<CrewEventType, Set<CrewEventListener>>();
|
|
22
|
-
private static
|
|
22
|
+
private static _instance?: EventBus;
|
|
23
23
|
|
|
24
24
|
static getInstance(): EventBus {
|
|
25
|
-
if (!EventBus.
|
|
26
|
-
EventBus.
|
|
25
|
+
if (!EventBus._instance) {
|
|
26
|
+
EventBus._instance = new EventBus();
|
|
27
27
|
}
|
|
28
|
-
return EventBus.
|
|
28
|
+
return EventBus._instance;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Dispose of the EventBus instance and clear all listeners.
|
|
33
|
+
* Resets the singleton so a new instance can be created.
|
|
34
|
+
*/
|
|
35
|
+
dispose(): void {
|
|
36
|
+
this.listeners.clear();
|
|
37
|
+
EventBus._instance = undefined;
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
emit(event: CrewEvent): void {
|
|
@@ -32,7 +32,6 @@ export function wireEventToMetrics(events: ExtensionAPI["events"] | undefined, r
|
|
|
32
32
|
const retryAttemptCount = registry.counter("crew.task.retry_attempt_total", "Retry attempts by run and task");
|
|
33
33
|
const deadletterCount = registry.counter("crew.task.deadletter_total", "Deadletter triggers by reason");
|
|
34
34
|
const overflowCount = registry.counter("crew.task.overflow_phase_total", "Overflow recovery phase transitions");
|
|
35
|
-
const waitingCount = registry.counter("crew.task.waiting_total", "Tasks entering waiting state");
|
|
36
35
|
const supervisorContactCount = registry.counter("crew.task.supervisor_contact_total", "Supervisor contact requests by reason");
|
|
37
36
|
registry.gauge("crew.heartbeat.staleness_ms", "Heartbeat elapsed since last seen, milliseconds");
|
|
38
37
|
const runDuration = registry.histogram("crew.run.duration_ms", "Run end-to-end duration, milliseconds", [1000, 5000, 15000, 30000, 60000, 300000, 600000, 1800000]);
|
|
@@ -50,7 +49,6 @@ export function wireEventToMetrics(events: ExtensionAPI["events"] | undefined, r
|
|
|
50
49
|
["crew.task.retry_attempt", (data) => { const item = recordValue(data); taskCount.inc({ status: "retry" }); retryAttemptCount.inc({ runId: stringValue(item.runId, "unknown"), taskId: stringValue(item.taskId, "unknown") }); }],
|
|
51
50
|
["crew.task.deadletter", (data) => { const item = recordValue(data); deadletterCount.inc({ reason: stringValue(item.reason, "unknown") }); }],
|
|
52
51
|
["crew.task.overflow", (data) => { const item = recordValue(data); overflowCount.inc({ phase: stringValue(item.phase, "unknown"), previous_phase: stringValue(item.previousPhase, "none") }); }],
|
|
53
|
-
["task.waiting", (data) => { const item = recordValue(data); waitingCount.inc({ taskId: stringValue(item.taskId, "unknown"), runId: stringValue(item.runId, "unknown") }); }],
|
|
54
52
|
["supervisor.contact", (data) => { const item = recordValue(data); supervisorContactCount.inc({ reason: stringValue(item.reason, "unknown"), taskId: stringValue(item.taskId, "unknown") }); }],
|
|
55
53
|
["crew.subagent.completed", (data) => { const item = recordValue(data); subagentCount.inc({ status: stringValue(item.status, "completed") }); }],
|
|
56
54
|
["crew.subagent.failed", () => subagentCount.inc({ status: "failed" })],
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maximum number of anchors to prevent memory leaks.
|
|
3
|
+
*/
|
|
4
|
+
const MAX_ANCHORS = 50;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Maximum number of handoffs per anchor to prevent memory leaks.
|
|
8
|
+
*/
|
|
9
|
+
const MAX_HANDOFFS_PER_ANCHOR = 100;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* AnchorManager - Creates shared summary points where multiple handoffs accumulate.
|
|
13
|
+
*
|
|
14
|
+
* Based on pi-boomerang's anchorMode pattern:
|
|
15
|
+
* - setAnchor() creates a shared summary point for a session
|
|
16
|
+
* - accumulateHandoff() adds handoffs to the anchor
|
|
17
|
+
* - clearAnchor() finalizes and returns accumulated summaries
|
|
18
|
+
* - getAnchorHandoff() retrieves accumulated summary without clearing
|
|
19
|
+
*
|
|
20
|
+
* @see docs/pi-boomerang-integration-plan.md
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { HandoffSummary } from "./handoff-manager.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Represents a shared summary point where multiple handoffs accumulate.
|
|
27
|
+
*/
|
|
28
|
+
export interface Anchor {
|
|
29
|
+
/** Unique anchor identifier */
|
|
30
|
+
id: string;
|
|
31
|
+
/** Session ID this anchor belongs to */
|
|
32
|
+
sessionId: string;
|
|
33
|
+
/** Timestamp when anchor was created */
|
|
34
|
+
createdAt: number;
|
|
35
|
+
/** Accumulated handoffs */
|
|
36
|
+
handoffs: HandoffSummary[];
|
|
37
|
+
/** Initial context when anchor was set */
|
|
38
|
+
context: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Options for AnchorManager.
|
|
43
|
+
*/
|
|
44
|
+
export interface AnchorManagerOptions {
|
|
45
|
+
/** Custom event emitter for anchor events */
|
|
46
|
+
eventEmitter?: AnchorEventEmitter;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Event emitter interface for anchor lifecycle events.
|
|
51
|
+
*/
|
|
52
|
+
export interface AnchorEventEmitter {
|
|
53
|
+
emit(event: string, data: unknown): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Event data for anchor lifecycle events.
|
|
58
|
+
*/
|
|
59
|
+
export interface AnchorEventData {
|
|
60
|
+
anchor: Anchor;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AnchorClearedEventData {
|
|
64
|
+
anchorId: string;
|
|
65
|
+
accumulated: HandoffSummary;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AnchorHandoffAccumulatedEventData {
|
|
69
|
+
anchorId: string;
|
|
70
|
+
handoff: HandoffSummary;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* AnchorManager creates shared summary points where multiple handoffs accumulate.
|
|
75
|
+
* This enables scenarios where multiple agents contribute to a shared summary
|
|
76
|
+
* that is then passed to a parent or used for tree navigation.
|
|
77
|
+
*/
|
|
78
|
+
export class AnchorManager {
|
|
79
|
+
private anchors: Map<string, Anchor> = new Map();
|
|
80
|
+
private sessionAnchors: Map<string, string> = new Map();
|
|
81
|
+
private options: AnchorManagerOptions;
|
|
82
|
+
private readonly MAX_ANCHORS = 1000;
|
|
83
|
+
private readonly TTL_MS = 300000; // 5 minutes
|
|
84
|
+
|
|
85
|
+
constructor(options: AnchorManagerOptions = {}) {
|
|
86
|
+
this.options = options;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Set an anchor point for a session.
|
|
91
|
+
* All subsequent handoffs will accumulate to this anchor.
|
|
92
|
+
*
|
|
93
|
+
* @param sessionId - The session ID to create anchor for
|
|
94
|
+
* @param context - Initial context for the anchor
|
|
95
|
+
* @returns The anchor ID
|
|
96
|
+
*/
|
|
97
|
+
setAnchor(sessionId: string, context: Record<string, unknown> = {}): string {
|
|
98
|
+
const anchorId = this.generateAnchorId();
|
|
99
|
+
|
|
100
|
+
// Evict expired or overflow anchors before adding new one
|
|
101
|
+
this.evictExpiredAnchors();
|
|
102
|
+
if (this.anchors.size >= this.MAX_ANCHORS) {
|
|
103
|
+
this.evictOldestAnchor();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const anchor: Anchor = {
|
|
107
|
+
id: anchorId,
|
|
108
|
+
sessionId,
|
|
109
|
+
createdAt: Date.now(),
|
|
110
|
+
handoffs: [],
|
|
111
|
+
context,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
this.anchors.set(anchorId, anchor);
|
|
115
|
+
this.sessionAnchors.set(sessionId, anchorId);
|
|
116
|
+
|
|
117
|
+
this.options.eventEmitter?.emit("anchor:created", { anchor });
|
|
118
|
+
|
|
119
|
+
return anchorId;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get the current anchor for a session.
|
|
124
|
+
*
|
|
125
|
+
* @param sessionId - The session ID
|
|
126
|
+
* @returns The anchor if exists, null otherwise
|
|
127
|
+
*/
|
|
128
|
+
getAnchor(sessionId: string): Anchor | null {
|
|
129
|
+
this.evictExpiredAnchors();
|
|
130
|
+
const anchorId = this.sessionAnchors.get(sessionId);
|
|
131
|
+
if (!anchorId) return null;
|
|
132
|
+
return this.anchors.get(anchorId) ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the anchor ID for a session.
|
|
137
|
+
*
|
|
138
|
+
* @param sessionId - The session ID
|
|
139
|
+
* @returns The anchor ID if exists, undefined otherwise
|
|
140
|
+
*/
|
|
141
|
+
getAnchorId(sessionId: string): string | undefined {
|
|
142
|
+
return this.sessionAnchors.get(sessionId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Clear an anchor and return the accumulated handoff summary.
|
|
147
|
+
* This removes the anchor and returns merged handoffs.
|
|
148
|
+
*
|
|
149
|
+
* @param anchorId - The anchor ID to clear
|
|
150
|
+
* @returns The accumulated handoff summary
|
|
151
|
+
* @throws Error if anchor not found or no handoffs accumulated
|
|
152
|
+
*/
|
|
153
|
+
clearAnchor(anchorId: string): HandoffSummary {
|
|
154
|
+
const anchor = this.anchors.get(anchorId);
|
|
155
|
+
|
|
156
|
+
if (!anchor) {
|
|
157
|
+
throw new AnchorNotFoundError(anchorId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const accumulated = this.accumulateHandoffs(anchor.handoffs);
|
|
161
|
+
|
|
162
|
+
// Clean up maps
|
|
163
|
+
this.sessionAnchors.delete(anchor.sessionId);
|
|
164
|
+
this.anchors.delete(anchorId);
|
|
165
|
+
|
|
166
|
+
this.options.eventEmitter?.emit("anchor:cleared", { anchorId, accumulated });
|
|
167
|
+
|
|
168
|
+
return accumulated;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Clear anchor by session ID.
|
|
173
|
+
*
|
|
174
|
+
* @param sessionId - The session ID
|
|
175
|
+
* @returns The accumulated handoff summary
|
|
176
|
+
*/
|
|
177
|
+
clearAnchorBySession(sessionId: string): HandoffSummary | null {
|
|
178
|
+
const anchorId = this.sessionAnchors.get(sessionId);
|
|
179
|
+
if (!anchorId) return null;
|
|
180
|
+
return this.clearAnchor(anchorId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Accumulate a handoff to an anchor.
|
|
185
|
+
* If anchor doesn't exist, creates an implicit anchor.
|
|
186
|
+
*
|
|
187
|
+
* @param anchorId - The anchor ID
|
|
188
|
+
* @param handoff - The handoff summary to accumulate
|
|
189
|
+
*/
|
|
190
|
+
accumulateHandoff(anchorId: string, handoff: HandoffSummary): void {
|
|
191
|
+
let anchor = this.anchors.get(anchorId);
|
|
192
|
+
|
|
193
|
+
// Create implicit anchor if doesn't exist - create directly with the given anchorId
|
|
194
|
+
if (!anchor) {
|
|
195
|
+
// Evict oldest anchor if at capacity
|
|
196
|
+
if (this.anchors.size >= MAX_ANCHORS) {
|
|
197
|
+
const oldest = this.anchors.keys().next().value;
|
|
198
|
+
if (oldest) {
|
|
199
|
+
this.anchors.delete(oldest);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const implicitAnchor: Anchor = {
|
|
203
|
+
id: anchorId,
|
|
204
|
+
sessionId: handoff.runId,
|
|
205
|
+
createdAt: Date.now(),
|
|
206
|
+
handoffs: [],
|
|
207
|
+
context: {},
|
|
208
|
+
};
|
|
209
|
+
this.anchors.set(anchorId, implicitAnchor);
|
|
210
|
+
anchor = implicitAnchor;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Enforce handoff limit per anchor to prevent unbounded growth
|
|
214
|
+
if (anchor!.handoffs.length >= MAX_HANDOFFS_PER_ANCHOR) {
|
|
215
|
+
anchor!.handoffs.shift();
|
|
216
|
+
}
|
|
217
|
+
anchor!.handoffs.push(handoff);
|
|
218
|
+
|
|
219
|
+
this.options.eventEmitter?.emit("anchor:handoffAccumulated", {
|
|
220
|
+
anchorId: anchor!.id,
|
|
221
|
+
handoff,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Accumulate handoff by session ID.
|
|
227
|
+
*
|
|
228
|
+
* @param sessionId - The session ID
|
|
229
|
+
* @param handoff - The handoff summary to accumulate
|
|
230
|
+
*/
|
|
231
|
+
accumulateHandoffBySession(sessionId: string, handoff: HandoffSummary): void {
|
|
232
|
+
const anchorId = this.sessionAnchors.get(sessionId);
|
|
233
|
+
if (!anchorId) {
|
|
234
|
+
// Create new anchor for this session
|
|
235
|
+
const newAnchorId = this.setAnchor(sessionId);
|
|
236
|
+
this.accumulateHandoff(newAnchorId, handoff);
|
|
237
|
+
} else {
|
|
238
|
+
this.accumulateHandoff(anchorId, handoff);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get the accumulated handoff for an anchor without clearing it.
|
|
244
|
+
*
|
|
245
|
+
* @param anchorId - The anchor ID
|
|
246
|
+
* @returns The accumulated handoff summary, or null if anchor not found or no handoffs
|
|
247
|
+
*/
|
|
248
|
+
getAnchorHandoff(anchorId: string): HandoffSummary | null {
|
|
249
|
+
const anchor = this.anchors.get(anchorId);
|
|
250
|
+
if (!anchor) return null;
|
|
251
|
+
if (anchor.handoffs.length === 0) return null;
|
|
252
|
+
return this.accumulateHandoffs(anchor.handoffs);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get accumulated handoff by session ID.
|
|
257
|
+
*
|
|
258
|
+
* @param sessionId - The session ID
|
|
259
|
+
* @returns The accumulated handoff summary, or null if no anchor or handoffs
|
|
260
|
+
*/
|
|
261
|
+
getAnchorHandoffBySession(sessionId: string): HandoffSummary | null {
|
|
262
|
+
const anchorId = this.sessionAnchors.get(sessionId);
|
|
263
|
+
if (!anchorId) return null;
|
|
264
|
+
return this.getAnchorHandoff(anchorId);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get status information for an anchor.
|
|
269
|
+
*
|
|
270
|
+
* @param anchorId - The anchor ID
|
|
271
|
+
* @returns Status object or null if anchor not found
|
|
272
|
+
*/
|
|
273
|
+
getAnchorStatus(anchorId: string): AnchorStatus | null {
|
|
274
|
+
const anchor = this.anchors.get(anchorId);
|
|
275
|
+
if (!anchor) return null;
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
anchorId: anchor.id,
|
|
279
|
+
sessionId: anchor.sessionId,
|
|
280
|
+
createdAt: anchor.createdAt,
|
|
281
|
+
handoffCount: anchor.handoffs.length,
|
|
282
|
+
totalTokens: anchor.handoffs.reduce(
|
|
283
|
+
(sum, h) => sum + h.metrics.tokensUsed,
|
|
284
|
+
0,
|
|
285
|
+
),
|
|
286
|
+
totalDuration: anchor.handoffs.reduce(
|
|
287
|
+
(sum, h) => sum + h.metrics.duration,
|
|
288
|
+
0,
|
|
289
|
+
),
|
|
290
|
+
context: anchor.context,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get status by session ID.
|
|
296
|
+
*
|
|
297
|
+
* @param sessionId - The session ID
|
|
298
|
+
* @returns Status object or null if no anchor
|
|
299
|
+
*/
|
|
300
|
+
getAnchorStatusBySession(sessionId: string): AnchorStatus | null {
|
|
301
|
+
const anchorId = this.sessionAnchors.get(sessionId);
|
|
302
|
+
if (!anchorId) return null;
|
|
303
|
+
return this.getAnchorStatus(anchorId);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Check if an anchor has handoffs accumulated.
|
|
308
|
+
*
|
|
309
|
+
* @param anchorId - The anchor ID
|
|
310
|
+
* @returns True if anchor has handoffs
|
|
311
|
+
*/
|
|
312
|
+
hasHandoffs(anchorId: string): boolean {
|
|
313
|
+
const anchor = this.anchors.get(anchorId);
|
|
314
|
+
return anchor ? anchor.handoffs.length > 0 : false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get all anchors.
|
|
319
|
+
*
|
|
320
|
+
* @returns Array of all anchors
|
|
321
|
+
*/
|
|
322
|
+
getAllAnchors(): Anchor[] {
|
|
323
|
+
return Array.from(this.anchors.values());
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Clear all anchors.
|
|
328
|
+
*/
|
|
329
|
+
clearAll(): void {
|
|
330
|
+
this.anchors.clear();
|
|
331
|
+
this.sessionAnchors.clear();
|
|
332
|
+
this.options.eventEmitter?.emit("anchor:cleared_all", {});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Merge multiple handoffs into a single accumulated summary.
|
|
337
|
+
*/
|
|
338
|
+
private accumulateHandoffs(handoffs: HandoffSummary[]): HandoffSummary {
|
|
339
|
+
if (handoffs.length === 0) {
|
|
340
|
+
throw new NoHandoffsError();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const allMetrics = handoffs.reduce(
|
|
344
|
+
(acc, h) => ({
|
|
345
|
+
tokensUsed: acc.tokensUsed + h.metrics.tokensUsed,
|
|
346
|
+
duration: acc.duration + h.metrics.duration,
|
|
347
|
+
iterations: acc.iterations + h.metrics.iterations,
|
|
348
|
+
toolsUsed: [...acc.toolsUsed, ...h.metrics.toolsUsed],
|
|
349
|
+
}),
|
|
350
|
+
{ tokensUsed: 0, duration: 0, iterations: 0, toolsUsed: [] as string[] },
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Deduplicate tools
|
|
354
|
+
const uniqueTools = [...new Set(allMetrics.toolsUsed)];
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
taskId: `anchor-${handoffs[0].taskId}`,
|
|
358
|
+
runId: handoffs[0].runId,
|
|
359
|
+
timestamp: Date.now(),
|
|
360
|
+
|
|
361
|
+
task: `Accumulated: ${handoffs.map((h) => h.task).join(" → ")}`,
|
|
362
|
+
outcome: handoffs.every((h) => h.outcome === "success")
|
|
363
|
+
? "success"
|
|
364
|
+
: handoffs.some((h) => h.outcome === "failure")
|
|
365
|
+
? "failure"
|
|
366
|
+
: "partial",
|
|
367
|
+
|
|
368
|
+
filesCreated: [...new Set(handoffs.flatMap((h) => h.filesCreated))],
|
|
369
|
+
filesModified: [...new Set(handoffs.flatMap((h) => h.filesModified))],
|
|
370
|
+
filesDeleted: [...new Set(handoffs.flatMap((h) => h.filesDeleted))],
|
|
371
|
+
|
|
372
|
+
decisions: handoffs.flatMap((h) => h.decisions),
|
|
373
|
+
blockers: [...new Set(handoffs.flatMap((h) => h.blockers))],
|
|
374
|
+
nextSteps: handoffs.flatMap((h) => h.nextSteps),
|
|
375
|
+
|
|
376
|
+
metrics: {
|
|
377
|
+
tokensUsed: allMetrics.tokensUsed,
|
|
378
|
+
duration: allMetrics.duration,
|
|
379
|
+
iterations: allMetrics.iterations,
|
|
380
|
+
toolsUsed: uniqueTools,
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
contextSnapshot: handoffs.map((h) => h.contextSnapshot).join("\n---\n"),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Generate a unique anchor ID.
|
|
389
|
+
*/
|
|
390
|
+
private generateAnchorId(): string {
|
|
391
|
+
return `anchor-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Evict expired anchors based on TTL.
|
|
396
|
+
*/
|
|
397
|
+
private evictExpiredAnchors(): void {
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
for (const [anchorId, anchor] of this.anchors) {
|
|
400
|
+
if (now - anchor.createdAt > this.TTL_MS) {
|
|
401
|
+
this.sessionAnchors.delete(anchor.sessionId);
|
|
402
|
+
this.anchors.delete(anchorId);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Evict the oldest anchor (LRU eviction when at max capacity).
|
|
409
|
+
*/
|
|
410
|
+
private evictOldestAnchor(): void {
|
|
411
|
+
let oldestAnchorId: string | null = null;
|
|
412
|
+
let oldestTime = Infinity;
|
|
413
|
+
for (const [anchorId, anchor] of this.anchors) {
|
|
414
|
+
if (anchor.createdAt < oldestTime) {
|
|
415
|
+
oldestTime = anchor.createdAt;
|
|
416
|
+
oldestAnchorId = anchorId;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (oldestAnchorId) {
|
|
420
|
+
const anchor = this.anchors.get(oldestAnchorId);
|
|
421
|
+
if (anchor) {
|
|
422
|
+
this.sessionAnchors.delete(anchor.sessionId);
|
|
423
|
+
}
|
|
424
|
+
this.anchors.delete(oldestAnchorId);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Status information for an anchor.
|
|
431
|
+
*/
|
|
432
|
+
export interface AnchorStatus {
|
|
433
|
+
anchorId: string;
|
|
434
|
+
sessionId: string;
|
|
435
|
+
createdAt: number;
|
|
436
|
+
handoffCount: number;
|
|
437
|
+
totalTokens: number;
|
|
438
|
+
totalDuration: number;
|
|
439
|
+
context: Record<string, unknown>;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Error thrown when an anchor is not found.
|
|
444
|
+
*/
|
|
445
|
+
export class AnchorNotFoundError extends Error {
|
|
446
|
+
public readonly anchorId: string;
|
|
447
|
+
|
|
448
|
+
constructor(anchorId: string) {
|
|
449
|
+
super(`Anchor not found: ${anchorId}`);
|
|
450
|
+
this.name = "AnchorNotFoundError";
|
|
451
|
+
this.anchorId = anchorId;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Error thrown when there are no handoffs to accumulate.
|
|
457
|
+
*/
|
|
458
|
+
export class NoHandoffsError extends Error {
|
|
459
|
+
constructor() {
|
|
460
|
+
super("No handoffs to accumulate");
|
|
461
|
+
this.name = "NoHandoffsError";
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Create an AnchorManager with default options.
|
|
467
|
+
*/
|
|
468
|
+
export function createAnchorManager(options?: AnchorManagerOptions): AnchorManager {
|
|
469
|
+
return new AnchorManager(options);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Re-export HandoffSummary for consumers
|
|
473
|
+
export type { HandoffSummary } from "./handoff-manager.ts";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, type SpawnOptions } from "node:child_process";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
@@ -150,14 +150,18 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
150
150
|
//
|
|
151
151
|
// IMPORTANT: session_shutdown handlers must NOT kill async runners.
|
|
152
152
|
// See register.ts cleanupRuntime — the kill loop was commented out.
|
|
153
|
-
|
|
153
|
+
// Type assertion for setsid is necessary because Node.js types don't include it
|
|
154
|
+
// in SpawnOptions on all platforms, but it's supported on Unix systems.
|
|
155
|
+
// Use explicit cast through unknown to satisfy TypeScript's strict type checking.
|
|
156
|
+
const spawnOpts = {
|
|
154
157
|
cwd: manifest.cwd,
|
|
155
158
|
detached: true,
|
|
156
|
-
setsid: true
|
|
159
|
+
setsid: true,
|
|
157
160
|
stdio: ["ignore", "pipe", "pipe"],
|
|
158
161
|
env: envWithoutParentPid,
|
|
159
162
|
windowsHide: true,
|
|
160
|
-
} as
|
|
163
|
+
} as unknown as Parameters<typeof spawn>[2];
|
|
164
|
+
const child = spawn(process.execPath, command.args, spawnOpts);
|
|
161
165
|
child.on("error", (error: Error) => {
|
|
162
166
|
console.error(`[pi-crew] async spawn failed: ${error.message}`);
|
|
163
167
|
});
|