@fradser/pi-agent-teams 0.8.3 → 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/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
- "send_message",
63
- "task_list",
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
- export const WORKER_BUILTIN_TOOLS: readonly string[] = [
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 async function terminateTeammate(name: string, graceMs = DEFAULT_TERMINATION_GRACE_MS): Promise<boolean> {
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 false;
128
- return terminateChildProcess(child, graceMs);
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
- return child ? writeToControlStream(child, { type: "steer", message }) : false;
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 applyStreamLine(state: StreamState, line: string): boolean {
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.text += sub.delta ?? "";
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.thinking += sub.delta ?? "";
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.toolcallArgs += sub.delta ?? "";
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
- changed = applyStreamLine(streamState, line) || changed;
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 parsed = parseTeammateOutput(stdoutChunks.join(""));
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.length, 0);
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()?.length ?? 0;
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
  }