@fradser/pi-agent-teams 0.8.2 → 0.9.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 -1
- package/.memory/follow-up-queue.md +8 -11
- package/.memory/team-centric-design.md +18 -14
- package/README.md +119 -161
- package/package.json +2 -2
- package/references/agent-roles.md +57 -12
- package/src/activity.ts +12 -33
- package/src/agent-actions.ts +101 -0
- package/src/agents.ts +1 -6
- package/src/guidance.ts +114 -199
- package/src/index.ts +117 -101
- package/src/leader-reports.ts +92 -0
- package/src/recipient.ts +44 -0
- package/src/spawner.ts +293 -38
- package/src/state.ts +250 -69
- package/src/statefile.ts +74 -31
- package/src/team-machine.ts +984 -424
- package/src/tool-copy.ts +374 -0
- package/src/tool-render.ts +47 -22
- package/src/tools.ts +182 -160
- package/src/types.ts +104 -65
- package/src/ui.ts +32 -56
- package/src/work-context.ts +88 -0
- package/src/worker-tools.ts +4 -0
- package/src/worker.ts +251 -184
- package/src/follow-up-queue.ts +0 -301
package/src/spawner.ts
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { WORKER_BUILTIN_TOOLS } from "./worker-tools.ts";
|
|
15
|
+
export { WORKER_BUILTIN_TOOLS } from "./worker-tools.ts";
|
|
14
16
|
import * as fs from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
15
18
|
import * as path from "node:path";
|
|
16
19
|
import { fileURLToPath } from "node:url";
|
|
17
20
|
import {
|
|
@@ -23,8 +26,16 @@ import {
|
|
|
23
26
|
} from "@fradser/pi-kit";
|
|
24
27
|
import type { ChildProcess } from "node:child_process";
|
|
25
28
|
import type { WorkerUsage } from "./types.ts";
|
|
29
|
+
import { writeWorkContext } from "./work-context.ts";
|
|
30
|
+
import type { SessionContext } from "@earendil-works/pi-coding-agent";
|
|
26
31
|
|
|
27
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;
|
|
28
39
|
|
|
29
40
|
export interface WorkerProcessResult {
|
|
30
41
|
pid: number;
|
|
@@ -48,8 +59,11 @@ export interface WorkerProgressUpdate {
|
|
|
48
59
|
* tool-call, or tool execution events). Independent of usage totals so
|
|
49
60
|
* providers that omit usage are not misclassified as silent. */
|
|
50
61
|
modelOutputSeen?: boolean;
|
|
62
|
+
/** A prompt-less child acknowledged native startup readiness. */
|
|
63
|
+
ready?: boolean;
|
|
51
64
|
/** Lifetime accumulated usage parsed from message_end events. */
|
|
52
65
|
usage?: WorkerUsage;
|
|
66
|
+
controlError?: string;
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
/** A crashed teammate is one that closed without a normal zero exit. */
|
|
@@ -59,10 +73,8 @@ export function isCleanExit(result: Pick<WorkerProcessResult, "exitCode" | "sign
|
|
|
59
73
|
|
|
60
74
|
/** Tools every teammate receives regardless of its role definition. */
|
|
61
75
|
export const WORKER_CAPABILITY_TOOLS: readonly string[] = [
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"task_claim",
|
|
65
|
-
"task_submit",
|
|
76
|
+
"agent_event",
|
|
77
|
+
"work",
|
|
66
78
|
];
|
|
67
79
|
|
|
68
80
|
/** Effective tool allowlist for one teammate: the role's requested tools plus
|
|
@@ -78,16 +90,7 @@ export function resolveWorkerTools(requested?: string[]): string[] {
|
|
|
78
90
|
* --no-extensions, so this list (plus the capability set) is the complete
|
|
79
91
|
* grantable universe; anything else is silently dropped by the child's
|
|
80
92
|
* --tools allowlist filter. */
|
|
81
|
-
|
|
82
|
-
"read",
|
|
83
|
-
"bash",
|
|
84
|
-
"edit",
|
|
85
|
-
"write",
|
|
86
|
-
"grep",
|
|
87
|
-
"find",
|
|
88
|
-
"ls",
|
|
89
|
-
"powershell",
|
|
90
|
-
];
|
|
93
|
+
// Canonical IDs are shared with public schema and guidance in worker-tools.ts.
|
|
91
94
|
|
|
92
95
|
/** Every tool id a teammate can actually receive: pi built-ins plus the capability set. */
|
|
93
96
|
export const WORKER_TOOL_UNIVERSE: readonly string[] = [...WORKER_BUILTIN_TOOLS, ...WORKER_CAPABILITY_TOOLS];
|
|
@@ -122,10 +125,13 @@ export function isWorkerCloseObserved(name: string): boolean {
|
|
|
122
125
|
export { terminateChildProcess };
|
|
123
126
|
|
|
124
127
|
/** Terminate a living teammate and wait until its child process has closed. */
|
|
125
|
-
export
|
|
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> {
|
|
126
131
|
const child = workers.get(name);
|
|
127
|
-
if (!child) return
|
|
128
|
-
|
|
132
|
+
if (!child) return { outcome: "missing" };
|
|
133
|
+
const closed = closedWorkers.has(child) || await terminateChildProcess(child, graceMs);
|
|
134
|
+
return { outcome: closed ? "closed" : "unconfirmed" };
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
export async function terminateAllTeammates(graceMs = DEFAULT_TERMINATION_GRACE_MS): Promise<Array<{ name: string; confirmedClosed: boolean }>> {
|
|
@@ -138,6 +144,30 @@ export async function terminateAllTeammates(graceMs = DEFAULT_TERMINATION_GRACE_
|
|
|
138
144
|
|
|
139
145
|
// ── Control stream ────────────────────────────────────────────────
|
|
140
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
|
+
|
|
141
171
|
function writeToControlStream(child: ChildProcess, line: unknown): boolean {
|
|
142
172
|
if (!child.stdin || child.stdin.destroyed || !child.stdin.writable) return false;
|
|
143
173
|
child.stdin.write(`${JSON.stringify(line)}\n`);
|
|
@@ -151,7 +181,7 @@ function writeToControlStream(child: ChildProcess, line: unknown): boolean {
|
|
|
151
181
|
export function deliverPrompt(name: string, message: string): boolean {
|
|
152
182
|
const child = workers.get(name);
|
|
153
183
|
if (!child) return false;
|
|
154
|
-
const sent = writeToControlStream(child, { type: "prompt", id: randomUUID(), message });
|
|
184
|
+
const sent = writeToControlStream(child, { type: "prompt", id: randomUUID(), message, streamingBehavior: "followUp" });
|
|
155
185
|
if (sent) beginSequence(name);
|
|
156
186
|
return sent;
|
|
157
187
|
}
|
|
@@ -166,20 +196,118 @@ function beginSequence(name: string): void {
|
|
|
166
196
|
state.text = "";
|
|
167
197
|
state.thinking = "";
|
|
168
198
|
clearActiveTools(state);
|
|
199
|
+
state.turnBytes = 0;
|
|
200
|
+
state.outputLimitError = undefined;
|
|
169
201
|
}
|
|
170
202
|
baselines.set(name, streamTurns.get(name) ?? 0);
|
|
171
203
|
}
|
|
172
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
|
+
|
|
173
220
|
/** Send a mid-turn steer to a working teammate; no peer mailbox is involved. */
|
|
174
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> {
|
|
175
294
|
const child = workers.get(name);
|
|
176
|
-
|
|
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
|
+
});
|
|
177
300
|
}
|
|
178
301
|
|
|
179
302
|
// ── Stream parsing ────────────────────────────────────────────────
|
|
180
303
|
|
|
181
304
|
type JsonEvent = {
|
|
305
|
+
id?: string;
|
|
182
306
|
type?: string;
|
|
307
|
+
command?: string;
|
|
308
|
+
success?: boolean;
|
|
309
|
+
error?: string;
|
|
310
|
+
data?: { cancelled?: boolean; isStreaming?: boolean; isCompacting?: boolean; pendingMessageCount?: number };
|
|
183
311
|
toolCallId?: string;
|
|
184
312
|
toolName?: string;
|
|
185
313
|
args?: unknown;
|
|
@@ -215,10 +343,13 @@ interface StreamState {
|
|
|
215
343
|
* message_end artifact. The stall classifier uses this, not usage. */
|
|
216
344
|
modelOutputSeen?: boolean;
|
|
217
345
|
usage?: WorkerUsage;
|
|
346
|
+
controlError?: string;
|
|
347
|
+
turnBytes: number;
|
|
348
|
+
outputLimitError?: string;
|
|
218
349
|
}
|
|
219
350
|
|
|
220
351
|
function createStreamState(): StreamState {
|
|
221
|
-
return { text: "", thinking: "", toolcallArgs: "", activeTools: new Map(), turns: 0 };
|
|
352
|
+
return { text: "", thinking: "", toolcallArgs: "", activeTools: new Map(), turns: 0, turnBytes: 0 };
|
|
222
353
|
}
|
|
223
354
|
|
|
224
355
|
function clearActiveTools(state: StreamState): void {
|
|
@@ -229,7 +360,7 @@ function clearActiveTools(state: StreamState): void {
|
|
|
229
360
|
|
|
230
361
|
function toolExecutionLabel(toolName: string | undefined, args: unknown): string {
|
|
231
362
|
const serialized = typeof args === "string" ? args : JSON.stringify(args ?? {});
|
|
232
|
-
return toolcallLabel(serialized) ?? toolName ?? "tool";
|
|
363
|
+
return truncate(toolcallLabel(serialized) ?? toolName ?? "tool", OUTPUT_CAP);
|
|
233
364
|
}
|
|
234
365
|
|
|
235
366
|
/** Human-readable label from a partially streamed tool-call argument JSON. */
|
|
@@ -258,7 +389,18 @@ function normalizeInline(text: string): string {
|
|
|
258
389
|
return text.replace(/\s+/g, " ").trim();
|
|
259
390
|
}
|
|
260
391
|
|
|
261
|
-
function
|
|
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 {
|
|
262
404
|
if (!line.trim()) return false;
|
|
263
405
|
let event: JsonEvent;
|
|
264
406
|
try {
|
|
@@ -266,6 +408,34 @@ function applyStreamLine(state: StreamState, line: string): boolean {
|
|
|
266
408
|
} catch {
|
|
267
409
|
return false;
|
|
268
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
|
+
}
|
|
269
439
|
if (event.type === "tool_execution_start") {
|
|
270
440
|
state.modelOutputSeen = true;
|
|
271
441
|
state.activeTools.set(event.toolCallId ?? `tool-${state.activeTools.size}`, toolExecutionLabel(event.toolName, event.args));
|
|
@@ -284,9 +454,13 @@ function applyStreamLine(state: StreamState, line: string): boolean {
|
|
|
284
454
|
state.turns++;
|
|
285
455
|
clearActiveTools(state);
|
|
286
456
|
state.thinking = "";
|
|
287
|
-
if (event.message.stopReason === "stop") state.finalResponse = true;
|
|
288
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
|
+
}
|
|
289
462
|
if (parts.trim()) state.text = parts;
|
|
463
|
+
state.turnBytes = 0;
|
|
290
464
|
// Usage stays diagnostics: only streamed content counts as model output,
|
|
291
465
|
// so input-only or failed responses cannot bypass the zero-output tier.
|
|
292
466
|
const u = event.message.usage;
|
|
@@ -309,12 +483,12 @@ function applyStreamLine(state: StreamState, line: string): boolean {
|
|
|
309
483
|
case "text_delta":
|
|
310
484
|
state.modelOutputSeen = true;
|
|
311
485
|
state.activeTool = undefined;
|
|
312
|
-
state
|
|
486
|
+
appendTurnText(state, "text", sub.delta ?? "");
|
|
313
487
|
return true;
|
|
314
488
|
case "thinking_delta":
|
|
315
489
|
state.modelOutputSeen = true;
|
|
316
490
|
state.activeTool = undefined;
|
|
317
|
-
state
|
|
491
|
+
appendTurnText(state, "thinking", sub.delta ?? "");
|
|
318
492
|
return true;
|
|
319
493
|
case "toolcall_start":
|
|
320
494
|
state.modelOutputSeen = true;
|
|
@@ -322,7 +496,7 @@ function applyStreamLine(state: StreamState, line: string): boolean {
|
|
|
322
496
|
return true;
|
|
323
497
|
case "toolcall_delta": {
|
|
324
498
|
state.modelOutputSeen = true;
|
|
325
|
-
state
|
|
499
|
+
appendTurnText(state, "toolcallArgs", sub.delta ?? "");
|
|
326
500
|
const label = toolcallLabel(state.toolcallArgs);
|
|
327
501
|
if (label) state.activeTool = label;
|
|
328
502
|
return true;
|
|
@@ -363,6 +537,10 @@ export interface ResidentSpawnOptions {
|
|
|
363
537
|
model?: string;
|
|
364
538
|
/** Execution-tool allowlist; capability tools are always appended. */
|
|
365
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"];
|
|
366
544
|
env?: Record<string, string | undefined>;
|
|
367
545
|
cwd?: string;
|
|
368
546
|
onUpdate?: (update: WorkerProgressUpdate) => void;
|
|
@@ -386,11 +564,11 @@ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident |
|
|
|
386
564
|
const args: string[] = [
|
|
387
565
|
...cli.args,
|
|
388
566
|
"--mode", "rpc",
|
|
389
|
-
"--no-session",
|
|
390
567
|
"--no-extensions",
|
|
391
568
|
"--extension", WORKER_EXTENSION,
|
|
392
569
|
];
|
|
393
570
|
if (options.model) args.push("--model", options.model);
|
|
571
|
+
if (options.thinking) args.push("--thinking", options.thinking);
|
|
394
572
|
args.push("--tools", resolveWorkerTools(options.tools).join(","));
|
|
395
573
|
|
|
396
574
|
let tempDir: string | undefined;
|
|
@@ -407,6 +585,12 @@ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident |
|
|
|
407
585
|
|
|
408
586
|
let child: ChildProcess;
|
|
409
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
|
+
}
|
|
410
594
|
child = spawnPiChild(cli.command, args, {
|
|
411
595
|
cwd: options.cwd,
|
|
412
596
|
env: { ...process.env, ...options.env },
|
|
@@ -428,6 +612,7 @@ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident |
|
|
|
428
612
|
const streamState = createStreamState();
|
|
429
613
|
streamStates.set(options.workerName, streamState);
|
|
430
614
|
let stdoutBuffer = "";
|
|
615
|
+
let failureReason: string | undefined;
|
|
431
616
|
let settled = false;
|
|
432
617
|
|
|
433
618
|
const emitProgress = () => options.onUpdate?.({
|
|
@@ -438,25 +623,54 @@ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident |
|
|
|
438
623
|
finalResponse: streamState.finalResponse,
|
|
439
624
|
modelOutputSeen: streamState.modelOutputSeen,
|
|
440
625
|
usage: streamState.usage,
|
|
626
|
+
controlError: streamState.controlError,
|
|
441
627
|
});
|
|
442
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
|
+
|
|
443
639
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
640
|
+
if (failureReason) return;
|
|
444
641
|
const text = chunk.toString();
|
|
445
|
-
appendCapped(stdoutChunks, text, OUTPUT_CAP * 4);
|
|
446
642
|
stdoutBuffer += text;
|
|
447
643
|
const lines = stdoutBuffer.split("\n");
|
|
448
644
|
stdoutBuffer = lines.pop() ?? "";
|
|
449
645
|
let changed = false;
|
|
450
646
|
for (const line of lines) {
|
|
451
|
-
|
|
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
|
+
}
|
|
452
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);
|
|
453
663
|
streamTurns.set(options.workerName, streamState.turns);
|
|
454
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);
|
|
455
670
|
});
|
|
456
|
-
child.stderr?.on("data", (chunk: Buffer) => appendCapped(stderrChunks, chunk.toString(), OUTPUT_CAP * 2));
|
|
457
671
|
|
|
458
672
|
child.on("error", (error) => {
|
|
459
|
-
cleanupTempDir();
|
|
673
|
+
if (child.pid === undefined) cleanupTempDir();
|
|
460
674
|
settled = true;
|
|
461
675
|
// Keep the registry entry until close is observed so shutdown diagnostics
|
|
462
676
|
// can distinguish an error/exit code from a confirmed close event.
|
|
@@ -467,30 +681,71 @@ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident |
|
|
|
467
681
|
cleanupTempDir();
|
|
468
682
|
if (workers.get(options.workerName) === child) workers.delete(options.workerName);
|
|
469
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
|
+
}
|
|
470
692
|
baselines.delete(options.workerName);
|
|
471
693
|
streamTurns.delete(options.workerName);
|
|
472
694
|
// A spawn failure was already reported via onError (Node fires error then
|
|
473
695
|
// close) — do not double-report through onExit.
|
|
474
696
|
if (settled) return;
|
|
475
|
-
const
|
|
697
|
+
const failed = failureReason !== undefined;
|
|
698
|
+
const parsed = failed ? { text: "", usage: undefined } : parseTeammateOutput(stdoutChunks.join(""));
|
|
476
699
|
options.onExit({
|
|
477
700
|
pid: child.pid ?? 0,
|
|
478
|
-
exitCode: code,
|
|
701
|
+
exitCode: code === 0 && failed ? 1 : code,
|
|
479
702
|
signal,
|
|
480
|
-
stdout: truncate(parsed.text, OUTPUT_CAP),
|
|
481
|
-
stderr: truncate(stderrChunks.join("").trim(), OUTPUT_CAP),
|
|
703
|
+
stdout: failed ? "" : truncate(parsed.text, OUTPUT_CAP),
|
|
704
|
+
stderr: truncate([failureReason, stderrChunks.join("").trim()].filter(Boolean).join("\n"), OUTPUT_CAP),
|
|
482
705
|
usage: parsed.usage,
|
|
483
706
|
});
|
|
484
707
|
});
|
|
485
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
|
+
}
|
|
486
739
|
return { pid: child.pid ?? 0 };
|
|
487
740
|
}
|
|
488
741
|
|
|
489
742
|
function appendCapped(chunks: string[], chunk: string, cap: number): void {
|
|
490
743
|
chunks.push(chunk);
|
|
491
|
-
let total = chunks.reduce((sum, value) => sum + value
|
|
744
|
+
let total = chunks.reduce((sum, value) => sum + Buffer.byteLength(value, "utf8"), 0);
|
|
492
745
|
while (total > cap && chunks.length > 1) {
|
|
493
|
-
total -= chunks.shift()
|
|
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");
|
|
494
750
|
}
|
|
495
|
-
if (total > cap && chunks.length === 1) chunks[0] = chunks[0].slice(-cap);
|
|
496
751
|
}
|