@fradser/pi-agent-teams 0.5.3 → 0.7.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 +3 -1
- package/.memory/node-terminal-followups.md +3 -2
- package/.memory/team-centric-design.md +21 -0
- package/README.md +103 -63
- package/package.json +4 -4
- package/references/agent-roles.md +77 -0
- package/src/activity.ts +86 -0
- package/src/agents.ts +93 -24
- package/src/follow-up-queue.ts +94 -26
- package/src/guidance.ts +96 -61
- package/src/index.ts +114 -28
- package/src/spawner.ts +194 -350
- package/src/state.ts +252 -565
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +968 -0
- package/src/tools.ts +160 -175
- package/src/types.ts +153 -189
- package/src/ui.ts +459 -226
- package/src/worker.ts +174 -34
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/agents/observer.md +0 -10
- package/agents/reviewer.md +0 -11
- package/agents/specialist.md +0 -10
- package/agents/worker.md +0 -11
- package/src/run-machine.ts +0 -606
- package/src/terminal.ts +0 -34
package/src/spawner.ts
CHANGED
|
@@ -1,82 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Teammate spawner — spawns
|
|
2
|
+
* Teammate spawner — spawns named resident child Pi processes in RPC mode.
|
|
3
3
|
*
|
|
4
|
-
* A teammate
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
11
13
|
import { randomUUID } from "node:crypto";
|
|
12
14
|
import * as fs from "node:fs";
|
|
13
|
-
import * as os from "node:os";
|
|
14
15
|
import * as path from "node:path";
|
|
15
16
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import {
|
|
17
|
-
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_TERMINATION_GRACE_MS,
|
|
19
|
+
extractTextContent,
|
|
20
|
+
resolvePiCli,
|
|
21
|
+
spawnPiChild,
|
|
22
|
+
terminateChildProcess,
|
|
23
|
+
} from "@fradser/pi-kit";
|
|
24
|
+
import type { ChildProcess } from "node:child_process";
|
|
25
|
+
import type { WorkerUsage } from "./types.ts";
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
export const DEFAULT_TURN_BUDGET = 100;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Build the one-task execution prompt for a spawned worker.
|
|
24
|
-
*
|
|
25
|
-
* A worker executes one bounded task run. It reports to the leader throughout
|
|
26
|
-
* the run and then exits. The parent independently drains the worker outbox.
|
|
27
|
-
*/
|
|
28
|
-
export function buildAutonomousPrompt(opts: {
|
|
29
|
-
name: string;
|
|
30
|
-
role: string;
|
|
31
|
-
prompt: string;
|
|
32
|
-
taskId?: string;
|
|
33
|
-
turnBudget?: number;
|
|
34
|
-
}): string {
|
|
35
|
-
const taskLine = `\nAssigned task: [${opts.taskId}].\nWhen finished, you MUST call teammate_message with status="completed" to submit your FULL final deliverable.`;
|
|
36
|
-
const budgetLine = opts.turnBudget
|
|
37
|
-
? `4. You have a maximum of ${opts.turnBudget} assistant turn(s) — manage your turn budget, avoid unnecessary exploration, and deliver your final message before the budget is exhausted.`
|
|
38
|
-
: `4. There is no turn budget — work until the task is complete. Do not rush or truncate your work.`;
|
|
39
|
-
|
|
40
|
-
return `You are a FULLY AUTONOMOUS teammate named "${opts.name}" (agent: ${opts.role}) in a pi multi-agent team run.
|
|
41
|
-
|
|
42
|
-
=== ROLE PROMPT ===
|
|
43
|
-
${opts.prompt}
|
|
44
|
-
${taskLine}
|
|
45
|
-
|
|
46
|
-
YOUR ROLE IN THIS RUN:
|
|
47
|
-
1. Work directly on your assigned scope and declared paths. DAG upstream results are ALREADY injected into this prompt below; do not poll for them.
|
|
48
|
-
2. Report plans, progress, and blockers with teammate_message. When finished, call teammate_message with status="completed" and put your FULL deliverable in the body. If blocked or failed, use status="failed" and explain the error.
|
|
49
|
-
3. There is no peer or leader-to-worker message channel. Make decisions within the assigned task and report blockers instead of waiting for a reply.
|
|
50
|
-
${budgetLine}
|
|
51
|
-
|
|
52
|
-
BOUND CAPABILITIES:
|
|
53
|
-
- teammate_message reports progress or a final deliverable to the team leader.`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface SpawnPiWorkerOptions {
|
|
57
|
-
/** Worker identity — used to register the child so the panel can interrupt/stop it. */
|
|
58
|
-
workerName?: string;
|
|
59
|
-
/** Task description — the actual work the worker executes. */
|
|
60
|
-
description: string;
|
|
61
|
-
/** Optional model pattern for the child (e.g. "anthropic/claude-sonnet-4"). */
|
|
62
|
-
model?: string;
|
|
63
|
-
/** Optional execution-tool allowlist for the child. Capability tools are always appended. */
|
|
64
|
-
tools?: string[];
|
|
65
|
-
/** Environment additions bound to this worker process. */
|
|
66
|
-
env?: Record<string, string | undefined>;
|
|
67
|
-
/** Working directory for the child (defaults to the parent's cwd). */
|
|
68
|
-
cwd?: string;
|
|
69
|
-
/** Maximum assistant turns for this worker (defaults to DEFAULT_TURN_BUDGET). */
|
|
70
|
-
turnBudget?: number;
|
|
71
|
-
/** Output parsing mode. RPC keeps stdin available for runtime steering. */
|
|
72
|
-
mode?: "json" | "rpc" | "text";
|
|
73
|
-
/** Called whenever JSON-mode worker output reveals live model or tool activity. */
|
|
74
|
-
onUpdate?: (update: WorkerProgressUpdate) => void;
|
|
75
|
-
/** Called once with the captured output when the child exits. */
|
|
76
|
-
onExit: (result: WorkerProcessResult) => void;
|
|
77
|
-
/** Called when the child could not be spawned at all. */
|
|
78
|
-
onError?: (error: Error) => void;
|
|
79
|
-
}
|
|
27
|
+
const OUTPUT_CAP = 16_000;
|
|
80
28
|
|
|
81
29
|
export interface WorkerProcessResult {
|
|
82
30
|
pid: number;
|
|
@@ -85,175 +33,104 @@ export interface WorkerProcessResult {
|
|
|
85
33
|
stdout: string;
|
|
86
34
|
stderr: string;
|
|
87
35
|
usage?: WorkerUsage;
|
|
88
|
-
turnBudgetExceeded?: boolean;
|
|
89
36
|
}
|
|
90
37
|
|
|
91
|
-
/** Live state extracted from a
|
|
38
|
+
/** Live state extracted from a teammate's RPC output stream. */
|
|
92
39
|
export interface WorkerProgressUpdate {
|
|
93
40
|
text: string;
|
|
94
41
|
activeTool?: string;
|
|
95
42
|
liveThinking?: string;
|
|
43
|
+
/** Assistant turns observed in the current wake-up sequence. */
|
|
96
44
|
turns: number;
|
|
45
|
+
/** True after the current sequence's final response. */
|
|
97
46
|
finalResponse?: boolean;
|
|
98
47
|
}
|
|
99
48
|
|
|
100
|
-
/** A
|
|
101
|
-
export function
|
|
49
|
+
/** A crashed teammate is one that closed without a normal zero exit. */
|
|
50
|
+
export function isCleanExit(result: Pick<WorkerProcessResult, "exitCode" | "signal">): boolean {
|
|
102
51
|
return result.exitCode === 0 && result.signal === null;
|
|
103
52
|
}
|
|
104
53
|
|
|
105
|
-
|
|
106
|
-
export function isCompletedWorkerExit(
|
|
107
|
-
result: Pick<WorkerProcessResult, "exitCode" | "signal">,
|
|
108
|
-
reportedCompleted = false,
|
|
109
|
-
): boolean {
|
|
110
|
-
return isSuccessfulWorkerExit(result) || reportedCompleted;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface SpawnedWorker {
|
|
114
|
-
pid: number;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Bounded grace period before a cancellation escalates from SIGTERM to SIGKILL. */
|
|
118
|
-
const CANCEL_GRACE_MS = 5_000;
|
|
119
|
-
/** Give a terminally reported worker a short chance to exit cleanly. */
|
|
120
|
-
export const POST_REPORT_GRACE_MS = 10_000;
|
|
54
|
+
// ── Process registry ──────────────────────────────────────────────
|
|
121
55
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
* a later task run.
|
|
126
|
-
*/
|
|
127
|
-
export class CancellationIntents {
|
|
128
|
-
private readonly finalizers = new Map<string, Array<(cancelled: boolean) => void>>();
|
|
56
|
+
/** Live children by teammate name — powers steering, prompting, and shutdown. */
|
|
57
|
+
const workers = new Map<string, ChildProcess>();
|
|
58
|
+
const closedWorkers = new WeakSet<ChildProcess>();
|
|
129
59
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
has(spawnId: string): boolean {
|
|
137
|
-
return this.finalizers.has(spawnId);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
defer(spawnId: string, finalize: (cancelled: boolean) => void): boolean {
|
|
141
|
-
const pending = this.finalizers.get(spawnId);
|
|
142
|
-
if (!pending) return false;
|
|
143
|
-
pending.push(finalize);
|
|
144
|
-
return true;
|
|
145
|
-
}
|
|
60
|
+
function observeWorkerClose(name: string, child: ChildProcess): void {
|
|
61
|
+
child.once("close", () => {
|
|
62
|
+
closedWorkers.add(child);
|
|
63
|
+
if (workers.get(name) === child) workers.delete(name);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
146
66
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
this.finalizers.delete(spawnId);
|
|
154
|
-
}
|
|
155
|
-
return true;
|
|
156
|
-
}
|
|
67
|
+
/** True only after Node has emitted the child process close event. */
|
|
68
|
+
export function isWorkerCloseObserved(name: string): boolean {
|
|
69
|
+
const child = workers.get(name);
|
|
70
|
+
if (child) return closedWorkers.has(child);
|
|
71
|
+
// Not registered anymore: either never spawned or already unregistered by close.
|
|
72
|
+
return true;
|
|
157
73
|
}
|
|
158
74
|
|
|
159
|
-
|
|
160
|
-
const workers = new Map<string, ReturnType<typeof spawn>>();
|
|
75
|
+
export { terminateChildProcess };
|
|
161
76
|
|
|
162
|
-
/**
|
|
163
|
-
export function
|
|
77
|
+
/** Terminate a living teammate and wait until its child process has closed. */
|
|
78
|
+
export async function terminateTeammate(name: string, graceMs = DEFAULT_TERMINATION_GRACE_MS): Promise<boolean> {
|
|
164
79
|
const child = workers.get(name);
|
|
165
|
-
if (!child
|
|
166
|
-
child
|
|
167
|
-
return true;
|
|
80
|
+
if (!child) return false;
|
|
81
|
+
return terminateChildProcess(child, graceMs);
|
|
168
82
|
}
|
|
169
83
|
|
|
170
|
-
function
|
|
171
|
-
|
|
84
|
+
export async function terminateAllTeammates(graceMs = DEFAULT_TERMINATION_GRACE_MS): Promise<Array<{ name: string; confirmedClosed: boolean }>> {
|
|
85
|
+
const entries = [...workers.entries()];
|
|
86
|
+
return Promise.all(entries.map(async ([name, child]) => ({
|
|
87
|
+
name,
|
|
88
|
+
confirmedClosed: closedWorkers.has(child) || await terminateChildProcess(child, graceMs),
|
|
89
|
+
})));
|
|
172
90
|
}
|
|
173
91
|
|
|
174
|
-
|
|
175
|
-
return new Promise((resolve) => {
|
|
176
|
-
let settled = false;
|
|
177
|
-
const onClose = () => finish(true);
|
|
178
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
179
|
-
timer.unref?.();
|
|
180
|
-
|
|
181
|
-
function finish(closed: boolean): void {
|
|
182
|
-
if (settled) return;
|
|
183
|
-
settled = true;
|
|
184
|
-
clearTimeout(timer);
|
|
185
|
-
child.off("close", onClose);
|
|
186
|
-
resolve(closed);
|
|
187
|
-
}
|
|
92
|
+
// ── Control stream ────────────────────────────────────────────────
|
|
188
93
|
|
|
189
|
-
|
|
190
|
-
|
|
94
|
+
function writeToControlStream(child: ChildProcess, line: unknown): boolean {
|
|
95
|
+
if (!child.stdin || child.stdin.destroyed || !child.stdin.writable) return false;
|
|
96
|
+
child.stdin.write(`${JSON.stringify(line)}\n`);
|
|
97
|
+
return true;
|
|
191
98
|
}
|
|
192
99
|
|
|
193
100
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
101
|
+
* Deliver a new wake-up prompt to an idle teammate's control stream.
|
|
102
|
+
* This starts a fresh assistant sequence in the child process.
|
|
196
103
|
*/
|
|
197
|
-
export
|
|
198
|
-
if (!isChildRunning(child)) return false;
|
|
199
|
-
|
|
200
|
-
const closedAfterTerm = waitForClose(child, graceMs);
|
|
201
|
-
try {
|
|
202
|
-
if (!child.kill("SIGTERM")) {
|
|
203
|
-
void closedAfterTerm;
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
} catch {
|
|
207
|
-
void closedAfterTerm;
|
|
208
|
-
return false;
|
|
209
|
-
}
|
|
210
|
-
if (await closedAfterTerm) return true;
|
|
211
|
-
if (!isChildRunning(child)) return false;
|
|
212
|
-
|
|
213
|
-
const closedAfterKill = waitForClose(child, graceMs);
|
|
214
|
-
try {
|
|
215
|
-
if (!child.kill("SIGKILL")) {
|
|
216
|
-
void closedAfterKill;
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
} catch {
|
|
220
|
-
void closedAfterKill;
|
|
221
|
-
return false;
|
|
222
|
-
}
|
|
223
|
-
return closedAfterKill;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Terminate a live worker and wait until its child process has closed. */
|
|
227
|
-
export async function terminateWorker(name: string, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
|
|
104
|
+
export function deliverPrompt(name: string, message: string): boolean {
|
|
228
105
|
const child = workers.get(name);
|
|
229
106
|
if (!child) return false;
|
|
230
|
-
|
|
107
|
+
const sent = writeToControlStream(child, { type: "prompt", id: randomUUID(), message });
|
|
108
|
+
if (sent) beginSequence(name);
|
|
109
|
+
return sent;
|
|
231
110
|
}
|
|
232
111
|
|
|
233
|
-
/**
|
|
234
|
-
|
|
235
|
-
return terminateWorker(name, graceMs);
|
|
236
|
-
}
|
|
112
|
+
/** Per-name stream states so prompt delivery can reset sequence boundaries. */
|
|
113
|
+
const streamStates = new Map<string, StreamState>();
|
|
237
114
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
115
|
+
function beginSequence(name: string): void {
|
|
116
|
+
const state = streamStates.get(name);
|
|
117
|
+
if (state) {
|
|
118
|
+
state.finalResponse = false;
|
|
119
|
+
state.text = "";
|
|
120
|
+
state.thinking = "";
|
|
121
|
+
clearActiveTools(state);
|
|
122
|
+
}
|
|
123
|
+
baselines.set(name, streamTurns.get(name) ?? 0);
|
|
243
124
|
}
|
|
244
125
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
chunks.push(chunk);
|
|
250
|
-
let total = chunks.reduce((sum, value) => sum + value.length, 0);
|
|
251
|
-
while (total > cap && chunks.length > 1) {
|
|
252
|
-
total -= chunks.shift()?.length ?? 0;
|
|
253
|
-
}
|
|
254
|
-
if (total > cap && chunks.length === 1) chunks[0] = chunks[0].slice(-cap);
|
|
126
|
+
/** Send a mid-turn steer to a working teammate; no peer mailbox is involved. */
|
|
127
|
+
export function sendWorkerSteer(name: string, message: string): boolean {
|
|
128
|
+
const child = workers.get(name);
|
|
129
|
+
return child ? writeToControlStream(child, { type: "steer", message }) : false;
|
|
255
130
|
}
|
|
256
131
|
|
|
132
|
+
// ── Stream parsing ────────────────────────────────────────────────
|
|
133
|
+
|
|
257
134
|
type JsonEvent = {
|
|
258
135
|
type?: string;
|
|
259
136
|
toolCallId?: string;
|
|
@@ -278,58 +155,26 @@ type JsonEvent = {
|
|
|
278
155
|
};
|
|
279
156
|
};
|
|
280
157
|
|
|
281
|
-
|
|
282
|
-
* Parse the JSONL output of a `pi --print --mode json` worker run.
|
|
283
|
-
* Returns the final assistant text and the last reported usage.
|
|
284
|
-
*/
|
|
285
|
-
export function parseWorkerOutput(stdout: string): { text: string; usage?: WorkerUsage } {
|
|
286
|
-
const state = createWorkerStreamState();
|
|
287
|
-
for (const line of stdout.split("\n")) applyWorkerJsonLine(state, line);
|
|
288
|
-
return { text: state.text.trim(), usage: state.usage };
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Parse the latest live progress state from a worker JSON stream. */
|
|
292
|
-
export function parseWorkerProgress(stdout: string): WorkerProgressUpdate {
|
|
293
|
-
const state = createWorkerStreamState();
|
|
294
|
-
for (const line of stdout.split("\n")) applyWorkerJsonLine(state, line);
|
|
295
|
-
return {
|
|
296
|
-
text: truncate(state.text, OUTPUT_CAP),
|
|
297
|
-
activeTool: state.activeTool,
|
|
298
|
-
liveThinking: truncate(state.thinking, OUTPUT_CAP),
|
|
299
|
-
turns: state.turns,
|
|
300
|
-
finalResponse: state.finalResponse,
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
interface WorkerStreamState {
|
|
158
|
+
interface StreamState {
|
|
305
159
|
text: string;
|
|
306
160
|
thinking: string;
|
|
307
161
|
toolcallArgs: string;
|
|
308
162
|
activeTool?: string;
|
|
309
|
-
activeToolCallId?: string;
|
|
310
163
|
activeTools: Map<string, string>;
|
|
164
|
+
/** Lifetime count of completed assistant messages. */
|
|
311
165
|
turns: number;
|
|
312
166
|
finalResponse?: boolean;
|
|
313
167
|
usage?: WorkerUsage;
|
|
314
168
|
}
|
|
315
169
|
|
|
316
|
-
function
|
|
170
|
+
function createStreamState(): StreamState {
|
|
317
171
|
return { text: "", thinking: "", toolcallArgs: "", activeTools: new Map(), turns: 0 };
|
|
318
172
|
}
|
|
319
173
|
|
|
320
|
-
function
|
|
321
|
-
|
|
322
|
-
state.
|
|
323
|
-
state.
|
|
324
|
-
state.activeTool = label;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function clearActiveTool(state: WorkerStreamState, toolCallId: string | undefined): void {
|
|
328
|
-
if (toolCallId) state.activeTools.delete(toolCallId);
|
|
329
|
-
else state.activeTools.clear();
|
|
330
|
-
const next = [...state.activeTools.entries()].at(-1);
|
|
331
|
-
state.activeToolCallId = next?.[0];
|
|
332
|
-
state.activeTool = next?.[1];
|
|
174
|
+
function clearActiveTools(state: StreamState): void {
|
|
175
|
+
state.toolcallArgs = "";
|
|
176
|
+
state.activeTool = undefined;
|
|
177
|
+
state.activeTools.clear();
|
|
333
178
|
}
|
|
334
179
|
|
|
335
180
|
function toolExecutionLabel(toolName: string | undefined, args: unknown): string {
|
|
@@ -344,20 +189,26 @@ function toolcallLabel(rawArgs: string): string | undefined {
|
|
|
344
189
|
try {
|
|
345
190
|
const args = JSON.parse(trimmed) as Record<string, unknown>;
|
|
346
191
|
const command = args.command;
|
|
347
|
-
if (typeof command === "string" && command.trim()) return `bash: ${
|
|
192
|
+
if (typeof command === "string" && command.trim()) return `bash: ${normalizeInline(command)}`;
|
|
348
193
|
const filePath = args.path;
|
|
349
|
-
if (typeof filePath === "string" && filePath.trim()) return `file: ${path.basename(filePath.trim())}`;
|
|
194
|
+
if (typeof filePath === "string" && filePath.trim()) return `file: ${normalizeInline(path.basename(filePath.trim()))}`;
|
|
350
195
|
const subject = args.subject;
|
|
351
|
-
if (typeof subject === "string" && subject.trim()) return `message: ${
|
|
196
|
+
if (typeof subject === "string" && subject.trim()) return `message: ${normalizeInline(subject)}`;
|
|
352
197
|
const query = args.query;
|
|
353
|
-
if (typeof query === "string" && query.trim()) return `search: ${
|
|
198
|
+
if (typeof query === "string" && query.trim()) return `search: ${normalizeInline(query)}`;
|
|
199
|
+
const to = args.to;
|
|
200
|
+
if (typeof to === "string" && to.trim() && typeof args.subject === "string") return `send: ${normalizeInline(args.subject)}`;
|
|
354
201
|
} catch {
|
|
355
202
|
// Incomplete JSON mid-stream — retry on the next delta.
|
|
356
203
|
}
|
|
357
204
|
return undefined;
|
|
358
205
|
}
|
|
359
206
|
|
|
360
|
-
function
|
|
207
|
+
function normalizeInline(text: string): string {
|
|
208
|
+
return text.replace(/\s+/g, " ").trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function applyStreamLine(state: StreamState, line: string): boolean {
|
|
361
212
|
if (!line.trim()) return false;
|
|
362
213
|
let event: JsonEvent;
|
|
363
214
|
try {
|
|
@@ -365,30 +216,21 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
365
216
|
} catch {
|
|
366
217
|
return false;
|
|
367
218
|
}
|
|
368
|
-
if (event.type === "message_start" && event.message?.role === "assistant") {
|
|
369
|
-
state.thinking = "";
|
|
370
|
-
state.activeTool = undefined;
|
|
371
|
-
state.activeToolCallId = undefined;
|
|
372
|
-
state.activeTools.clear();
|
|
373
|
-
return true;
|
|
374
|
-
}
|
|
375
219
|
if (event.type === "tool_execution_start") {
|
|
376
|
-
|
|
220
|
+
state.activeTools.set(event.toolCallId ?? `tool-${state.activeTools.size}`, toolExecutionLabel(event.toolName, event.args));
|
|
221
|
+
state.activeTool = [...state.activeTools.values()].at(-1);
|
|
377
222
|
return true;
|
|
378
223
|
}
|
|
379
224
|
if (event.type === "tool_execution_end") {
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return false;
|
|
225
|
+
if (event.toolCallId) state.activeTools.delete(event.toolCallId);
|
|
226
|
+
else state.activeTools.clear();
|
|
227
|
+
state.activeTool = [...state.activeTools.values()].at(-1);
|
|
228
|
+
return true;
|
|
385
229
|
}
|
|
386
230
|
if (event.type !== "message_update") {
|
|
387
231
|
if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
|
|
388
232
|
state.turns++;
|
|
389
|
-
state
|
|
390
|
-
state.activeToolCallId = undefined;
|
|
391
|
-
state.activeTools.clear();
|
|
233
|
+
clearActiveTools(state);
|
|
392
234
|
state.thinking = "";
|
|
393
235
|
if (event.message.stopReason === "stop") state.finalResponse = true;
|
|
394
236
|
const parts = extractTextContent(event.message.content, "");
|
|
@@ -396,12 +238,12 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
396
238
|
const u = event.message.usage;
|
|
397
239
|
if (u) {
|
|
398
240
|
state.usage = {
|
|
399
|
-
input: u.input ?? 0,
|
|
400
|
-
output: u.output ?? 0,
|
|
401
|
-
cacheRead: u.cacheRead ?? 0,
|
|
402
|
-
cacheWrite: u.cacheWrite ?? 0,
|
|
403
|
-
totalTokens: u.totalTokens ?? 0,
|
|
404
|
-
cost: u.cost?.total ?? 0,
|
|
241
|
+
input: (state.usage?.input ?? 0) + (u.input ?? 0),
|
|
242
|
+
output: (state.usage?.output ?? 0) + (u.output ?? 0),
|
|
243
|
+
cacheRead: (state.usage?.cacheRead ?? 0) + (u.cacheRead ?? 0),
|
|
244
|
+
cacheWrite: (state.usage?.cacheWrite ?? 0) + (u.cacheWrite ?? 0),
|
|
245
|
+
totalTokens: (state.usage?.totalTokens ?? 0) + (u.totalTokens ?? 0),
|
|
246
|
+
cost: (state.usage?.cost ?? 0) + (u.cost?.total ?? 0),
|
|
405
247
|
};
|
|
406
248
|
}
|
|
407
249
|
return true;
|
|
@@ -418,10 +260,7 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
418
260
|
state.thinking += sub.delta ?? "";
|
|
419
261
|
return true;
|
|
420
262
|
case "toolcall_start":
|
|
421
|
-
state
|
|
422
|
-
state.activeTool = undefined;
|
|
423
|
-
state.activeToolCallId = undefined;
|
|
424
|
-
state.activeTools.clear();
|
|
263
|
+
clearActiveTools(state);
|
|
425
264
|
return true;
|
|
426
265
|
case "toolcall_delta": {
|
|
427
266
|
state.toolcallArgs += sub.delta ?? "";
|
|
@@ -430,22 +269,19 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
430
269
|
return true;
|
|
431
270
|
}
|
|
432
271
|
case "toolcall_end":
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
state.toolcallArgs = "";
|
|
436
|
-
state.activeTool = undefined;
|
|
437
|
-
state.activeToolCallId = undefined;
|
|
438
|
-
state.activeTools.clear();
|
|
272
|
+
// Execution events own the activity label until the result arrives.
|
|
273
|
+
clearActiveTools(state);
|
|
439
274
|
return true;
|
|
440
275
|
default:
|
|
441
276
|
return false;
|
|
442
277
|
}
|
|
443
278
|
}
|
|
444
279
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
280
|
+
/** Parse the final assistant text and accumulated usage from captured stdout. */
|
|
281
|
+
export function parseTeammateOutput(stdout: string): { text: string; usage?: WorkerUsage } {
|
|
282
|
+
const state = createStreamState();
|
|
283
|
+
for (const line of stdout.split("\n")) applyStreamLine(state, line);
|
|
284
|
+
return { text: state.text.trim(), usage: state.usage };
|
|
449
285
|
}
|
|
450
286
|
|
|
451
287
|
function truncate(text: string, cap: number): string {
|
|
@@ -453,38 +289,51 @@ function truncate(text: string, cap: number): string {
|
|
|
453
289
|
return `${text.slice(0, cap)}\n... [truncated ${text.length - cap} chars]`;
|
|
454
290
|
}
|
|
455
291
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
292
|
+
// ── Resident spawn ────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/** Turn-count baseline per teammate at its most recent delivered prompt. */
|
|
295
|
+
const baselines = new Map<string, number>();
|
|
296
|
+
const streamTurns = new Map<string, number>();
|
|
297
|
+
|
|
298
|
+
export interface ResidentSpawnOptions {
|
|
299
|
+
/** Teammate name — the registry key for steering, prompting, and shutdown. */
|
|
300
|
+
workerName: string;
|
|
301
|
+
/** Optional kickoff prompt; omit to let the teammate idle immediately. */
|
|
302
|
+
description?: string;
|
|
303
|
+
model?: string;
|
|
304
|
+
/** Execution-tool allowlist; capability tools are always appended. */
|
|
305
|
+
tools?: string[];
|
|
306
|
+
env?: Record<string, string | undefined>;
|
|
307
|
+
cwd?: string;
|
|
308
|
+
onUpdate?: (update: WorkerProgressUpdate) => void;
|
|
309
|
+
onExit: (result: WorkerProcessResult) => void;
|
|
310
|
+
onError?: (error: Error) => void;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface SpawnedResident {
|
|
314
|
+
pid: number;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const WORKER_EXTENSION = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
461
318
|
|
|
462
319
|
/**
|
|
463
|
-
* Spawn
|
|
464
|
-
*
|
|
465
|
-
*
|
|
320
|
+
* Spawn one resident child Pi process in RPC mode. Returns immediately with
|
|
321
|
+
* the child pid; outcomes arrive via onExit / onError. An empty description
|
|
322
|
+
* spawns an idle teammate that waits for its first delivered prompt.
|
|
466
323
|
*/
|
|
467
|
-
export function
|
|
468
|
-
// resolvePiCli always resolves (worst case a "pi" binary on PATH); a missing
|
|
469
|
-
// binary surfaces later as a child "error" event through onError.
|
|
324
|
+
export function spawnResident(options: ResidentSpawnOptions): SpawnedResident | { error: string } {
|
|
470
325
|
const cli = resolvePiCli();
|
|
471
|
-
const workerExtension = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
472
|
-
const mode = options.mode ?? "json";
|
|
473
326
|
const args: string[] = [
|
|
474
327
|
...cli.args,
|
|
475
|
-
|
|
328
|
+
"--mode", "rpc",
|
|
476
329
|
"--no-session",
|
|
477
330
|
"--no-extensions",
|
|
478
|
-
"--extension",
|
|
479
|
-
workerExtension,
|
|
331
|
+
"--extension", WORKER_EXTENSION,
|
|
480
332
|
];
|
|
481
333
|
if (options.model) args.push("--model", options.model);
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const requestedTools = (options.tools ?? []).filter((tool) => !tool.startsWith("teammate_"));
|
|
486
|
-
const tools = [...new Set([...requestedTools, ...capabilityTools])];
|
|
487
|
-
args.push("--tools", tools.join(","));
|
|
334
|
+
const capabilityTools = ["send_message", "task_list", "task_claim", "task_submit"];
|
|
335
|
+
const requestedTools = (options.tools ?? []).filter((tool) => !capabilityTools.includes(tool));
|
|
336
|
+
args.push("--tools", [...new Set([...requestedTools, ...capabilityTools])].join(","));
|
|
488
337
|
|
|
489
338
|
let tempDir: string | undefined;
|
|
490
339
|
const cleanupTempDir = () => {
|
|
@@ -497,69 +346,51 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
497
346
|
tempDir = undefined;
|
|
498
347
|
}
|
|
499
348
|
};
|
|
500
|
-
const setupError = (error: unknown): { error: string } => ({
|
|
501
|
-
error: error instanceof Error ? error.message : String(error),
|
|
502
|
-
});
|
|
503
349
|
|
|
504
|
-
let
|
|
350
|
+
let child: ChildProcess;
|
|
505
351
|
try {
|
|
506
|
-
|
|
507
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "teammate-"));
|
|
508
|
-
const taskFile = path.join(tempDir, "task.md");
|
|
509
|
-
fs.writeFileSync(taskFile, taskText, { mode: 0o600 });
|
|
510
|
-
args.push(`@${taskFile}`);
|
|
511
|
-
} else if (mode !== "rpc") {
|
|
512
|
-
args.push(taskText);
|
|
513
|
-
}
|
|
514
|
-
} catch (error) {
|
|
515
|
-
cleanupTempDir();
|
|
516
|
-
return setupError(error);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
let child;
|
|
520
|
-
try {
|
|
521
|
-
child = spawn(cli.command, args, {
|
|
352
|
+
child = spawnPiChild(cli.command, args, {
|
|
522
353
|
cwd: options.cwd,
|
|
523
354
|
env: { ...process.env, ...options.env },
|
|
524
|
-
stdio: [
|
|
355
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
525
356
|
});
|
|
526
357
|
} catch (error) {
|
|
527
358
|
cleanupTempDir();
|
|
528
|
-
return
|
|
359
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
529
360
|
}
|
|
530
|
-
if (options.
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
child.stdin?.write(`${JSON.stringify({ type: "prompt", id: randomUUID(), message: taskText })}\n`);
|
|
361
|
+
if (options.description && options.description.trim()) {
|
|
362
|
+
writeToControlStream(child, { type: "prompt", id: randomUUID(), message: options.description });
|
|
363
|
+
baselines.set(options.workerName, 0);
|
|
534
364
|
}
|
|
365
|
+
observeWorkerClose(options.workerName, child);
|
|
366
|
+
workers.set(options.workerName, child);
|
|
535
367
|
|
|
536
368
|
const stdoutChunks: string[] = [];
|
|
537
369
|
const stderrChunks: string[] = [];
|
|
538
|
-
const streamState =
|
|
370
|
+
const streamState = createStreamState();
|
|
371
|
+
streamStates.set(options.workerName, streamState);
|
|
539
372
|
let stdoutBuffer = "";
|
|
540
373
|
let settled = false;
|
|
541
|
-
|
|
374
|
+
|
|
542
375
|
const emitProgress = () => options.onUpdate?.({
|
|
543
376
|
text: truncate(streamState.text, OUTPUT_CAP),
|
|
544
377
|
activeTool: streamState.activeTool,
|
|
545
378
|
liveThinking: truncate(streamState.thinking, OUTPUT_CAP),
|
|
546
|
-
turns: streamState.turns,
|
|
379
|
+
turns: Math.max(0, streamState.turns - (baselines.get(options.workerName) ?? 0)),
|
|
547
380
|
finalResponse: streamState.finalResponse,
|
|
548
381
|
});
|
|
382
|
+
|
|
549
383
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
550
384
|
const text = chunk.toString();
|
|
551
|
-
appendCapped(stdoutChunks, text, OUTPUT_CAP *
|
|
385
|
+
appendCapped(stdoutChunks, text, OUTPUT_CAP * 4);
|
|
552
386
|
stdoutBuffer += text;
|
|
553
387
|
const lines = stdoutBuffer.split("\n");
|
|
554
388
|
stdoutBuffer = lines.pop() ?? "";
|
|
555
389
|
let changed = false;
|
|
556
390
|
for (const line of lines) {
|
|
557
|
-
changed =
|
|
558
|
-
if (options.turnBudget && streamState.turns >= options.turnBudget && !streamState.finalResponse && !turnBudgetExceeded) {
|
|
559
|
-
turnBudgetExceeded = true;
|
|
560
|
-
void terminateChildProcess(child);
|
|
561
|
-
}
|
|
391
|
+
changed = applyStreamLine(streamState, line) || changed;
|
|
562
392
|
}
|
|
393
|
+
streamTurns.set(options.workerName, streamState.turns);
|
|
563
394
|
if (changed) emitProgress();
|
|
564
395
|
});
|
|
565
396
|
child.stderr?.on("data", (chunk: Buffer) => appendCapped(stderrChunks, chunk.toString(), OUTPUT_CAP * 2));
|
|
@@ -567,26 +398,39 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
567
398
|
child.on("error", (error) => {
|
|
568
399
|
cleanupTempDir();
|
|
569
400
|
settled = true;
|
|
570
|
-
|
|
401
|
+
// Keep the registry entry until close is observed so shutdown diagnostics
|
|
402
|
+
// can distinguish an error/exit code from a confirmed close event.
|
|
571
403
|
options.onError?.(error);
|
|
572
404
|
});
|
|
573
405
|
|
|
574
406
|
child.on("close", (code, signal) => {
|
|
575
407
|
cleanupTempDir();
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
408
|
+
if (workers.get(options.workerName) === child) workers.delete(options.workerName);
|
|
409
|
+
streamStates.delete(options.workerName);
|
|
410
|
+
baselines.delete(options.workerName);
|
|
411
|
+
streamTurns.delete(options.workerName);
|
|
412
|
+
// A spawn failure was already reported via onError (Node fires error then
|
|
413
|
+
// close) — do not double-report through onExit.
|
|
579
414
|
if (settled) return;
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
415
|
+
const parsed = parseTeammateOutput(stdoutChunks.join(""));
|
|
416
|
+
options.onExit({
|
|
417
|
+
pid: child.pid ?? 0,
|
|
418
|
+
exitCode: code,
|
|
419
|
+
signal,
|
|
420
|
+
stdout: truncate(parsed.text, OUTPUT_CAP),
|
|
421
|
+
stderr: truncate(stderrChunks.join("").trim(), OUTPUT_CAP),
|
|
422
|
+
usage: parsed.usage,
|
|
423
|
+
});
|
|
588
424
|
});
|
|
589
425
|
|
|
590
426
|
return { pid: child.pid ?? 0 };
|
|
591
427
|
}
|
|
592
428
|
|
|
429
|
+
function appendCapped(chunks: string[], chunk: string, cap: number): void {
|
|
430
|
+
chunks.push(chunk);
|
|
431
|
+
let total = chunks.reduce((sum, value) => sum + value.length, 0);
|
|
432
|
+
while (total > cap && chunks.length > 1) {
|
|
433
|
+
total -= chunks.shift()?.length ?? 0;
|
|
434
|
+
}
|
|
435
|
+
if (total > cap && chunks.length === 1) chunks[0] = chunks[0].slice(-cap);
|
|
436
|
+
}
|