@fradser/pi-agent-teams 0.9.2 → 1.0.0
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/.memory/MEMORY.md +1 -0
- package/.memory/attempt-bound-authority.md +19 -0
- package/README.md +137 -58
- package/package.json +13 -5
- package/references/agent-roles.md +23 -13
- package/src/activity.ts +20 -11
- package/src/agent-actions.ts +61 -36
- package/src/agent-host.ts +64 -0
- package/src/capability-tools.ts +34 -0
- package/src/guidance.ts +49 -37
- package/src/index.ts +149 -30
- package/src/recipient.ts +4 -4
- package/src/state.ts +163 -607
- package/src/statefile.ts +28 -244
- package/src/team-machine.ts +827 -535
- package/src/tool-copy.ts +26 -34
- package/src/tools.ts +57 -210
- package/src/types.ts +56 -157
- package/src/ui.ts +85 -59
- package/src/worker.ts +152 -79
- package/src/agents.ts +0 -296
- package/src/leader-reports.ts +0 -92
- package/src/spawner.ts +0 -751
- package/src/work-context.ts +0 -88
- package/src/worker-tools.ts +0 -4
- package/src/worktree.ts +0 -164
package/src/spawner.ts
DELETED
|
@@ -1,751 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Teammate spawner — spawns named resident child Pi processes in RPC mode.
|
|
3
|
-
*
|
|
4
|
-
* A teammate is a long-lived worker: it receives prompts on its control
|
|
5
|
-
* stream (stdin), streams JSON events on stdout, and suspends between wake
|
|
6
|
-
* ups without consuming tokens. The harness delivers new prompts via
|
|
7
|
-
* deliverPrompt (idle wake-up) and steering lines via sendWorkerSteer
|
|
8
|
-
* (mid-turn delivery). Wake-up sequences are uncapped: no turn-count or
|
|
9
|
-
* wall-clock limit terminates a working teammate; anomalies surface as
|
|
10
|
-
* leader notifications instead.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { randomUUID } from "node:crypto";
|
|
14
|
-
import { WORKER_BUILTIN_TOOLS } from "./worker-tools.ts";
|
|
15
|
-
export { WORKER_BUILTIN_TOOLS } from "./worker-tools.ts";
|
|
16
|
-
import * as fs from "node:fs";
|
|
17
|
-
import { tmpdir } from "node:os";
|
|
18
|
-
import * as path from "node:path";
|
|
19
|
-
import { fileURLToPath } from "node:url";
|
|
20
|
-
import {
|
|
21
|
-
DEFAULT_TERMINATION_GRACE_MS,
|
|
22
|
-
extractTextContent,
|
|
23
|
-
resolvePiCli,
|
|
24
|
-
spawnPiChild,
|
|
25
|
-
terminateChildProcess,
|
|
26
|
-
} from "@fradser/pi-kit";
|
|
27
|
-
import type { ChildProcess } from "node:child_process";
|
|
28
|
-
import type { WorkerUsage } from "./types.ts";
|
|
29
|
-
import { writeWorkContext } from "./work-context.ts";
|
|
30
|
-
import type { SessionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
|
|
32
|
-
const OUTPUT_CAP = 16_000;
|
|
33
|
-
/** A single JSONL record and an unterminated record may not exceed this many bytes. */
|
|
34
|
-
export const MAX_JSONL_LINE_BYTES = 8 * 1024 * 1024;
|
|
35
|
-
/** Live text, thinking, or tool arguments are bounded per assistant message. */
|
|
36
|
-
export const MAX_TURN_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
37
|
-
/** Diagnostic tail only; this is not a resident-process lifetime quota. */
|
|
38
|
-
const DIAGNOSTIC_TAIL_BYTES = 64 * 1024;
|
|
39
|
-
|
|
40
|
-
export interface WorkerProcessResult {
|
|
41
|
-
pid: number;
|
|
42
|
-
exitCode: number | null;
|
|
43
|
-
signal: NodeJS.Signals | null;
|
|
44
|
-
stdout: string;
|
|
45
|
-
stderr: string;
|
|
46
|
-
usage?: WorkerUsage;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Live state extracted from a teammate's RPC output stream. */
|
|
50
|
-
export interface WorkerProgressUpdate {
|
|
51
|
-
text: string;
|
|
52
|
-
activeTool?: string;
|
|
53
|
-
liveThinking?: string;
|
|
54
|
-
/** Assistant turns observed in the current wake-up sequence. */
|
|
55
|
-
turns: number;
|
|
56
|
-
/** True after the current sequence's final response. */
|
|
57
|
-
finalResponse?: boolean;
|
|
58
|
-
/** True once the stream delivered recognized model activity (text, thinking,
|
|
59
|
-
* tool-call, or tool execution events). Independent of usage totals so
|
|
60
|
-
* providers that omit usage are not misclassified as silent. */
|
|
61
|
-
modelOutputSeen?: boolean;
|
|
62
|
-
/** A prompt-less child acknowledged native startup readiness. */
|
|
63
|
-
ready?: boolean;
|
|
64
|
-
/** Lifetime accumulated usage parsed from message_end events. */
|
|
65
|
-
usage?: WorkerUsage;
|
|
66
|
-
controlError?: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** A crashed teammate is one that closed without a normal zero exit. */
|
|
70
|
-
export function isCleanExit(result: Pick<WorkerProcessResult, "exitCode" | "signal">): boolean {
|
|
71
|
-
return result.exitCode === 0 && result.signal === null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Tools every teammate receives regardless of its role definition. */
|
|
75
|
-
export const WORKER_CAPABILITY_TOOLS: readonly string[] = [
|
|
76
|
-
"agent_event",
|
|
77
|
-
"work",
|
|
78
|
-
];
|
|
79
|
-
|
|
80
|
-
/** Effective tool allowlist for one teammate: the role's requested tools plus
|
|
81
|
-
* the capability set, deduplicated in request order. Roles without a tools
|
|
82
|
-
* field get exactly the capability set — leaders should see that narrow
|
|
83
|
-
* grant before the first wake so missing read/bash is obvious at spawn time. */
|
|
84
|
-
export function resolveWorkerTools(requested?: string[]): string[] {
|
|
85
|
-
const requestedOnly = (requested ?? []).filter((tool) => !WORKER_CAPABILITY_TOOLS.includes(tool));
|
|
86
|
-
return [...new Set([...requestedOnly, ...WORKER_CAPABILITY_TOOLS])];
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Pi built-in tool ids a bare teammate process registers. The child runs
|
|
90
|
-
* --no-extensions, so this list (plus the capability set) is the complete
|
|
91
|
-
* grantable universe; anything else is silently dropped by the child's
|
|
92
|
-
* --tools allowlist filter. */
|
|
93
|
-
// Canonical IDs are shared with public schema and guidance in worker-tools.ts.
|
|
94
|
-
|
|
95
|
-
/** Every tool id a teammate can actually receive: pi built-ins plus the capability set. */
|
|
96
|
-
export const WORKER_TOOL_UNIVERSE: readonly string[] = [...WORKER_BUILTIN_TOOLS, ...WORKER_CAPABILITY_TOOLS];
|
|
97
|
-
|
|
98
|
-
/** Requested tool ids outside the teammate universe — exactly the ones the
|
|
99
|
-
* child's --tools allowlist would silently drop. */
|
|
100
|
-
export function unknownWorkerTools(requested?: string[]): string[] {
|
|
101
|
-
return [...new Set((requested ?? []).filter((tool) => !WORKER_TOOL_UNIVERSE.includes(tool)))];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ── Process registry ──────────────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
/** Live children by teammate name — powers steering, prompting, and shutdown. */
|
|
107
|
-
const workers = new Map<string, ChildProcess>();
|
|
108
|
-
const closedWorkers = new WeakSet<ChildProcess>();
|
|
109
|
-
|
|
110
|
-
function observeWorkerClose(name: string, child: ChildProcess): void {
|
|
111
|
-
child.once("close", () => {
|
|
112
|
-
closedWorkers.add(child);
|
|
113
|
-
if (workers.get(name) === child) workers.delete(name);
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** True only after Node has emitted the child process close event. */
|
|
118
|
-
export function isWorkerCloseObserved(name: string): boolean {
|
|
119
|
-
const child = workers.get(name);
|
|
120
|
-
if (child) return closedWorkers.has(child);
|
|
121
|
-
// Not registered anymore: either never spawned or already unregistered by close.
|
|
122
|
-
return true;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export { terminateChildProcess };
|
|
126
|
-
|
|
127
|
-
/** Terminate a living teammate and wait until its child process has closed. */
|
|
128
|
-
export type TeammateTermination = { outcome: "missing" } | { outcome: "closed" } | { outcome: "unconfirmed" };
|
|
129
|
-
|
|
130
|
-
export async function terminateTeammate(name: string, graceMs = DEFAULT_TERMINATION_GRACE_MS): Promise<TeammateTermination> {
|
|
131
|
-
const child = workers.get(name);
|
|
132
|
-
if (!child) return { outcome: "missing" };
|
|
133
|
-
const closed = closedWorkers.has(child) || await terminateChildProcess(child, graceMs);
|
|
134
|
-
return { outcome: closed ? "closed" : "unconfirmed" };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export async function terminateAllTeammates(graceMs = DEFAULT_TERMINATION_GRACE_MS): Promise<Array<{ name: string; confirmedClosed: boolean }>> {
|
|
138
|
-
const entries = [...workers.entries()];
|
|
139
|
-
return Promise.all(entries.map(async ([name, child]) => ({
|
|
140
|
-
name,
|
|
141
|
-
confirmedClosed: closedWorkers.has(child) || await terminateChildProcess(child, graceMs),
|
|
142
|
-
})));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ── Control stream ────────────────────────────────────────────────
|
|
146
|
-
|
|
147
|
-
interface PendingControlResponse {
|
|
148
|
-
child: ChildProcess;
|
|
149
|
-
command: string;
|
|
150
|
-
timeout: ReturnType<typeof setTimeout>;
|
|
151
|
-
resolve: (success: boolean) => void;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
type PendingFreshDelivery = {
|
|
155
|
-
message: string;
|
|
156
|
-
streamingBehavior: "steer" | "followUp";
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
interface PendingFreshAssignment {
|
|
160
|
-
child: ChildProcess;
|
|
161
|
-
message: string;
|
|
162
|
-
resetId?: string;
|
|
163
|
-
queued: PendingFreshDelivery[];
|
|
164
|
-
resolve: (success: boolean) => void;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const CONTROL_RESPONSE_TIMEOUT_MS = 5_000;
|
|
168
|
-
const pendingControlResponses = new Map<string, PendingControlResponse>();
|
|
169
|
-
const pendingFreshAssignments = new Map<string, PendingFreshAssignment>();
|
|
170
|
-
|
|
171
|
-
function writeToControlStream(child: ChildProcess, line: unknown): boolean {
|
|
172
|
-
if (!child.stdin || child.stdin.destroyed || !child.stdin.writable) return false;
|
|
173
|
-
child.stdin.write(`${JSON.stringify(line)}\n`);
|
|
174
|
-
return true;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Deliver a new wake-up prompt to an idle teammate's control stream.
|
|
179
|
-
* This starts a fresh assistant sequence in the child process.
|
|
180
|
-
*/
|
|
181
|
-
export function deliverPrompt(name: string, message: string): boolean {
|
|
182
|
-
const child = workers.get(name);
|
|
183
|
-
if (!child) return false;
|
|
184
|
-
const sent = writeToControlStream(child, { type: "prompt", id: randomUUID(), message, streamingBehavior: "followUp" });
|
|
185
|
-
if (sent) beginSequence(name);
|
|
186
|
-
return sent;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Per-name stream states so prompt delivery can reset sequence boundaries. */
|
|
190
|
-
const streamStates = new Map<string, StreamState>();
|
|
191
|
-
|
|
192
|
-
function beginSequence(name: string): void {
|
|
193
|
-
const state = streamStates.get(name);
|
|
194
|
-
if (state) {
|
|
195
|
-
state.finalResponse = false;
|
|
196
|
-
state.text = "";
|
|
197
|
-
state.thinking = "";
|
|
198
|
-
clearActiveTools(state);
|
|
199
|
-
state.turnBytes = 0;
|
|
200
|
-
state.outputLimitError = undefined;
|
|
201
|
-
}
|
|
202
|
-
baselines.set(name, streamTurns.get(name) ?? 0);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function sendOrQueueDuringFreshReset(
|
|
206
|
-
name: string,
|
|
207
|
-
message: string,
|
|
208
|
-
streamingBehavior: PendingFreshDelivery["streamingBehavior"],
|
|
209
|
-
): boolean {
|
|
210
|
-
const child = workers.get(name);
|
|
211
|
-
if (!child) return false;
|
|
212
|
-
const pending = pendingFreshAssignments.get(name);
|
|
213
|
-
if (pending?.child === child) {
|
|
214
|
-
pending.queued.push({ message, streamingBehavior });
|
|
215
|
-
return true;
|
|
216
|
-
}
|
|
217
|
-
return writeToControlStream(child, { type: "prompt", id: randomUUID(), message, streamingBehavior });
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Send a mid-turn steer to a working teammate; no peer mailbox is involved. */
|
|
221
|
-
export function sendWorkerSteer(name: string, message: string): boolean {
|
|
222
|
-
return sendOrQueueDuringFreshReset(name, message, "steer");
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Peer traffic yields to leader steering without depending on roster freshness. */
|
|
226
|
-
export function sendWorkerFollowUp(name: string, message: string): boolean {
|
|
227
|
-
return sendOrQueueDuringFreshReset(name, message, "followUp");
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function settleFreshAssignment(name: string, success: boolean): void {
|
|
231
|
-
const pending = pendingFreshAssignments.get(name);
|
|
232
|
-
if (!pending) return;
|
|
233
|
-
pendingFreshAssignments.delete(name);
|
|
234
|
-
if (pending.resetId) {
|
|
235
|
-
const control = pendingControlResponses.get(pending.resetId);
|
|
236
|
-
if (control) clearTimeout(control.timeout);
|
|
237
|
-
pendingControlResponses.delete(pending.resetId);
|
|
238
|
-
}
|
|
239
|
-
pending.resolve(success);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function deliverFreshPrompts(name: string, pending: PendingFreshAssignment): boolean {
|
|
243
|
-
if (!writeToControlStream(pending.child, {
|
|
244
|
-
type: "prompt",
|
|
245
|
-
id: randomUUID(),
|
|
246
|
-
message: pending.message,
|
|
247
|
-
streamingBehavior: "followUp",
|
|
248
|
-
})) return false;
|
|
249
|
-
beginSequence(name);
|
|
250
|
-
for (const delivery of pending.queued) {
|
|
251
|
-
if (!writeToControlStream(pending.child, {
|
|
252
|
-
type: "prompt",
|
|
253
|
-
id: randomUUID(),
|
|
254
|
-
message: delivery.message,
|
|
255
|
-
streamingBehavior: delivery.streamingBehavior,
|
|
256
|
-
})) break;
|
|
257
|
-
}
|
|
258
|
-
return true;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function startFreshAssignmentReset(name: string): void {
|
|
262
|
-
const pending = pendingFreshAssignments.get(name);
|
|
263
|
-
const state = streamStates.get(name);
|
|
264
|
-
if (!pending || pending.resetId || state?.finalResponse !== true || workers.get(name) !== pending.child) return;
|
|
265
|
-
const id = randomUUID();
|
|
266
|
-
pending.resetId = id;
|
|
267
|
-
const timeout = setTimeout(() => settleFreshAssignment(name, false), CONTROL_RESPONSE_TIMEOUT_MS);
|
|
268
|
-
timeout.unref?.();
|
|
269
|
-
pendingControlResponses.set(id, {
|
|
270
|
-
child: pending.child,
|
|
271
|
-
command: "new_session",
|
|
272
|
-
timeout,
|
|
273
|
-
resolve: (reset) => {
|
|
274
|
-
const current = pendingFreshAssignments.get(name);
|
|
275
|
-
if (current !== pending || !reset || workers.get(name) !== pending.child) {
|
|
276
|
-
settleFreshAssignment(name, false);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
settleFreshAssignment(name, deliverFreshPrompts(name, pending));
|
|
280
|
-
},
|
|
281
|
-
});
|
|
282
|
-
if (!writeToControlStream(pending.child, { type: "new_session", id })) {
|
|
283
|
-
settleFreshAssignment(name, false);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** True while a new Assignment Attempt is waiting for reset or prompt delivery. */
|
|
288
|
-
export function isFreshAssignmentPending(name: string): boolean {
|
|
289
|
-
return pendingFreshAssignments.has(name);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Queue a fresh Pi session and deliver a new Assignment Attempt after settlement. */
|
|
293
|
-
export function deliverFreshAssignment(name: string, message: string): Promise<boolean> {
|
|
294
|
-
const child = workers.get(name);
|
|
295
|
-
if (!child || pendingFreshAssignments.has(name)) return Promise.resolve(false);
|
|
296
|
-
return new Promise((resolve) => {
|
|
297
|
-
pendingFreshAssignments.set(name, { child, message, queued: [], resolve });
|
|
298
|
-
startFreshAssignmentReset(name);
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// ── Stream parsing ────────────────────────────────────────────────
|
|
303
|
-
|
|
304
|
-
type JsonEvent = {
|
|
305
|
-
id?: string;
|
|
306
|
-
type?: string;
|
|
307
|
-
command?: string;
|
|
308
|
-
success?: boolean;
|
|
309
|
-
error?: string;
|
|
310
|
-
data?: { cancelled?: boolean; isStreaming?: boolean; isCompacting?: boolean; pendingMessageCount?: number };
|
|
311
|
-
toolCallId?: string;
|
|
312
|
-
toolName?: string;
|
|
313
|
-
args?: unknown;
|
|
314
|
-
assistantMessageEvent?: {
|
|
315
|
-
type?: string;
|
|
316
|
-
delta?: string;
|
|
317
|
-
};
|
|
318
|
-
message?: {
|
|
319
|
-
role?: string;
|
|
320
|
-
stopReason?: string;
|
|
321
|
-
content?: Array<{ type?: string; text?: string }>;
|
|
322
|
-
usage?: {
|
|
323
|
-
input?: number;
|
|
324
|
-
output?: number;
|
|
325
|
-
cacheRead?: number;
|
|
326
|
-
cacheWrite?: number;
|
|
327
|
-
totalTokens?: number;
|
|
328
|
-
cost?: { total?: number };
|
|
329
|
-
};
|
|
330
|
-
};
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
interface StreamState {
|
|
334
|
-
text: string;
|
|
335
|
-
thinking: string;
|
|
336
|
-
toolcallArgs: string;
|
|
337
|
-
activeTool?: string;
|
|
338
|
-
activeTools: Map<string, string>;
|
|
339
|
-
/** Lifetime count of completed assistant messages. */
|
|
340
|
-
turns: number;
|
|
341
|
-
finalResponse?: boolean;
|
|
342
|
-
/** Set by any recognized model/stream activity; never by a bare empty
|
|
343
|
-
* message_end artifact. The stall classifier uses this, not usage. */
|
|
344
|
-
modelOutputSeen?: boolean;
|
|
345
|
-
usage?: WorkerUsage;
|
|
346
|
-
controlError?: string;
|
|
347
|
-
turnBytes: number;
|
|
348
|
-
outputLimitError?: string;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function createStreamState(): StreamState {
|
|
352
|
-
return { text: "", thinking: "", toolcallArgs: "", activeTools: new Map(), turns: 0, turnBytes: 0 };
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function clearActiveTools(state: StreamState): void {
|
|
356
|
-
state.toolcallArgs = "";
|
|
357
|
-
state.activeTool = undefined;
|
|
358
|
-
state.activeTools.clear();
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function toolExecutionLabel(toolName: string | undefined, args: unknown): string {
|
|
362
|
-
const serialized = typeof args === "string" ? args : JSON.stringify(args ?? {});
|
|
363
|
-
return truncate(toolcallLabel(serialized) ?? toolName ?? "tool", OUTPUT_CAP);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** Human-readable label from a partially streamed tool-call argument JSON. */
|
|
367
|
-
function toolcallLabel(rawArgs: string): string | undefined {
|
|
368
|
-
const trimmed = rawArgs.trim();
|
|
369
|
-
if (!trimmed.startsWith("{")) return undefined;
|
|
370
|
-
try {
|
|
371
|
-
const args = JSON.parse(trimmed) as Record<string, unknown>;
|
|
372
|
-
const command = args.command;
|
|
373
|
-
if (typeof command === "string" && command.trim()) return `bash: ${normalizeInline(command)}`;
|
|
374
|
-
const filePath = args.path;
|
|
375
|
-
if (typeof filePath === "string" && filePath.trim()) return `file: ${normalizeInline(path.basename(filePath.trim()))}`;
|
|
376
|
-
const subject = args.subject;
|
|
377
|
-
if (typeof subject === "string" && subject.trim()) return `message: ${normalizeInline(subject)}`;
|
|
378
|
-
const query = args.query;
|
|
379
|
-
if (typeof query === "string" && query.trim()) return `search: ${normalizeInline(query)}`;
|
|
380
|
-
const to = args.to;
|
|
381
|
-
if (typeof to === "string" && to.trim() && typeof args.subject === "string") return `send: ${normalizeInline(args.subject)}`;
|
|
382
|
-
} catch {
|
|
383
|
-
// Incomplete JSON mid-stream — retry on the next delta.
|
|
384
|
-
}
|
|
385
|
-
return undefined;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function normalizeInline(text: string): string {
|
|
389
|
-
return text.replace(/\s+/g, " ").trim();
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function appendTurnText(state: StreamState, field: "text" | "thinking" | "toolcallArgs", delta: string): boolean {
|
|
393
|
-
const nextBytes = state.turnBytes + Buffer.byteLength(delta, "utf8");
|
|
394
|
-
if (nextBytes > MAX_TURN_OUTPUT_BYTES) {
|
|
395
|
-
state.outputLimitError = `Resident worker ${field} exceeded ${MAX_TURN_OUTPUT_BYTES} bytes in one turn.`;
|
|
396
|
-
return false;
|
|
397
|
-
}
|
|
398
|
-
state.turnBytes = nextBytes;
|
|
399
|
-
state[field] += delta;
|
|
400
|
-
return true;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function applyStreamLine(state: StreamState, line: string, child?: ChildProcess): boolean {
|
|
404
|
-
if (!line.trim()) return false;
|
|
405
|
-
let event: JsonEvent;
|
|
406
|
-
try {
|
|
407
|
-
event = JSON.parse(line) as JsonEvent;
|
|
408
|
-
} catch {
|
|
409
|
-
return false;
|
|
410
|
-
}
|
|
411
|
-
if (event.type === "response" && event.id) {
|
|
412
|
-
const pending = pendingControlResponses.get(event.id);
|
|
413
|
-
if (pending && child === pending.child && pending.command === event.command) {
|
|
414
|
-
clearTimeout(pending.timeout);
|
|
415
|
-
pendingControlResponses.delete(event.id);
|
|
416
|
-
pending.resolve(event.success === true && event.data?.cancelled !== true
|
|
417
|
-
&& (pending.command !== "get_state" || (event.data?.isStreaming === false
|
|
418
|
-
&& event.data?.isCompacting !== true && (event.data?.pendingMessageCount ?? 0) === 0)));
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
if (event.type === "response" && event.success === false) {
|
|
422
|
-
state.controlError = truncate(`RPC ${event.command ?? "command"} rejected: ${event.error ?? "unknown error"}`, 1000);
|
|
423
|
-
return true;
|
|
424
|
-
}
|
|
425
|
-
if (event.type === "agent_settled") {
|
|
426
|
-
state.finalResponse = true;
|
|
427
|
-
clearActiveTools(state);
|
|
428
|
-
return true;
|
|
429
|
-
}
|
|
430
|
-
if (event.type === "agent_start") {
|
|
431
|
-
state.finalResponse = false;
|
|
432
|
-
state.text = "";
|
|
433
|
-
state.thinking = "";
|
|
434
|
-
clearActiveTools(state);
|
|
435
|
-
state.turnBytes = 0;
|
|
436
|
-
state.outputLimitError = undefined;
|
|
437
|
-
return true;
|
|
438
|
-
}
|
|
439
|
-
if (event.type === "tool_execution_start") {
|
|
440
|
-
state.modelOutputSeen = true;
|
|
441
|
-
state.activeTools.set(event.toolCallId ?? `tool-${state.activeTools.size}`, toolExecutionLabel(event.toolName, event.args));
|
|
442
|
-
state.activeTool = [...state.activeTools.values()].at(-1);
|
|
443
|
-
return true;
|
|
444
|
-
}
|
|
445
|
-
if (event.type === "tool_execution_end") {
|
|
446
|
-
state.modelOutputSeen = true;
|
|
447
|
-
if (event.toolCallId) state.activeTools.delete(event.toolCallId);
|
|
448
|
-
else state.activeTools.clear();
|
|
449
|
-
state.activeTool = [...state.activeTools.values()].at(-1);
|
|
450
|
-
return true;
|
|
451
|
-
}
|
|
452
|
-
if (event.type !== "message_update") {
|
|
453
|
-
if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
|
|
454
|
-
state.turns++;
|
|
455
|
-
clearActiveTools(state);
|
|
456
|
-
state.thinking = "";
|
|
457
|
-
const parts = extractTextContent(event.message.content, "");
|
|
458
|
-
if (Buffer.byteLength(parts, "utf8") > MAX_TURN_OUTPUT_BYTES) {
|
|
459
|
-
state.outputLimitError = `Resident worker message exceeded ${MAX_TURN_OUTPUT_BYTES} bytes.`;
|
|
460
|
-
return true;
|
|
461
|
-
}
|
|
462
|
-
if (parts.trim()) state.text = parts;
|
|
463
|
-
state.turnBytes = 0;
|
|
464
|
-
// Usage stays diagnostics: only streamed content counts as model output,
|
|
465
|
-
// so input-only or failed responses cannot bypass the zero-output tier.
|
|
466
|
-
const u = event.message.usage;
|
|
467
|
-
if (parts.trim()) state.modelOutputSeen = true;
|
|
468
|
-
if (u) {
|
|
469
|
-
state.usage = {
|
|
470
|
-
input: (state.usage?.input ?? 0) + (u.input ?? 0),
|
|
471
|
-
output: (state.usage?.output ?? 0) + (u.output ?? 0),
|
|
472
|
-
cacheRead: (state.usage?.cacheRead ?? 0) + (u.cacheRead ?? 0),
|
|
473
|
-
cacheWrite: (state.usage?.cacheWrite ?? 0) + (u.cacheWrite ?? 0),
|
|
474
|
-
totalTokens: (state.usage?.totalTokens ?? 0) + (u.totalTokens ?? 0),
|
|
475
|
-
cost: (state.usage?.cost ?? 0) + (u.cost?.total ?? 0),
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
return true;
|
|
479
|
-
}
|
|
480
|
-
const sub = event.assistantMessageEvent;
|
|
481
|
-
if (!sub) return false;
|
|
482
|
-
switch (sub.type) {
|
|
483
|
-
case "text_delta":
|
|
484
|
-
state.modelOutputSeen = true;
|
|
485
|
-
state.activeTool = undefined;
|
|
486
|
-
appendTurnText(state, "text", sub.delta ?? "");
|
|
487
|
-
return true;
|
|
488
|
-
case "thinking_delta":
|
|
489
|
-
state.modelOutputSeen = true;
|
|
490
|
-
state.activeTool = undefined;
|
|
491
|
-
appendTurnText(state, "thinking", sub.delta ?? "");
|
|
492
|
-
return true;
|
|
493
|
-
case "toolcall_start":
|
|
494
|
-
state.modelOutputSeen = true;
|
|
495
|
-
clearActiveTools(state);
|
|
496
|
-
return true;
|
|
497
|
-
case "toolcall_delta": {
|
|
498
|
-
state.modelOutputSeen = true;
|
|
499
|
-
appendTurnText(state, "toolcallArgs", sub.delta ?? "");
|
|
500
|
-
const label = toolcallLabel(state.toolcallArgs);
|
|
501
|
-
if (label) state.activeTool = label;
|
|
502
|
-
return true;
|
|
503
|
-
}
|
|
504
|
-
case "toolcall_end":
|
|
505
|
-
state.modelOutputSeen = true;
|
|
506
|
-
// Execution events own the activity label until the result arrives.
|
|
507
|
-
clearActiveTools(state);
|
|
508
|
-
return true;
|
|
509
|
-
default:
|
|
510
|
-
return false;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/** Parse the final assistant text and accumulated usage from captured stdout. */
|
|
515
|
-
export function parseTeammateOutput(stdout: string): { text: string; usage?: WorkerUsage } {
|
|
516
|
-
const state = createStreamState();
|
|
517
|
-
for (const line of stdout.split("\n")) applyStreamLine(state, line);
|
|
518
|
-
return { text: state.text.trim(), usage: state.usage };
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function truncate(text: string, cap: number): string {
|
|
522
|
-
if (text.length <= cap) return text;
|
|
523
|
-
return `${text.slice(0, cap)}\n... [truncated ${text.length - cap} chars]`;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// ── Resident spawn ────────────────────────────────────────────────
|
|
527
|
-
|
|
528
|
-
/** Turn-count baseline per teammate at its most recent delivered prompt. */
|
|
529
|
-
const baselines = new Map<string, number>();
|
|
530
|
-
const streamTurns = new Map<string, number>();
|
|
531
|
-
|
|
532
|
-
export interface ResidentSpawnOptions {
|
|
533
|
-
/** Teammate name — the registry key for steering, prompting, and shutdown. */
|
|
534
|
-
workerName: string;
|
|
535
|
-
/** Optional kickoff prompt; omit to let the teammate idle immediately. */
|
|
536
|
-
description?: string;
|
|
537
|
-
model?: string;
|
|
538
|
-
/** Execution-tool allowlist; capability tools are always appended. */
|
|
539
|
-
tools?: string[];
|
|
540
|
-
/** Leader session's thinking level, forwarded as the child's default. */
|
|
541
|
-
thinking?: string;
|
|
542
|
-
/** Detached active-context snapshot; undefined starts with fresh history. */
|
|
543
|
-
context?: SessionContext["messages"];
|
|
544
|
-
env?: Record<string, string | undefined>;
|
|
545
|
-
cwd?: string;
|
|
546
|
-
onUpdate?: (update: WorkerProgressUpdate) => void;
|
|
547
|
-
onExit: (result: WorkerProcessResult) => void;
|
|
548
|
-
onError?: (error: Error) => void;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
export interface SpawnedResident {
|
|
552
|
-
pid: number;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
const WORKER_EXTENSION = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Spawn one resident child Pi process in RPC mode. Returns immediately with
|
|
559
|
-
* the child pid; outcomes arrive via onExit / onError. An empty description
|
|
560
|
-
* spawns an idle teammate that waits for its first delivered prompt.
|
|
561
|
-
*/
|
|
562
|
-
export function spawnResident(options: ResidentSpawnOptions): SpawnedResident | { error: string } {
|
|
563
|
-
const cli = resolvePiCli();
|
|
564
|
-
const args: string[] = [
|
|
565
|
-
...cli.args,
|
|
566
|
-
"--mode", "rpc",
|
|
567
|
-
"--no-extensions",
|
|
568
|
-
"--extension", WORKER_EXTENSION,
|
|
569
|
-
];
|
|
570
|
-
if (options.model) args.push("--model", options.model);
|
|
571
|
-
if (options.thinking) args.push("--thinking", options.thinking);
|
|
572
|
-
args.push("--tools", resolveWorkerTools(options.tools).join(","));
|
|
573
|
-
|
|
574
|
-
let tempDir: string | undefined;
|
|
575
|
-
const cleanupTempDir = () => {
|
|
576
|
-
if (!tempDir) return;
|
|
577
|
-
try {
|
|
578
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
579
|
-
} catch {
|
|
580
|
-
// Temporary cleanup must not mask the worker outcome.
|
|
581
|
-
} finally {
|
|
582
|
-
tempDir = undefined;
|
|
583
|
-
}
|
|
584
|
-
};
|
|
585
|
-
|
|
586
|
-
let child: ChildProcess;
|
|
587
|
-
try {
|
|
588
|
-
if (options.context !== undefined) {
|
|
589
|
-
tempDir = fs.mkdtempSync(path.join(tmpdir(), "pi-work-context-"));
|
|
590
|
-
args.push("--session", writeWorkContext(options.cwd ?? process.cwd(), tempDir, options.context));
|
|
591
|
-
} else {
|
|
592
|
-
args.push("--no-session");
|
|
593
|
-
}
|
|
594
|
-
child = spawnPiChild(cli.command, args, {
|
|
595
|
-
cwd: options.cwd,
|
|
596
|
-
env: { ...process.env, ...options.env },
|
|
597
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
598
|
-
});
|
|
599
|
-
} catch (error) {
|
|
600
|
-
cleanupTempDir();
|
|
601
|
-
return { error: error instanceof Error ? error.message : String(error) };
|
|
602
|
-
}
|
|
603
|
-
if (options.description && options.description.trim()) {
|
|
604
|
-
writeToControlStream(child, { type: "prompt", id: randomUUID(), message: options.description });
|
|
605
|
-
baselines.set(options.workerName, 0);
|
|
606
|
-
}
|
|
607
|
-
observeWorkerClose(options.workerName, child);
|
|
608
|
-
workers.set(options.workerName, child);
|
|
609
|
-
|
|
610
|
-
const stdoutChunks: string[] = [];
|
|
611
|
-
const stderrChunks: string[] = [];
|
|
612
|
-
const streamState = createStreamState();
|
|
613
|
-
streamStates.set(options.workerName, streamState);
|
|
614
|
-
let stdoutBuffer = "";
|
|
615
|
-
let failureReason: string | undefined;
|
|
616
|
-
let settled = false;
|
|
617
|
-
|
|
618
|
-
const emitProgress = () => options.onUpdate?.({
|
|
619
|
-
text: truncate(streamState.text, OUTPUT_CAP),
|
|
620
|
-
activeTool: streamState.activeTool,
|
|
621
|
-
liveThinking: truncate(streamState.thinking, OUTPUT_CAP),
|
|
622
|
-
turns: Math.max(0, streamState.turns - (baselines.get(options.workerName) ?? 0)),
|
|
623
|
-
finalResponse: streamState.finalResponse,
|
|
624
|
-
modelOutputSeen: streamState.modelOutputSeen,
|
|
625
|
-
usage: streamState.usage,
|
|
626
|
-
controlError: streamState.controlError,
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
let termination: Promise<boolean> | undefined;
|
|
630
|
-
const failWorker = (reason: string) => {
|
|
631
|
-
failureReason ??= reason;
|
|
632
|
-
stdoutBuffer = "";
|
|
633
|
-
streamState.outputLimitError ??= reason;
|
|
634
|
-
streamState.controlError = reason;
|
|
635
|
-
emitProgress();
|
|
636
|
-
termination ??= terminateChildProcess(child);
|
|
637
|
-
};
|
|
638
|
-
|
|
639
|
-
child.stdout?.on("data", (chunk: Buffer) => {
|
|
640
|
-
if (failureReason) return;
|
|
641
|
-
const text = chunk.toString();
|
|
642
|
-
stdoutBuffer += text;
|
|
643
|
-
const lines = stdoutBuffer.split("\n");
|
|
644
|
-
stdoutBuffer = lines.pop() ?? "";
|
|
645
|
-
let changed = false;
|
|
646
|
-
for (const line of lines) {
|
|
647
|
-
if (Buffer.byteLength(line, "utf8") > MAX_JSONL_LINE_BYTES) {
|
|
648
|
-
failWorker(`Resident worker JSONL line exceeded ${MAX_JSONL_LINE_BYTES} bytes.`);
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
changed = applyStreamLine(streamState, line, child) || changed;
|
|
652
|
-
if (streamState.finalResponse) startFreshAssignmentReset(options.workerName);
|
|
653
|
-
if (streamState.outputLimitError) {
|
|
654
|
-
failWorker(streamState.outputLimitError);
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
if (Buffer.byteLength(stdoutBuffer, "utf8") > MAX_JSONL_LINE_BYTES) {
|
|
659
|
-
failWorker(`Resident worker JSONL line exceeded ${MAX_JSONL_LINE_BYTES} bytes.`);
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
appendCapped(stdoutChunks, text, DIAGNOSTIC_TAIL_BYTES);
|
|
663
|
-
streamTurns.set(options.workerName, streamState.turns);
|
|
664
|
-
if (changed) emitProgress();
|
|
665
|
-
if (!failureReason) streamState.controlError = undefined;
|
|
666
|
-
});
|
|
667
|
-
child.stderr?.on("data", (chunk: Buffer) => {
|
|
668
|
-
if (failureReason) return;
|
|
669
|
-
appendCapped(stderrChunks, chunk.toString(), DIAGNOSTIC_TAIL_BYTES);
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
child.on("error", (error) => {
|
|
673
|
-
if (child.pid === undefined) cleanupTempDir();
|
|
674
|
-
settled = true;
|
|
675
|
-
// Keep the registry entry until close is observed so shutdown diagnostics
|
|
676
|
-
// can distinguish an error/exit code from a confirmed close event.
|
|
677
|
-
options.onError?.(error);
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
child.on("close", (code, signal) => {
|
|
681
|
-
cleanupTempDir();
|
|
682
|
-
if (workers.get(options.workerName) === child) workers.delete(options.workerName);
|
|
683
|
-
streamStates.delete(options.workerName);
|
|
684
|
-
const fresh = pendingFreshAssignments.get(options.workerName);
|
|
685
|
-
if (fresh?.child === child) settleFreshAssignment(options.workerName, false);
|
|
686
|
-
for (const [id, pending] of pendingControlResponses) {
|
|
687
|
-
if (pending.child !== child) continue;
|
|
688
|
-
clearTimeout(pending.timeout);
|
|
689
|
-
pendingControlResponses.delete(id);
|
|
690
|
-
pending.resolve(false);
|
|
691
|
-
}
|
|
692
|
-
baselines.delete(options.workerName);
|
|
693
|
-
streamTurns.delete(options.workerName);
|
|
694
|
-
// A spawn failure was already reported via onError (Node fires error then
|
|
695
|
-
// close) — do not double-report through onExit.
|
|
696
|
-
if (settled) return;
|
|
697
|
-
const failed = failureReason !== undefined;
|
|
698
|
-
const parsed = failed ? { text: "", usage: undefined } : parseTeammateOutput(stdoutChunks.join(""));
|
|
699
|
-
options.onExit({
|
|
700
|
-
pid: child.pid ?? 0,
|
|
701
|
-
exitCode: code === 0 && failed ? 1 : code,
|
|
702
|
-
signal,
|
|
703
|
-
stdout: failed ? "" : truncate(parsed.text, OUTPUT_CAP),
|
|
704
|
-
stderr: truncate([failureReason, stderrChunks.join("").trim()].filter(Boolean).join("\n"), OUTPUT_CAP),
|
|
705
|
-
usage: parsed.usage,
|
|
706
|
-
});
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
if (!options.description?.trim()) {
|
|
710
|
-
const id = randomUUID();
|
|
711
|
-
const timeout = setTimeout(() => {
|
|
712
|
-
pendingControlResponses.delete(id);
|
|
713
|
-
streamState.controlError = "Resident startup readiness was not acknowledged.";
|
|
714
|
-
emitProgress();
|
|
715
|
-
}, CONTROL_RESPONSE_TIMEOUT_MS);
|
|
716
|
-
timeout.unref?.();
|
|
717
|
-
pendingControlResponses.set(id, {
|
|
718
|
-
child,
|
|
719
|
-
command: "get_state",
|
|
720
|
-
timeout,
|
|
721
|
-
resolve: (ready) => {
|
|
722
|
-
if (workers.get(options.workerName) !== child) return;
|
|
723
|
-
if (!ready) {
|
|
724
|
-
streamState.controlError = "Resident startup readiness acknowledgement was rejected or not idle.";
|
|
725
|
-
emitProgress();
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
|
-
streamState.finalResponse = true;
|
|
729
|
-
options.onUpdate?.({ text: "", turns: 0, finalResponse: true, ready: true });
|
|
730
|
-
startFreshAssignmentReset(options.workerName);
|
|
731
|
-
},
|
|
732
|
-
});
|
|
733
|
-
if (!writeToControlStream(child, { type: "get_state", id })) {
|
|
734
|
-
clearTimeout(timeout);
|
|
735
|
-
pendingControlResponses.delete(id);
|
|
736
|
-
options.onError?.(new Error("Cannot request resident startup readiness."));
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
return { pid: child.pid ?? 0 };
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
function appendCapped(chunks: string[], chunk: string, cap: number): void {
|
|
743
|
-
chunks.push(chunk);
|
|
744
|
-
let total = chunks.reduce((sum, value) => sum + Buffer.byteLength(value, "utf8"), 0);
|
|
745
|
-
while (total > cap && chunks.length > 1) {
|
|
746
|
-
total -= Buffer.byteLength(chunks.shift() ?? "", "utf8");
|
|
747
|
-
}
|
|
748
|
-
if (total > cap && chunks.length === 1) {
|
|
749
|
-
chunks[0] = Buffer.from(chunks[0], "utf8").subarray(-cap).toString("utf8");
|
|
750
|
-
}
|
|
751
|
-
}
|