@fradser/pi-agent-teams 0.5.2 → 0.6.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/follow-up-queue.md +17 -0
- package/.memory/node-terminal-followups.md +18 -0
- package/.memory/team-centric-design.md +21 -0
- package/README.md +99 -62
- package/agents/observer.md +1 -1
- package/agents/reviewer.md +4 -2
- package/agents/specialist.md +1 -1
- package/agents/worker.md +3 -3
- package/package.json +3 -3
- package/src/activity.ts +86 -0
- package/src/agents.ts +58 -21
- package/src/console-viewport.ts +2 -1
- package/src/follow-up-queue.ts +237 -0
- package/src/guidance.ts +81 -57
- package/src/index.ts +119 -38
- package/src/spawner.ts +217 -393
- package/src/state.ts +253 -568
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +946 -0
- package/src/tools.ts +181 -118
- package/src/types.ts +157 -155
- package/src/ui.ts +327 -268
- package/src/worker.ts +172 -31
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/src/run-machine.ts +0 -573
- package/src/terminal.ts +0 -34
package/src/spawner.ts
CHANGED
|
@@ -1,75 +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 {
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
11
14
|
import * as fs from "node:fs";
|
|
12
|
-
import * as os from "node:os";
|
|
13
15
|
import * as path from "node:path";
|
|
14
16
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import {
|
|
16
|
-
|
|
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";
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
* Build the one-task execution prompt for a spawned worker.
|
|
20
|
-
*
|
|
21
|
-
* A worker executes one bounded task run. It reports to the leader throughout
|
|
22
|
-
* the run and then exits. The parent independently drains the worker outbox.
|
|
23
|
-
*/
|
|
24
|
-
export function buildAutonomousPrompt(opts: {
|
|
25
|
-
name: string;
|
|
26
|
-
role: string;
|
|
27
|
-
prompt: string;
|
|
28
|
-
taskId?: string;
|
|
29
|
-
timeoutSec: number;
|
|
30
|
-
}): string {
|
|
31
|
-
const taskLine = `\nAssigned task: [${opts.taskId}].\nWhen finished, you MUST call teammate_message with status="completed" to submit your FULL final deliverable.`;
|
|
32
|
-
|
|
33
|
-
return `You are a FULLY AUTONOMOUS teammate named "${opts.name}" (agent: ${opts.role}) in a pi multi-agent team run.
|
|
34
|
-
|
|
35
|
-
=== ROLE PROMPT ===
|
|
36
|
-
${opts.prompt}
|
|
37
|
-
${taskLine}
|
|
38
|
-
|
|
39
|
-
YOUR ROLE IN THIS RUN:
|
|
40
|
-
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.
|
|
41
|
-
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.
|
|
42
|
-
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.
|
|
43
|
-
4. The hard wall-clock cap is ${opts.timeoutSec}s — manage your time budget, avoid unnecessary exploration, and deliver your final message before the deadline.
|
|
44
|
-
|
|
45
|
-
BOUND CAPABILITIES:
|
|
46
|
-
- teammate_message reports progress or a final deliverable to the team leader.`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface SpawnPiWorkerOptions {
|
|
50
|
-
/** Worker identity — used to register the child so the panel can interrupt/stop it. */
|
|
51
|
-
workerName?: string;
|
|
52
|
-
/** Task description — the actual work the worker executes. */
|
|
53
|
-
description: string;
|
|
54
|
-
/** Optional model pattern for the child (e.g. "anthropic/claude-sonnet-4"). */
|
|
55
|
-
model?: string;
|
|
56
|
-
/** Optional execution-tool allowlist for the child. Capability tools are always appended. */
|
|
57
|
-
tools?: string[];
|
|
58
|
-
/** Environment additions bound to this worker process. */
|
|
59
|
-
env?: Record<string, string | undefined>;
|
|
60
|
-
/** Working directory for the child (defaults to the parent's cwd). */
|
|
61
|
-
cwd?: string;
|
|
62
|
-
/** Kill the worker after this many milliseconds (undefined = no timeout). */
|
|
63
|
-
timeoutMs?: number;
|
|
64
|
-
/** Output parsing mode. "json" (default) parses usage from JSONL output. */
|
|
65
|
-
mode?: "json" | "text";
|
|
66
|
-
/** Called whenever JSON-mode worker output reveals live model or tool activity. */
|
|
67
|
-
onUpdate?: (update: WorkerProgressUpdate) => void;
|
|
68
|
-
/** Called once with the captured output when the child exits. */
|
|
69
|
-
onExit: (result: WorkerProcessResult) => void;
|
|
70
|
-
/** Called when the child could not be spawned at all. */
|
|
71
|
-
onError?: (error: Error) => void;
|
|
72
|
-
}
|
|
27
|
+
const OUTPUT_CAP = 16_000;
|
|
73
28
|
|
|
74
29
|
export interface WorkerProcessResult {
|
|
75
30
|
pid: number;
|
|
@@ -78,220 +33,109 @@ export interface WorkerProcessResult {
|
|
|
78
33
|
stdout: string;
|
|
79
34
|
stderr: string;
|
|
80
35
|
usage?: WorkerUsage;
|
|
81
|
-
timedOut: boolean;
|
|
82
36
|
}
|
|
83
37
|
|
|
84
|
-
/** Live state extracted from a
|
|
38
|
+
/** Live state extracted from a teammate's RPC output stream. */
|
|
85
39
|
export interface WorkerProgressUpdate {
|
|
86
40
|
text: string;
|
|
87
41
|
activeTool?: string;
|
|
88
42
|
liveThinking?: string;
|
|
43
|
+
/** Assistant turns observed in the current wake-up sequence. */
|
|
89
44
|
turns: number;
|
|
45
|
+
/** True after the current sequence's final response. */
|
|
90
46
|
finalResponse?: boolean;
|
|
91
47
|
}
|
|
92
48
|
|
|
93
|
-
/** A
|
|
94
|
-
export function
|
|
95
|
-
return result.exitCode === 0 && result.signal === null
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** A reported completion remains successful when the harness closes it or teardown observes a signal/timeout. */
|
|
99
|
-
export function isCompletedWorkerExit(
|
|
100
|
-
result: Pick<WorkerProcessResult, "exitCode" | "signal" | "timedOut">,
|
|
101
|
-
reportedCompleted = false,
|
|
102
|
-
): boolean {
|
|
103
|
-
return isSuccessfulWorkerExit(result) || reportedCompleted;
|
|
49
|
+
/** A crashed teammate is one that closed without a normal zero exit. */
|
|
50
|
+
export function isCleanExit(result: Pick<WorkerProcessResult, "exitCode" | "signal">): boolean {
|
|
51
|
+
return result.exitCode === 0 && result.signal === null;
|
|
104
52
|
}
|
|
105
53
|
|
|
106
|
-
|
|
107
|
-
pid: number;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Bounded grace period before a cancellation escalates from SIGTERM to SIGKILL. */
|
|
111
|
-
const CANCEL_GRACE_MS = 5_000;
|
|
112
|
-
/** Give a terminally reported worker a short chance to exit cleanly. */
|
|
113
|
-
export const POST_REPORT_GRACE_MS = 10_000;
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Defers a close finalizer while the leader confirms a cancellation request.
|
|
117
|
-
* A run ID is unique to one child process, so an old close event cannot affect
|
|
118
|
-
* a later task run.
|
|
119
|
-
*/
|
|
120
|
-
export class CancellationIntents {
|
|
121
|
-
private readonly finalizers = new Map<string, Array<(cancelled: boolean) => void>>();
|
|
122
|
-
|
|
123
|
-
begin(spawnId: string): boolean {
|
|
124
|
-
if (this.finalizers.has(spawnId)) return false;
|
|
125
|
-
this.finalizers.set(spawnId, []);
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
has(spawnId: string): boolean {
|
|
130
|
-
return this.finalizers.has(spawnId);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
defer(spawnId: string, finalize: (cancelled: boolean) => void): boolean {
|
|
134
|
-
const pending = this.finalizers.get(spawnId);
|
|
135
|
-
if (!pending) return false;
|
|
136
|
-
pending.push(finalize);
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
resolve(spawnId: string, cancelled: boolean): boolean {
|
|
141
|
-
const pending = this.finalizers.get(spawnId);
|
|
142
|
-
if (!pending) return false;
|
|
143
|
-
try {
|
|
144
|
-
for (const finalize of pending) finalize(cancelled);
|
|
145
|
-
} finally {
|
|
146
|
-
this.finalizers.delete(spawnId);
|
|
147
|
-
}
|
|
148
|
-
return true;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
54
|
+
// ── Process registry ──────────────────────────────────────────────
|
|
151
55
|
|
|
152
|
-
/** Live
|
|
153
|
-
const workers = new Map<string,
|
|
56
|
+
/** Live children by teammate name — powers steering, prompting, and shutdown. */
|
|
57
|
+
const workers = new Map<string, ChildProcess>();
|
|
58
|
+
const closedWorkers = new WeakSet<ChildProcess>();
|
|
154
59
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
});
|
|
160
65
|
}
|
|
161
66
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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;
|
|
166
73
|
}
|
|
167
74
|
|
|
168
|
-
export
|
|
169
|
-
child: ReturnType<typeof spawn>,
|
|
170
|
-
timeoutMs: number,
|
|
171
|
-
): WorkerTimeoutHandle {
|
|
172
|
-
let cancelled = false;
|
|
173
|
-
let disposed = false;
|
|
174
|
-
let timedOut = false;
|
|
175
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
176
|
-
|
|
177
|
-
const handle: WorkerTimeoutHandle = {
|
|
178
|
-
cancel() {
|
|
179
|
-
if (disposed) return;
|
|
180
|
-
cancelled = true;
|
|
181
|
-
if (timer) clearTimeout(timer);
|
|
182
|
-
disposed = true;
|
|
183
|
-
workerTimeouts.delete(child);
|
|
184
|
-
},
|
|
185
|
-
dispose() {
|
|
186
|
-
if (disposed) return;
|
|
187
|
-
if (timer) clearTimeout(timer);
|
|
188
|
-
disposed = true;
|
|
189
|
-
workerTimeouts.delete(child);
|
|
190
|
-
},
|
|
191
|
-
wasCancelled: () => cancelled,
|
|
192
|
-
didTimeout: () => timedOut,
|
|
193
|
-
};
|
|
75
|
+
export { terminateChildProcess };
|
|
194
76
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
try {
|
|
202
|
-
if (child.kill("SIGKILL")) timedOut = true;
|
|
203
|
-
} catch {
|
|
204
|
-
// A failed kill is not a timeout outcome; close/error will report the
|
|
205
|
-
// actual child result.
|
|
206
|
-
}
|
|
207
|
-
}, timeoutMs);
|
|
208
|
-
timer.unref?.();
|
|
209
|
-
workerTimeouts.set(child, handle);
|
|
210
|
-
return handle;
|
|
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> {
|
|
79
|
+
const child = workers.get(name);
|
|
80
|
+
if (!child) return false;
|
|
81
|
+
return terminateChildProcess(child, graceMs);
|
|
211
82
|
}
|
|
212
83
|
|
|
213
|
-
export function
|
|
214
|
-
|
|
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
|
+
})));
|
|
215
90
|
}
|
|
216
91
|
|
|
217
|
-
|
|
218
|
-
return new Promise((resolve) => {
|
|
219
|
-
let settled = false;
|
|
220
|
-
const onClose = () => finish(true);
|
|
221
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
222
|
-
timer.unref?.();
|
|
223
|
-
|
|
224
|
-
function finish(closed: boolean): void {
|
|
225
|
-
if (settled) return;
|
|
226
|
-
settled = true;
|
|
227
|
-
clearTimeout(timer);
|
|
228
|
-
child.off("close", onClose);
|
|
229
|
-
resolve(closed);
|
|
230
|
-
}
|
|
92
|
+
// ── Control stream ────────────────────────────────────────────────
|
|
231
93
|
|
|
232
|
-
|
|
233
|
-
|
|
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;
|
|
234
98
|
}
|
|
235
99
|
|
|
236
100
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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.
|
|
239
103
|
*/
|
|
240
|
-
export
|
|
241
|
-
cancelWorkerTimeout(child);
|
|
242
|
-
if (!isChildRunning(child)) return false;
|
|
243
|
-
|
|
244
|
-
const closedAfterTerm = waitForClose(child, graceMs);
|
|
245
|
-
try {
|
|
246
|
-
if (!child.kill("SIGTERM")) {
|
|
247
|
-
void closedAfterTerm;
|
|
248
|
-
return false;
|
|
249
|
-
}
|
|
250
|
-
} catch {
|
|
251
|
-
void closedAfterTerm;
|
|
252
|
-
return false;
|
|
253
|
-
}
|
|
254
|
-
if (await closedAfterTerm) return true;
|
|
255
|
-
if (!isChildRunning(child)) return false;
|
|
256
|
-
|
|
257
|
-
const closedAfterKill = waitForClose(child, graceMs);
|
|
258
|
-
try {
|
|
259
|
-
if (!child.kill("SIGKILL")) {
|
|
260
|
-
void closedAfterKill;
|
|
261
|
-
return false;
|
|
262
|
-
}
|
|
263
|
-
} catch {
|
|
264
|
-
void closedAfterKill;
|
|
265
|
-
return false;
|
|
266
|
-
}
|
|
267
|
-
return closedAfterKill;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** Terminate a live worker and wait until its child process has closed. */
|
|
271
|
-
export async function terminateWorker(name: string, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
|
|
104
|
+
export function deliverPrompt(name: string, message: string): boolean {
|
|
272
105
|
const child = workers.get(name);
|
|
273
106
|
if (!child) return false;
|
|
274
|
-
|
|
107
|
+
const sent = writeToControlStream(child, { type: "prompt", id: randomUUID(), message });
|
|
108
|
+
if (sent) beginSequence(name);
|
|
109
|
+
return sent;
|
|
275
110
|
}
|
|
276
111
|
|
|
277
|
-
/**
|
|
278
|
-
|
|
279
|
-
|
|
112
|
+
/** Per-name stream states so prompt delivery can reset sequence boundaries. */
|
|
113
|
+
const streamStates = new Map<string, StreamState>();
|
|
114
|
+
|
|
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);
|
|
280
124
|
}
|
|
281
125
|
|
|
282
|
-
/**
|
|
283
|
-
export
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
workers.clear();
|
|
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;
|
|
287
130
|
}
|
|
288
131
|
|
|
289
|
-
|
|
290
|
-
const OUTPUT_CAP = 16_000;
|
|
132
|
+
// ── Stream parsing ────────────────────────────────────────────────
|
|
291
133
|
|
|
292
134
|
type JsonEvent = {
|
|
293
135
|
type?: string;
|
|
136
|
+
toolCallId?: string;
|
|
294
137
|
toolName?: string;
|
|
138
|
+
args?: unknown;
|
|
295
139
|
assistantMessageEvent?: {
|
|
296
140
|
type?: string;
|
|
297
141
|
delta?: string;
|
|
@@ -311,28 +155,31 @@ type JsonEvent = {
|
|
|
311
155
|
};
|
|
312
156
|
};
|
|
313
157
|
|
|
314
|
-
|
|
315
|
-
* Parse the JSONL output of a `pi --print --mode json` worker run.
|
|
316
|
-
* Returns the final assistant text and the last reported usage.
|
|
317
|
-
*/
|
|
318
|
-
export function parseWorkerOutput(stdout: string): { text: string; usage?: WorkerUsage } {
|
|
319
|
-
const state = createWorkerStreamState();
|
|
320
|
-
for (const line of stdout.split("\n")) applyWorkerJsonLine(state, line);
|
|
321
|
-
return { text: state.text.trim(), usage: state.usage };
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
interface WorkerStreamState {
|
|
158
|
+
interface StreamState {
|
|
325
159
|
text: string;
|
|
326
160
|
thinking: string;
|
|
327
161
|
toolcallArgs: string;
|
|
328
162
|
activeTool?: string;
|
|
163
|
+
activeTools: Map<string, string>;
|
|
164
|
+
/** Lifetime count of completed assistant messages. */
|
|
329
165
|
turns: number;
|
|
330
166
|
finalResponse?: boolean;
|
|
331
167
|
usage?: WorkerUsage;
|
|
332
168
|
}
|
|
333
169
|
|
|
334
|
-
function
|
|
335
|
-
return { text: "", thinking: "", toolcallArgs: "", turns: 0 };
|
|
170
|
+
function createStreamState(): StreamState {
|
|
171
|
+
return { text: "", thinking: "", toolcallArgs: "", activeTools: new Map(), turns: 0 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clearActiveTools(state: StreamState): void {
|
|
175
|
+
state.toolcallArgs = "";
|
|
176
|
+
state.activeTool = undefined;
|
|
177
|
+
state.activeTools.clear();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function toolExecutionLabel(toolName: string | undefined, args: unknown): string {
|
|
181
|
+
const serialized = typeof args === "string" ? args : JSON.stringify(args ?? {});
|
|
182
|
+
return toolcallLabel(serialized) ?? toolName ?? "tool";
|
|
336
183
|
}
|
|
337
184
|
|
|
338
185
|
/** Human-readable label from a partially streamed tool-call argument JSON. */
|
|
@@ -342,20 +189,26 @@ function toolcallLabel(rawArgs: string): string | undefined {
|
|
|
342
189
|
try {
|
|
343
190
|
const args = JSON.parse(trimmed) as Record<string, unknown>;
|
|
344
191
|
const command = args.command;
|
|
345
|
-
if (typeof command === "string" && command.trim()) return `bash: ${
|
|
192
|
+
if (typeof command === "string" && command.trim()) return `bash: ${normalizeInline(command)}`;
|
|
346
193
|
const filePath = args.path;
|
|
347
|
-
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()))}`;
|
|
348
195
|
const subject = args.subject;
|
|
349
|
-
if (typeof subject === "string" && subject.trim()) return `message: ${
|
|
196
|
+
if (typeof subject === "string" && subject.trim()) return `message: ${normalizeInline(subject)}`;
|
|
350
197
|
const query = args.query;
|
|
351
|
-
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)}`;
|
|
352
201
|
} catch {
|
|
353
202
|
// Incomplete JSON mid-stream — retry on the next delta.
|
|
354
203
|
}
|
|
355
204
|
return undefined;
|
|
356
205
|
}
|
|
357
206
|
|
|
358
|
-
function
|
|
207
|
+
function normalizeInline(text: string): string {
|
|
208
|
+
return text.replace(/\s+/g, " ").trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function applyStreamLine(state: StreamState, line: string): boolean {
|
|
359
212
|
if (!line.trim()) return false;
|
|
360
213
|
let event: JsonEvent;
|
|
361
214
|
try {
|
|
@@ -363,21 +216,34 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
363
216
|
} catch {
|
|
364
217
|
return false;
|
|
365
218
|
}
|
|
219
|
+
if (event.type === "tool_execution_start") {
|
|
220
|
+
state.activeTools.set(event.toolCallId ?? `tool-${state.activeTools.size}`, toolExecutionLabel(event.toolName, event.args));
|
|
221
|
+
state.activeTool = [...state.activeTools.values()].at(-1);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (event.type === "tool_execution_end") {
|
|
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;
|
|
229
|
+
}
|
|
366
230
|
if (event.type !== "message_update") {
|
|
367
231
|
if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
|
|
368
232
|
state.turns++;
|
|
233
|
+
clearActiveTools(state);
|
|
234
|
+
state.thinking = "";
|
|
369
235
|
if (event.message.stopReason === "stop") state.finalResponse = true;
|
|
370
236
|
const parts = extractTextContent(event.message.content, "");
|
|
371
237
|
if (parts.trim()) state.text = parts;
|
|
372
238
|
const u = event.message.usage;
|
|
373
239
|
if (u) {
|
|
374
240
|
state.usage = {
|
|
375
|
-
input: u.input ?? 0,
|
|
376
|
-
output: u.output ?? 0,
|
|
377
|
-
cacheRead: u.cacheRead ?? 0,
|
|
378
|
-
cacheWrite: u.cacheWrite ?? 0,
|
|
379
|
-
totalTokens: u.totalTokens ?? 0,
|
|
380
|
-
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),
|
|
381
247
|
};
|
|
382
248
|
}
|
|
383
249
|
return true;
|
|
@@ -386,14 +252,15 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
386
252
|
if (!sub) return false;
|
|
387
253
|
switch (sub.type) {
|
|
388
254
|
case "text_delta":
|
|
255
|
+
state.activeTool = undefined;
|
|
389
256
|
state.text += sub.delta ?? "";
|
|
390
257
|
return true;
|
|
391
258
|
case "thinking_delta":
|
|
259
|
+
state.activeTool = undefined;
|
|
392
260
|
state.thinking += sub.delta ?? "";
|
|
393
261
|
return true;
|
|
394
262
|
case "toolcall_start":
|
|
395
|
-
state
|
|
396
|
-
state.activeTool = undefined;
|
|
263
|
+
clearActiveTools(state);
|
|
397
264
|
return true;
|
|
398
265
|
case "toolcall_delta": {
|
|
399
266
|
state.toolcallArgs += sub.delta ?? "";
|
|
@@ -401,23 +268,20 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
|
|
|
401
268
|
if (label) state.activeTool = label;
|
|
402
269
|
return true;
|
|
403
270
|
}
|
|
404
|
-
case "toolcall_end":
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
// fall back to the bare tool name only when no label could be parsed.
|
|
408
|
-
if (tc?.name && !state.activeTool) state.activeTool = tc.name;
|
|
409
|
-
state.toolcallArgs = "";
|
|
271
|
+
case "toolcall_end":
|
|
272
|
+
// Execution events own the activity label until the result arrives.
|
|
273
|
+
clearActiveTools(state);
|
|
410
274
|
return true;
|
|
411
|
-
}
|
|
412
275
|
default:
|
|
413
276
|
return false;
|
|
414
277
|
}
|
|
415
278
|
}
|
|
416
279
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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 };
|
|
421
285
|
}
|
|
422
286
|
|
|
423
287
|
function truncate(text: string, cap: number): string {
|
|
@@ -425,89 +289,51 @@ function truncate(text: string, cap: number): string {
|
|
|
425
289
|
return `${text.slice(0, cap)}\n... [truncated ${text.length - cap} chars]`;
|
|
426
290
|
}
|
|
427
291
|
|
|
428
|
-
|
|
429
|
-
/** Command to execute — either a runtime (node/bun) or a `pi` binary. */
|
|
430
|
-
command: string;
|
|
431
|
-
/** Leading args: the CLI script path when running via a runtime, else empty. */
|
|
432
|
-
args: string[];
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** Whether a file is the pi coding-agent CLI script (verified against its package manifest). */
|
|
436
|
-
function isPiPackageScript(filePath: string): boolean {
|
|
437
|
-
try {
|
|
438
|
-
const resolved = fs.realpathSync(filePath);
|
|
439
|
-
if (!/\.(mjs|cjs|js)$/.test(resolved)) return false;
|
|
440
|
-
let dir = path.dirname(resolved);
|
|
441
|
-
while (dir !== path.dirname(dir)) {
|
|
442
|
-
const pkgPath = path.join(dir, "package.json");
|
|
443
|
-
if (fs.existsSync(pkgPath)) {
|
|
444
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
|
|
445
|
-
return pkg.name === "@earendil-works/pi-coding-agent";
|
|
446
|
-
}
|
|
447
|
-
dir = path.dirname(dir);
|
|
448
|
-
}
|
|
449
|
-
} catch {
|
|
450
|
-
// Unreadable paths are simply not candidates.
|
|
451
|
-
}
|
|
452
|
-
return false;
|
|
453
|
-
}
|
|
292
|
+
// ── Resident spawn ────────────────────────────────────────────────
|
|
454
293
|
|
|
455
|
-
/**
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
* Resolution order:
|
|
459
|
-
* 1. `process.argv[1]` — the current Pi process entry, verified against the
|
|
460
|
-
* package manifest (avoids mistaking unrelated scripts for the CLI).
|
|
461
|
-
* 2. The installed `@earendil-works/pi-coding-agent` package's `dist/cli.js`.
|
|
462
|
-
* 3. A `pi` binary on PATH (best effort).
|
|
463
|
-
*/
|
|
464
|
-
export function resolvePiCli(): PiCliResolution {
|
|
465
|
-
const argv1 = process.argv[1];
|
|
466
|
-
if (argv1 && isPiPackageScript(argv1)) {
|
|
467
|
-
return { command: process.execPath, args: [path.resolve(argv1)] };
|
|
468
|
-
}
|
|
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>();
|
|
469
297
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
+
}
|
|
480
312
|
|
|
481
|
-
|
|
313
|
+
export interface SpawnedResident {
|
|
314
|
+
pid: number;
|
|
482
315
|
}
|
|
483
316
|
|
|
317
|
+
const WORKER_EXTENSION = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
318
|
+
|
|
484
319
|
/**
|
|
485
|
-
* Spawn
|
|
486
|
-
*
|
|
487
|
-
*
|
|
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.
|
|
488
323
|
*/
|
|
489
|
-
export function
|
|
490
|
-
// resolvePiCli always resolves (worst case a "pi" binary on PATH); a missing
|
|
491
|
-
// binary surfaces later as a child "error" event through onError.
|
|
324
|
+
export function spawnResident(options: ResidentSpawnOptions): SpawnedResident | { error: string } {
|
|
492
325
|
const cli = resolvePiCli();
|
|
493
|
-
const workerExtension = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
494
326
|
const args: string[] = [
|
|
495
327
|
...cli.args,
|
|
496
|
-
"--
|
|
497
|
-
"--mode",
|
|
498
|
-
"json",
|
|
328
|
+
"--mode", "rpc",
|
|
499
329
|
"--no-session",
|
|
500
330
|
"--no-extensions",
|
|
501
|
-
"--extension",
|
|
502
|
-
workerExtension,
|
|
331
|
+
"--extension", WORKER_EXTENSION,
|
|
503
332
|
];
|
|
504
333
|
if (options.model) args.push("--model", options.model);
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const requestedTools = (options.tools ?? []).filter((tool) => !tool.startsWith("teammate_"));
|
|
509
|
-
const tools = [...new Set([...requestedTools, ...capabilityTools])];
|
|
510
|
-
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(","));
|
|
511
337
|
|
|
512
338
|
let tempDir: string | undefined;
|
|
513
339
|
const cleanupTempDir = () => {
|
|
@@ -520,93 +346,91 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
|
|
|
520
346
|
tempDir = undefined;
|
|
521
347
|
}
|
|
522
348
|
};
|
|
523
|
-
const setupError = (error: unknown): { error: string } => ({
|
|
524
|
-
error: error instanceof Error ? error.message : String(error),
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
try {
|
|
528
|
-
const taskText = `Task: ${options.description}`;
|
|
529
|
-
if (taskText.length > TASK_ARG_LIMIT) {
|
|
530
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "teammate-"));
|
|
531
|
-
const taskFile = path.join(tempDir, "task.md");
|
|
532
|
-
fs.writeFileSync(taskFile, taskText, { mode: 0o600 });
|
|
533
|
-
args.push(`@${taskFile}`);
|
|
534
|
-
} else {
|
|
535
|
-
args.push(taskText);
|
|
536
|
-
}
|
|
537
|
-
} catch (error) {
|
|
538
|
-
cleanupTempDir();
|
|
539
|
-
return setupError(error);
|
|
540
|
-
}
|
|
541
349
|
|
|
542
|
-
let child;
|
|
350
|
+
let child: ChildProcess;
|
|
543
351
|
try {
|
|
544
|
-
child =
|
|
352
|
+
child = spawnPiChild(cli.command, args, {
|
|
545
353
|
cwd: options.cwd,
|
|
546
354
|
env: { ...process.env, ...options.env },
|
|
547
|
-
stdio: ["
|
|
355
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
548
356
|
});
|
|
549
357
|
} catch (error) {
|
|
550
358
|
cleanupTempDir();
|
|
551
|
-
return
|
|
359
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
360
|
+
}
|
|
361
|
+
if (options.description && options.description.trim()) {
|
|
362
|
+
writeToControlStream(child, { type: "prompt", id: randomUUID(), message: options.description });
|
|
363
|
+
baselines.set(options.workerName, 0);
|
|
552
364
|
}
|
|
553
|
-
|
|
365
|
+
observeWorkerClose(options.workerName, child);
|
|
366
|
+
workers.set(options.workerName, child);
|
|
554
367
|
|
|
555
368
|
const stdoutChunks: string[] = [];
|
|
556
369
|
const stderrChunks: string[] = [];
|
|
557
|
-
const streamState =
|
|
370
|
+
const streamState = createStreamState();
|
|
371
|
+
streamStates.set(options.workerName, streamState);
|
|
558
372
|
let stdoutBuffer = "";
|
|
559
|
-
let timeoutHandle: WorkerTimeoutHandle | undefined;
|
|
560
373
|
let settled = false;
|
|
374
|
+
|
|
561
375
|
const emitProgress = () => options.onUpdate?.({
|
|
562
376
|
text: truncate(streamState.text, OUTPUT_CAP),
|
|
563
377
|
activeTool: streamState.activeTool,
|
|
564
378
|
liveThinking: truncate(streamState.thinking, OUTPUT_CAP),
|
|
565
|
-
turns: streamState.turns,
|
|
379
|
+
turns: Math.max(0, streamState.turns - (baselines.get(options.workerName) ?? 0)),
|
|
566
380
|
finalResponse: streamState.finalResponse,
|
|
567
381
|
});
|
|
568
|
-
|
|
382
|
+
|
|
383
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
569
384
|
const text = chunk.toString();
|
|
570
|
-
stdoutChunks
|
|
385
|
+
appendCapped(stdoutChunks, text, OUTPUT_CAP * 4);
|
|
571
386
|
stdoutBuffer += text;
|
|
572
387
|
const lines = stdoutBuffer.split("\n");
|
|
573
388
|
stdoutBuffer = lines.pop() ?? "";
|
|
574
389
|
let changed = false;
|
|
575
|
-
for (const line of lines)
|
|
390
|
+
for (const line of lines) {
|
|
391
|
+
changed = applyStreamLine(streamState, line) || changed;
|
|
392
|
+
}
|
|
393
|
+
streamTurns.set(options.workerName, streamState.turns);
|
|
576
394
|
if (changed) emitProgress();
|
|
577
395
|
});
|
|
578
|
-
child.stderr
|
|
579
|
-
|
|
580
|
-
if (options.timeoutMs) {
|
|
581
|
-
timeoutHandle = registerWorkerTimeout(child, options.timeoutMs);
|
|
582
|
-
}
|
|
396
|
+
child.stderr?.on("data", (chunk: Buffer) => appendCapped(stderrChunks, chunk.toString(), OUTPUT_CAP * 2));
|
|
583
397
|
|
|
584
398
|
child.on("error", (error) => {
|
|
585
|
-
timeoutHandle?.dispose();
|
|
586
399
|
cleanupTempDir();
|
|
587
400
|
settled = true;
|
|
588
|
-
|
|
401
|
+
// Keep the registry entry until close is observed so shutdown diagnostics
|
|
402
|
+
// can distinguish an error/exit code from a confirmed close event.
|
|
589
403
|
options.onError?.(error);
|
|
590
404
|
});
|
|
591
405
|
|
|
592
406
|
child.on("close", (code, signal) => {
|
|
593
|
-
const timedOut = timeoutHandle?.didTimeout() ?? false;
|
|
594
|
-
timeoutHandle?.dispose();
|
|
595
407
|
cleanupTempDir();
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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.
|
|
599
414
|
if (settled) return;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
+
});
|
|
608
424
|
});
|
|
609
425
|
|
|
610
426
|
return { pid: child.pid ?? 0 };
|
|
611
427
|
}
|
|
612
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
|
+
}
|