taskplane 0.28.4 → 0.28.6

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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,745 +1,833 @@
1
- /**
2
- * Agent Host — Direct-child Pi agent hosting for Runtime V2
3
- *
4
- * Spawns `pi --mode rpc` as a direct child process (no terminal multiplexer, no shell),
5
- * parses RPC JSONL events, normalizes them into RuntimeAgentEvents,
6
- * manages mailbox delivery, and produces exit summaries.
7
- *
8
- * This replaces the legacy terminal-session hosting path with
9
- * a programmatic parent-child model where the caller has full process
10
- * ownership.
11
- *
12
- * Key differences from the legacy path:
13
- * 1. No terminal-session backend — `spawn()` with `shell: false`
14
- * 2. No sidecar tailing — events flow directly to the caller via callbacks
15
- * 3. No PID-file orphan guessing — caller owns the process handle
16
- * 4. Registry integration — manifests updated on status transitions
17
- * 5. Pi CLI resolved to JS entrypoint, not .CMD shim
18
- *
19
- * @module taskplane/agent-host
20
- * @since TP-104
21
- */
22
-
23
- import { spawn, type ChildProcess } from "child_process";
24
- import {
25
- readFileSync, writeFileSync, appendFileSync, mkdirSync,
26
- existsSync, readdirSync, renameSync,
27
- } from "fs";
28
- import { join, dirname, basename, resolve } from "path";
29
- import { StringDecoder } from "string_decoder";
30
-
31
- import type {
32
- RuntimeAgentId,
33
- RuntimeAgentRole,
34
- RuntimeAgentEvent,
35
- RuntimeAgentEventType,
36
- RuntimeAgentManifest,
37
- PacketPaths,
38
- } from "./types.ts";
39
-
40
- import {
41
- createManifest,
42
- writeManifest,
43
- updateManifestStatus,
44
- buildRegistrySnapshot,
45
- writeRegistrySnapshot,
46
- } from "./process-registry.ts";
47
- import { appendMailboxAuditEvent } from "./mailbox.ts";
48
- import { resolvePiCliPath } from "./path-resolver.ts";
49
-
50
- // ── Pi CLI Resolution ────────────────────────────────────────────────
51
- // resolvePiCliPath() is imported from path-resolver.ts and re-exported below (TP-157)
52
-
53
- export { resolvePiCliPath };
54
- // ── Conversation Payload Helpers (TP-111) ───────────────────────────────
55
-
56
- /** Maximum characters for conversation event text payloads. */
57
- const MAX_CONV_PAYLOAD_CHARS = 2000;
58
-
59
- /** Truncate a string to maxLen chars, appending ellipsis if truncated. */
60
- function truncatePayload(text: string, maxLen: number): string {
61
- if (text.length <= maxLen) return text;
62
- return text.slice(0, maxLen) + "…";
63
- }
64
-
65
- /**
66
- * Extract text content from a Pi RPC message_end event's message object.
67
- * Pi may return content as a string or as an array of content blocks.
68
- */
69
- function extractAssistantText(message: Record<string, unknown>): string {
70
- // Direct string content
71
- if (typeof message.content === "string") return message.content;
72
- // Array of content blocks (Anthropic format)
73
- // Guard: skip null/non-object entries to prevent TypeError on malformed streams
74
- if (Array.isArray(message.content)) {
75
- const textBlocks = message.content
76
- .filter((b: unknown): b is { type: string; text: string } =>
77
- typeof b === "object" && b !== null &&
78
- (b as any).type === "text" && typeof (b as any).text === "string")
79
- .map((b) => b.text);
80
- if (textBlocks.length > 0) return textBlocks.join("\n");
81
- }
82
- // Fallback: try text field
83
- if (typeof message.text === "string") return message.text;
84
- return "";
85
- }
86
-
87
- // ── Types ────────────────────────────────────────────────────────────
88
-
89
- /**
90
- * Options for spawning an agent via the direct host.
91
- *
92
- * @since TP-104
93
- */
94
- export interface AgentHostOptions {
95
- /** Stable agent identity */
96
- agentId: RuntimeAgentId;
97
- /** Agent role */
98
- role: RuntimeAgentRole;
99
- /** Batch ID this agent belongs to */
100
- batchId: string;
101
- /** Lane number (null for merge agents) */
102
- laneNumber: number | null;
103
- /** Task ID being executed (null before first assignment) */
104
- taskId: string | null;
105
- /** Repo ID the agent is operating in */
106
- repoId: string;
107
- /** Working directory for the Pi process */
108
- cwd: string;
109
- /** User prompt content */
110
- prompt: string;
111
- /** Optional system prompt content */
112
- systemPrompt?: string;
113
- /** Model identifier (e.g., "anthropic/claude-sonnet-4-20250514") */
114
- model?: string;
115
- /** Comma-separated tool list */
116
- tools?: string;
117
- /** Thinking mode override */
118
- thinking?: string;
119
- /** Extension paths to load */
120
- extensions?: string[];
121
- /** Mailbox directory for steering (null = no mailbox) */
122
- mailboxDir?: string | null;
123
- /** Steering-pending JSONL path (TP-090, worker-only) */
124
- steeringPendingPath?: string | null;
125
- /** Path to persist normalized events JSONL */
126
- eventsPath?: string | null;
127
- /** Path to write exit summary JSON */
128
- exitSummaryPath?: string | null;
129
- /** Timeout in milliseconds (0 = no timeout) */
130
- timeoutMs?: number;
131
- /** Delay in ms before closing stdin after agent_end (default: 100) */
132
- closeDelayMs?: number;
133
- /** State root for process registry (null = no registry integration) */
134
- stateRoot?: string | null;
135
- /** Packet paths for registry manifest (null for merge agents) */
136
- packet?: PacketPaths | null;
137
- /** Extra environment variables for the child process */
138
- env?: Record<string, string>;
139
- /**
140
- * Callback invoked when agent_end fires, before stdin is closed.
141
- * Receives the last assistant message text.
142
- * Return a string to send as a new prompt (re-prompt the agent),
143
- * or null to close the session normally.
144
- *
145
- * @since TP-172
146
- */
147
- onPrematureExit?: (assistantMessage: string) => Promise<string | null>;
148
- /**
149
- * Maximum number of exit interceptions before forcing session close.
150
- * Prevents infinite loops where the callback always returns a new prompt.
151
- * Default: 2
152
- *
153
- * @since TP-172
154
- */
155
- maxExitInterceptions?: number;
156
- }
157
-
158
- /**
159
- * Accumulated telemetry from a completed agent session.
160
- *
161
- * @since TP-104
162
- */
163
- export interface AgentHostResult {
164
- /** Process exit code (null if killed by signal) */
165
- exitCode: number | null;
166
- /** Signal that killed the process (null if exited normally) */
167
- signal: string | null;
168
- /** Wall-clock duration in milliseconds */
169
- durationMs: number;
170
- /** Whether the process was killed by the caller */
171
- killed: boolean;
172
- /** Total input tokens */
173
- inputTokens: number;
174
- /** Total output tokens */
175
- outputTokens: number;
176
- /** Cache read tokens */
177
- cacheReadTokens: number;
178
- /** Cache write tokens */
179
- cacheWriteTokens: number;
180
- /** Cumulative cost in USD */
181
- costUsd: number;
182
- /** Number of tool calls */
183
- toolCalls: number;
184
- /** Last tool call description */
185
- lastTool: string;
186
- /** Number of auto-retries */
187
- retries: number;
188
- /** Number of auto-compactions */
189
- compactions: number;
190
- /** Authoritative context usage from Pi */
191
- contextUsage: { tokens: number; contextWindow: number; percent: number } | null;
192
- /** Final error message (null if clean exit) */
193
- error: string | null;
194
- /** Whether agent_end was received */
195
- agentEnded: boolean;
196
- /** Captured stderr tail (last 2KB) */
197
- stderrTail: string;
198
- }
199
-
200
- /**
201
- * Callback for normalized agent events.
202
- *
203
- * @since TP-104
204
- */
205
- export type AgentEventCallback = (event: RuntimeAgentEvent) => void;
206
-
207
- /**
208
- * Callback for telemetry updates (called on each message_end).
209
- *
210
- * @since TP-104
211
- */
212
- export type AgentTelemetryCallback = (result: Partial<AgentHostResult>) => void;
213
-
214
- // ── JSONL Helpers ────────────────────────────────────────────────────
215
-
216
- const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply", "escalate"]);
217
-
218
- function isValidMailboxMessage(obj: any): boolean {
219
- if (!obj || typeof obj !== "object") return false;
220
- return (
221
- typeof obj.id === "string" &&
222
- typeof obj.batchId === "string" &&
223
- typeof obj.from === "string" &&
224
- typeof obj.to === "string" &&
225
- typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) &&
226
- typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) &&
227
- typeof obj.content === "string"
228
- );
229
- }
230
-
231
- // ── Core Host Function ───────────────────────────────────────────────
232
-
233
- /**
234
- * Spawn and manage a Pi agent as a direct child process.
235
- *
236
- * Returns a promise that resolves with the full session result when
237
- * the agent exits, plus a kill function for early termination.
238
- *
239
- * @param opts - Agent host options
240
- * @param onEvent - Optional callback for normalized events
241
- * @param onTelemetry - Optional callback for telemetry updates
242
- * @returns Object with promise (resolves on exit) and kill function
243
- *
244
- * @since TP-104
245
- */
246
- export function spawnAgent(
247
- opts: AgentHostOptions,
248
- onEvent?: AgentEventCallback,
249
- onTelemetry?: AgentTelemetryCallback,
250
- ): { promise: Promise<AgentHostResult>; kill: () => void } {
251
-
252
- const cliPath = resolvePiCliPath();
253
- const closeDelayMs = opts.closeDelayMs ?? 100;
254
- const timeoutMs = opts.timeoutMs ?? 0;
255
- const maxExitInterceptions = opts.maxExitInterceptions ?? 3;
256
-
257
- // Build Pi CLI arguments
258
- const piArgs: string[] = [cliPath, "--mode", "rpc", "--no-session"];
259
- if (opts.model) piArgs.push("--model", opts.model);
260
- if (opts.tools) piArgs.push("--tools", opts.tools);
261
- if (opts.systemPrompt) piArgs.push("--system-prompt", opts.systemPrompt);
262
- // Always pass --no-extensions to prevent auto-discovery from cwd.
263
- // Explicit -e entries are still honored by pi even with --no-extensions.
264
- // This matches the fix from TP-095 that eliminated duplicate extension loading.
265
- piArgs.push("--no-extensions");
266
- if (opts.extensions && opts.extensions.length > 0) {
267
- for (const ext of opts.extensions) {
268
- piArgs.push("-e", ext);
269
- }
270
- }
271
- piArgs.push("--no-skills");
272
- if (opts.thinking) piArgs.push("--thinking", opts.thinking);
273
-
274
- // Spawn directly no shell, no terminal multiplexer
275
- const proc = spawn(process.execPath, piArgs, {
276
- shell: false,
277
- cwd: opts.cwd,
278
- stdio: ["pipe", "pipe", "pipe"],
279
- env: { ...process.env, ...(opts.env ?? {}) },
280
- });
281
-
282
- // State accumulator
283
- const startedAt = Date.now();
284
- let killed = false;
285
- let timedOut = false;
286
- let agentEnded = false;
287
- let stdinClosed = false;
288
- let assistantMessageEnds = 0;
289
- const STATS_REFRESH_EVERY_ASSISTANT_MESSAGES = 5;
290
- let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
291
- let costUsd = 0, toolCalls = 0, retries = 0, compactions = 0;
292
- let lastTool = "", error: string | null = null;
293
- let contextUsage: AgentHostResult["contextUsage"] = null;
294
- let stderrBuffer = "";
295
- const STDERR_MAX = 2048;
296
- /** Last assistant message text captured from message_end events (TP-172) */
297
- let lastAssistantMessage = "";
298
- /** Number of times exit interception has occurred (TP-172) */
299
- let exitInterceptionCount = 0;
300
- /** Whether the current turn had any tool calls (TP-172: text-only gate) */
301
- let currentTurnHadToolCalls = false;
302
-
303
- // Timeout
304
- let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
305
- if (timeoutMs > 0) {
306
- timeoutHandle = setTimeout(() => {
307
- timedOut = true;
308
- killed = true;
309
- try { proc.kill("SIGTERM"); } catch { /* ignore */ }
310
- }, timeoutMs);
311
- }
312
-
313
- const REGISTRY_REFRESH_INTERVAL_MS = 1_000;
314
- let lastRegistryRefreshAt = 0;
315
- const refreshRegistrySnapshot = (force: boolean = false) => {
316
- if (!opts.stateRoot) return;
317
- const now = Date.now();
318
- if (!force && (now - lastRegistryRefreshAt) < REGISTRY_REFRESH_INTERVAL_MS) return;
319
- try {
320
- const snapshot = buildRegistrySnapshot(opts.stateRoot, opts.batchId);
321
- writeRegistrySnapshot(opts.stateRoot, snapshot);
322
- lastRegistryRefreshAt = now;
323
- } catch { /* best effort */ }
324
- };
325
-
326
- // Registry integration: write manifest before process is considered visible
327
- if (opts.stateRoot) {
328
- const manifest = createManifest({
329
- batchId: opts.batchId,
330
- agentId: opts.agentId,
331
- role: opts.role,
332
- laneNumber: opts.laneNumber,
333
- taskId: opts.taskId,
334
- repoId: opts.repoId,
335
- pid: proc.pid ?? 0,
336
- parentPid: process.pid,
337
- cwd: opts.cwd,
338
- packet: opts.packet ?? null,
339
- });
340
- manifest.status = "running";
341
- writeManifest(opts.stateRoot, manifest);
342
- refreshRegistrySnapshot(true);
343
- }
344
-
345
- // Helper: close stdin safely with delay
346
- function closeStdin() {
347
- if (stdinClosed) return;
348
- stdinClosed = true;
349
- if (closeDelayMs > 0) {
350
- setTimeout(() => {
351
- try { proc.stdin?.end(); } catch { /* ignore */ }
352
- }, closeDelayMs);
353
- } else {
354
- try { proc.stdin?.end(); } catch { /* ignore */ }
355
- }
356
- }
357
-
358
- // Helper: emit normalized event
359
- function emitEvent(type: RuntimeAgentEventType, payload: Record<string, unknown> = {}) {
360
- const event: RuntimeAgentEvent = {
361
- batchId: opts.batchId,
362
- agentId: opts.agentId,
363
- role: opts.role,
364
- laneNumber: opts.laneNumber,
365
- taskId: opts.taskId,
366
- repoId: opts.repoId,
367
- ts: Date.now(),
368
- type,
369
- payload,
370
- };
371
- if (onEvent) onEvent(event);
372
- // Persist to events JSONL if path is provided
373
- if (opts.eventsPath) {
374
- try {
375
- mkdirSync(dirname(opts.eventsPath), { recursive: true });
376
- appendFileSync(opts.eventsPath, JSON.stringify(event) + "\n", "utf-8");
377
- } catch { /* best effort */ }
378
- }
379
- }
380
-
381
- // Helper: check mailbox and inject (own inbox + _broadcast)
382
- function checkMailbox() {
383
- if (!opts.mailboxDir || !proc.stdin || proc.stdin.destroyed) return;
384
-
385
- const expectedSessionName = basename(opts.mailboxDir);
386
- const expectedBatchId = basename(dirname(opts.mailboxDir));
387
-
388
- // Collect messages from own inbox AND broadcast inbox
389
- const inboxDirs: Array<{ dir: string; isBroadcast: boolean }> = [
390
- { dir: join(opts.mailboxDir, "inbox"), isBroadcast: false },
391
- ];
392
- // TP-106: Also check _broadcast/inbox for broadcast messages
393
- const broadcastInbox = join(dirname(opts.mailboxDir), "_broadcast", "inbox");
394
- if (existsSync(broadcastInbox)) {
395
- inboxDirs.push({ dir: broadcastInbox, isBroadcast: true });
396
- }
397
-
398
- for (const { dir: inboxDir, isBroadcast } of inboxDirs) {
399
- if (!existsSync(inboxDir)) continue;
400
-
401
- let entries: string[];
402
- try { entries = readdirSync(inboxDir); } catch { continue; }
403
-
404
- const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")).sort();
405
- if (msgFiles.length === 0) continue;
406
-
407
- const ackDir = join(opts.mailboxDir, "ack");
408
-
409
- for (const filename of msgFiles) {
410
- try {
411
- const raw = readFileSync(join(inboxDir, filename), "utf-8");
412
- const msg = JSON.parse(raw);
413
- if (!isValidMailboxMessage(msg)) continue;
414
- if (msg.batchId !== expectedBatchId) continue;
415
- // Validate 'to' field: own inbox requires exact match, broadcast accepts "_broadcast"
416
- if (!isBroadcast && msg.to !== expectedSessionName) continue;
417
- if (isBroadcast && msg.to !== "_broadcast") continue;
418
-
419
- mkdirSync(ackDir, { recursive: true });
420
- const ackPath = join(ackDir, filename);
421
- // Broadcast fan-out: if this agent already acked this broadcast message,
422
- // skip to avoid duplicate delivery while preserving message for peers.
423
- if (isBroadcast && existsSync(ackPath)) continue;
424
-
425
- proc.stdin.write(JSON.stringify({ type: "steer", message: msg.content }) + "\n");
426
-
427
- if (isBroadcast) {
428
- // Do NOT remove the shared broadcast inbox file. Persist a per-agent
429
- // ack marker so all agents can consume the same broadcast exactly once.
430
- try { writeFileSync(ackPath, raw, "utf-8"); } catch { /* best effort */ }
431
- } else {
432
- try { renameSync(join(inboxDir, filename), ackPath); } catch { /* race ok */ }
433
- }
434
-
435
- emitEvent("message_delivered", { messageId: msg.id, content: msg.content, broadcast: isBroadcast });
436
- if (opts.stateRoot) {
437
- appendMailboxAuditEvent(opts.stateRoot, expectedBatchId, {
438
- type: "message_delivered",
439
- from: msg.from,
440
- to: isBroadcast ? expectedSessionName : msg.to,
441
- messageId: msg.id,
442
- messageType: msg.type,
443
- contentPreview: msg.content.slice(0, 200),
444
- broadcast: isBroadcast,
445
- });
446
- }
447
-
448
- // TP-090: steering-pending flag
449
- if (opts.steeringPendingPath) {
450
- try {
451
- appendFileSync(opts.steeringPendingPath,
452
- JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id }) + "\n", "utf-8");
453
- } catch { /* best effort */ }
454
- }
455
- } catch { /* skip malformed */ }
456
- }
457
- }
458
- }
459
-
460
- const promise = new Promise<AgentHostResult>((resolvePromise) => {
461
- let stdoutBuf = "";
462
- const decoder = new StringDecoder("utf8");
463
- let finished = false;
464
-
465
- function finish(exitCode: number | null, signal: string | null) {
466
- if (finished) return;
467
- finished = true;
468
- if (timeoutHandle) clearTimeout(timeoutHandle);
469
-
470
- const result: AgentHostResult = {
471
- exitCode,
472
- signal,
473
- durationMs: Date.now() - startedAt,
474
- killed,
475
- inputTokens,
476
- outputTokens,
477
- cacheReadTokens,
478
- cacheWriteTokens,
479
- costUsd,
480
- toolCalls,
481
- lastTool,
482
- retries,
483
- compactions,
484
- contextUsage,
485
- error,
486
- agentEnded,
487
- stderrTail: stderrBuffer.trim().slice(-STDERR_MAX),
488
- };
489
-
490
- // Write exit summary if path provided
491
- if (opts.exitSummaryPath) {
492
- try {
493
- mkdirSync(dirname(opts.exitSummaryPath), { recursive: true });
494
- const summary = {
495
- exitCode: result.exitCode,
496
- exitSignal: result.signal,
497
- tokens: (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens) > 0
498
- ? { input: inputTokens, output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens }
499
- : null,
500
- cost: costUsd > 0 ? costUsd : null,
501
- toolCalls,
502
- retries,
503
- compactions,
504
- durationSec: Math.round(result.durationMs / 1000),
505
- lastToolCall: lastTool || null,
506
- error: error || null,
507
- contextUsage: contextUsage || null,
508
- };
509
- writeFileSync(opts.exitSummaryPath, JSON.stringify(summary, null, 2) + "\n", "utf-8");
510
- } catch { /* best effort */ }
511
- }
512
-
513
- const exitEventType: RuntimeAgentEventType =
514
- timedOut ? "agent_timeout" :
515
- killed ? "agent_killed" :
516
- (exitCode === 0 && agentEnded) ? "agent_exited" :
517
- "agent_crashed";
518
- emitEvent(exitEventType, { exitCode, signal, durationMs: result.durationMs, timedOut });
519
-
520
- // Registry integration: update manifest to terminal status
521
- if (opts.stateRoot) {
522
- const terminalStatus =
523
- timedOut ? "timed_out" as const :
524
- killed ? "killed" as const :
525
- (exitCode === 0 && agentEnded) ? "exited" as const :
526
- "crashed" as const;
527
- updateManifestStatus(opts.stateRoot, opts.batchId, opts.agentId, terminalStatus);
528
- refreshRegistrySnapshot(true);
529
- }
530
-
531
- resolvePromise(result);
532
- }
533
-
534
- proc.stdout.on("data", (chunk: Buffer | string) => {
535
- stdoutBuf += typeof chunk === "string" ? chunk : decoder.write(chunk);
536
- let idx: number;
537
- while ((idx = stdoutBuf.indexOf("\n")) >= 0) {
538
- let line = stdoutBuf.slice(0, idx);
539
- stdoutBuf = stdoutBuf.slice(idx + 1);
540
- if (line.endsWith("\r")) line = line.slice(0, -1);
541
- if (!line.trim()) continue;
542
-
543
- let event: any;
544
- try { event = JSON.parse(line); } catch { continue; }
545
- if (!event || !event.type) continue;
546
-
547
- // Accumulate telemetry
548
- switch (event.type) {
549
- case "message_end": {
550
- const usage = event.message?.usage;
551
- if (usage) {
552
- inputTokens += usage.input || 0;
553
- outputTokens += usage.output || 0;
554
- cacheReadTokens += usage.cacheRead || 0;
555
- cacheWriteTokens += usage.cacheWrite || 0;
556
- if (usage.cost) {
557
- costUsd += typeof usage.cost === "object" ? (usage.cost.total || 0) : (typeof usage.cost === "number" ? usage.cost : 0);
558
- }
559
- }
560
- // TP-111: Emit assistant_message with bounded content
561
- if (event.message?.role === "assistant") {
562
- const content = extractAssistantText(event.message);
563
- if (content) {
564
- emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
565
- // TP-172: Track last assistant message for exit interception
566
- lastAssistantMessage = content;
567
- }
568
- }
569
- // Request session stats immediately on first assistant message,
570
- // then periodically at a bounded cadence to refresh context usage.
571
- if (event.message?.role === "assistant") {
572
- assistantMessageEnds += 1;
573
- if (assistantMessageEnds === 1 || assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0) {
574
- try { proc.stdin?.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); } catch { /* ignore */ }
575
- }
576
- }
577
- // Check mailbox
578
- checkMailbox();
579
- // Keep registry snapshot freshness while agent is active.
580
- refreshRegistrySnapshot(false);
581
- // Emit telemetry update
582
- if (onTelemetry) {
583
- onTelemetry({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, toolCalls, lastTool, contextUsage });
584
- }
585
- break;
586
- }
587
- case "tool_execution_start": {
588
- toolCalls++;
589
- currentTurnHadToolCalls = true;
590
- const toolName = event.toolName || "tool";
591
- const argPreview = typeof event.args === "string" ? event.args.slice(0, 300) :
592
- (event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 300) : "");
593
- lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName;
594
- // TP-111: Bounded payload only — no raw args in durable event log
595
- const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : "";
596
- emitEvent("tool_call", { tool: toolName, path: toolPath, argsPreview: argPreview });
597
- break;
598
- }
599
- case "tool_execution_end": {
600
- // TP-111: Include bounded result summary for dashboard display
601
- const toolResultSummary = typeof event.result === "string" ? event.result.slice(0, 200)
602
- : event.output ? String(event.output).slice(0, 200) : "";
603
- emitEvent("tool_result", { tool: event.toolName, summary: toolResultSummary });
604
- break;
605
- }
606
- case "auto_retry_start": {
607
- retries++;
608
- emitEvent("retry_started", { attempt: event.attempt, error: event.errorMessage || event.error });
609
- break;
610
- }
611
- case "auto_compaction_start": {
612
- compactions++;
613
- emitEvent("compaction_started", {});
614
- break;
615
- }
616
- case "response": {
617
- if (event.success === false && event.error) {
618
- error = event.error;
619
- }
620
- if (event.success === true && event.data?.contextUsage) {
621
- contextUsage = event.data.contextUsage;
622
- emitEvent("context_usage", { ...event.data.contextUsage });
623
- // Emit telemetry immediately so context % is live in dashboard
624
- if (onTelemetry) {
625
- onTelemetry({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, toolCalls, lastTool, contextUsage });
626
- }
627
- }
628
- break;
629
- }
630
- case "agent_end": {
631
- agentEnded = true;
632
- // TP-172: Exit interception intercept any exit when callback
633
- // is provided and under limit. The callback (lane-runner) decides
634
- // whether the worker made progress. We don't gate on tool calls
635
- // because workers commonly use tools (reads/greps) then exit
636
- // with a text declaration ("Now let me fix this:") without
637
- // actually making the edit.
638
- const shouldIntercept = opts.onPrematureExit
639
- && exitInterceptionCount < maxExitInterceptions;
640
- if (shouldIntercept) {
641
- exitInterceptionCount++;
642
- const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout
643
- // Wrap in Promise.resolve().then() to catch synchronous throws
644
- const interceptPromise = Promise.resolve().then(() =>
645
- opts.onPrematureExit!(lastAssistantMessage));
646
- const timeoutPromise = new Promise<null>((res) =>
647
- setTimeout(() => res(null), INTERCEPTION_TIMEOUT_MS));
648
- Promise.race([interceptPromise, timeoutPromise])
649
- .then(
650
- (newPrompt: string | null) => {
651
- if (newPrompt && !stdinClosed && proc.stdin && !proc.stdin.destroyed) {
652
- // Re-prompt the agent with supervisor guidance
653
- agentEnded = false; // Reset for the new turn
654
- currentTurnHadToolCalls = false; // Reset for new turn
655
- proc.stdin.write(JSON.stringify({ type: "prompt", message: newPrompt }) + "\n");
656
- emitEvent("exit_intercepted", {
657
- interceptionCount: exitInterceptionCount,
658
- assistantMessage: truncatePayload(lastAssistantMessage, 500),
659
- supervisorConsulted: true,
660
- action: "reprompt",
661
- newPromptPreview: truncatePayload(newPrompt, MAX_CONV_PAYLOAD_CHARS),
662
- });
663
- } else {
664
- // Callback returned null or stdin already closed — close session
665
- const reason = stdinClosed ? "stdin_closed"
666
- : newPrompt === null ? "callback_returned_null"
667
- : "unknown";
668
- emitEvent("exit_intercepted", {
669
- interceptionCount: exitInterceptionCount,
670
- assistantMessage: truncatePayload(lastAssistantMessage, 500),
671
- supervisorConsulted: true,
672
- action: "close",
673
- reason,
674
- });
675
- closeStdin();
676
- }
677
- },
678
- (err: unknown) => {
679
- // Callback rejected emit single diagnostic event and close
680
- const msg = err instanceof Error ? err.message : String(err);
681
- emitEvent("exit_intercepted", {
682
- interceptionCount: exitInterceptionCount,
683
- assistantMessage: truncatePayload(lastAssistantMessage, 500),
684
- supervisorConsulted: false,
685
- action: "close",
686
- reason: "callback_error",
687
- error: msg,
688
- });
689
- closeStdin();
690
- },
691
- );
692
- } else {
693
- // No callback, had tool calls, or interception limit reached — close normally
694
- if (opts.onPrematureExit && exitInterceptionCount >= maxExitInterceptions) {
695
- emitEvent("exit_intercepted", {
696
- interceptionCount: exitInterceptionCount,
697
- assistantMessage: truncatePayload(lastAssistantMessage, 500),
698
- supervisorConsulted: false,
699
- action: "close",
700
- reason: "max_interceptions_reached",
701
- });
702
- }
703
- closeStdin();
704
- }
705
- break;
706
- }
707
- }
708
- }
709
- });
710
-
711
- proc.stderr?.setEncoding("utf-8");
712
- proc.stderr?.on("data", (chunk: string) => {
713
- stderrBuffer += chunk;
714
- if (stderrBuffer.length > STDERR_MAX * 2) {
715
- stderrBuffer = stderrBuffer.slice(-STDERR_MAX);
716
- }
717
- });
718
-
719
- proc.on("error", (err: Error) => {
720
- error = `spawn error: ${err.message}`;
721
- finish(null, null);
722
- });
723
-
724
- proc.on("close", (code: number | null, signal: string | null) => {
725
- finish(code, signal);
726
- });
727
-
728
- // Send steering mode and prompt
729
- if (opts.mailboxDir) {
730
- proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n");
731
- }
732
- proc.stdin.write(JSON.stringify({ type: "prompt", message: opts.prompt }) + "\n");
733
-
734
- emitEvent("agent_started", { model: opts.model, cwd: opts.cwd });
735
- // TP-111: Emit prompt_sent with bounded preview
736
- emitEvent("prompt_sent", { text: truncatePayload(opts.prompt, MAX_CONV_PAYLOAD_CHARS) });
737
- });
738
-
739
- const kill = () => {
740
- killed = true;
741
- try { proc.kill("SIGTERM"); } catch { /* ignore */ }
742
- };
743
-
744
- return { promise, kill };
745
- }
1
+ /**
2
+ * Agent Host — Direct-child Pi agent hosting for Runtime V2
3
+ *
4
+ * Spawns `pi --mode rpc` as a direct child process (no terminal multiplexer, no shell),
5
+ * parses RPC JSONL events, normalizes them into RuntimeAgentEvents,
6
+ * manages mailbox delivery, and produces exit summaries.
7
+ *
8
+ * This replaces the legacy terminal-session hosting path with
9
+ * a programmatic parent-child model where the caller has full process
10
+ * ownership.
11
+ *
12
+ * Key differences from the legacy path:
13
+ * 1. No terminal-session backend — `spawn()` with `shell: false`
14
+ * 2. No sidecar tailing — events flow directly to the caller via callbacks
15
+ * 3. No PID-file orphan guessing — caller owns the process handle
16
+ * 4. Registry integration — manifests updated on status transitions
17
+ * 5. Pi CLI resolved to JS entrypoint, not .CMD shim
18
+ *
19
+ * @module taskplane/agent-host
20
+ * @since TP-104
21
+ */
22
+
23
+ import { spawn, type ChildProcess } from "child_process";
24
+ import {
25
+ readFileSync, writeFileSync, appendFileSync, mkdirSync,
26
+ existsSync, readdirSync, renameSync,
27
+ } from "fs";
28
+ import { join, dirname, basename, resolve } from "path";
29
+ import { StringDecoder } from "string_decoder";
30
+
31
+ import type {
32
+ RuntimeAgentId,
33
+ RuntimeAgentRole,
34
+ RuntimeAgentEvent,
35
+ RuntimeAgentEventType,
36
+ RuntimeAgentManifest,
37
+ PacketPaths,
38
+ } from "./types.ts";
39
+
40
+ import {
41
+ createManifest,
42
+ writeManifest,
43
+ updateManifestStatus,
44
+ buildRegistrySnapshot,
45
+ writeRegistrySnapshot,
46
+ } from "./process-registry.ts";
47
+ import { appendMailboxAuditEvent } from "./mailbox.ts";
48
+ import { resolvePiCliPath } from "./path-resolver.ts";
49
+
50
+ // ── Pi CLI Resolution ────────────────────────────────────────────────
51
+ // resolvePiCliPath() is imported from path-resolver.ts and re-exported below (TP-157)
52
+
53
+ export { resolvePiCliPath };
54
+
55
+ // ── Worker Tools Allowlist (TP-184) ─────────────────────────────────
56
+
57
+ /**
58
+ * Engine-internal tools that the orchestrator's bridge extension
59
+ * (`agent-bridge-extension.ts`) registers for every spawned worker. These
60
+ * tools are coordination primitives owned by taskplane, NOT user-facing
61
+ * capabilities, so they must be present in the worker's `--tools` allowlist
62
+ * regardless of what `taskRunner.worker.tools` is configured to.
63
+ *
64
+ * If a worker is spawned without one of these tools in its allowlist, pi's
65
+ * tool gate filters the registered tool out and the matching feature
66
+ * silently no-ops:
67
+ * - `review_step`: plan/code/test reviews never fire at
68
+ * any Review Level >= 1
69
+ * - `notify_supervisor`: worker cannot reply to supervisor
70
+ * steering messages
71
+ * - `escalate_to_supervisor`: worker cannot escalate blockers or
72
+ * ambiguity to the supervisor/operator
73
+ * - `request_segment_expansion`: multi-repo segment expansion
74
+ * unreachable (the request file IPC is
75
+ * never written)
76
+ *
77
+ * Keep this list in sync with the registrations in
78
+ * `agent-bridge-extension.ts` (lines ~137, 180, 230, 599).
79
+ *
80
+ * @see https://github.com/HenryLach/taskplane/issues/530
81
+ * @since TP-184
82
+ */
83
+ export const ENGINE_BRIDGE_TOOLS = [
84
+ "review_step",
85
+ "notify_supervisor",
86
+ "escalate_to_supervisor",
87
+ "request_segment_expansion",
88
+ ] as const;
89
+
90
+ /**
91
+ * Default user-tools portion of the worker `--tools` allowlist. This is the
92
+ * fallback used when neither `taskRunner.worker.tools` config nor the
93
+ * `TASKPLANE_WORKER_TOOLS` env var supplies a value. Engine bridge tools
94
+ * (`ENGINE_BRIDGE_TOOLS`) are appended on top by
95
+ * `buildWorkerToolsAllowlist()` at the spawn site — they are NOT part of
96
+ * this default and should not be added by callers.
97
+ *
98
+ * NOTE: This literal is duplicated in `config-schema.ts` (defaults block)
99
+ * and `types.ts` (defaults block) as well. Those modules intentionally
100
+ * keep the literal to avoid pulling agent-host's heavy imports (child
101
+ * process, fs) into pure schema/type files. If you change the default
102
+ * here, update those copies too.
103
+ *
104
+ * @since TP-184
105
+ */
106
+ export const DEFAULT_WORKER_USER_TOOLS = "read,write,edit,bash,grep,find,ls";
107
+
108
+ /**
109
+ * Build the final worker `--tools` allowlist string by combining the
110
+ * user-tools portion (from config or {@link DEFAULT_WORKER_USER_TOOLS}) with
111
+ * {@link ENGINE_BRIDGE_TOOLS} (always appended, deduplicated).
112
+ *
113
+ * Semantics:
114
+ * - `null` / `undefined` / empty / whitespace-only input → falls back to
115
+ * {@link DEFAULT_WORKER_USER_TOOLS}
116
+ * - Non-empty input → split on `,`, trim each entry, drop empties
117
+ * - All three bridge tools are appended; duplicates are dropped via Set
118
+ * - Returned string has no leading/trailing commas, no whitespace
119
+ *
120
+ * Call this exactly **once** in the spawn pipeline (currently
121
+ * `lane-runner.ts:580`) augmentation is intended to be a single,
122
+ * idempotent layer; double-application is harmless (deduplicated) but
123
+ * obscures the data flow.
124
+ *
125
+ * @see https://github.com/HenryLach/taskplane/issues/530
126
+ * @since TP-184
127
+ */
128
+ export function buildWorkerToolsAllowlist(userTools: string | undefined | null): string {
129
+ const userPart = (userTools && userTools.trim()) || DEFAULT_WORKER_USER_TOOLS;
130
+ const rawUserList = userPart.split(",").map((s) => s.trim()).filter(Boolean);
131
+ // Guard against delimiter-only / whitespace-only inputs (e.g. ",", " , ")
132
+ // that would otherwise parse to an empty list and yield bridge-tools-only
133
+ // workers with no file/shell capabilities.
134
+ const userList = rawUserList.length > 0
135
+ ? rawUserList
136
+ : DEFAULT_WORKER_USER_TOOLS.split(",").map((s) => s.trim()).filter(Boolean);
137
+ const merged = new Set<string>(userList);
138
+ for (const t of ENGINE_BRIDGE_TOOLS) merged.add(t);
139
+ return Array.from(merged).join(",");
140
+ }
141
+
142
+ // ── Conversation Payload Helpers (TP-111) ───────────────────────────────
143
+
144
+ /** Maximum characters for conversation event text payloads. */
145
+ const MAX_CONV_PAYLOAD_CHARS = 2000;
146
+
147
+ /** Truncate a string to maxLen chars, appending ellipsis if truncated. */
148
+ function truncatePayload(text: string, maxLen: number): string {
149
+ if (text.length <= maxLen) return text;
150
+ return text.slice(0, maxLen) + "…";
151
+ }
152
+
153
+ /**
154
+ * Extract text content from a Pi RPC message_end event's message object.
155
+ * Pi may return content as a string or as an array of content blocks.
156
+ */
157
+ function extractAssistantText(message: Record<string, unknown>): string {
158
+ // Direct string content
159
+ if (typeof message.content === "string") return message.content;
160
+ // Array of content blocks (Anthropic format)
161
+ // Guard: skip null/non-object entries to prevent TypeError on malformed streams
162
+ if (Array.isArray(message.content)) {
163
+ const textBlocks = message.content
164
+ .filter((b: unknown): b is { type: string; text: string } =>
165
+ typeof b === "object" && b !== null &&
166
+ (b as any).type === "text" && typeof (b as any).text === "string")
167
+ .map((b) => b.text);
168
+ if (textBlocks.length > 0) return textBlocks.join("\n");
169
+ }
170
+ // Fallback: try text field
171
+ if (typeof message.text === "string") return message.text;
172
+ return "";
173
+ }
174
+
175
+ // ── Types ────────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Options for spawning an agent via the direct host.
179
+ *
180
+ * @since TP-104
181
+ */
182
+ export interface AgentHostOptions {
183
+ /** Stable agent identity */
184
+ agentId: RuntimeAgentId;
185
+ /** Agent role */
186
+ role: RuntimeAgentRole;
187
+ /** Batch ID this agent belongs to */
188
+ batchId: string;
189
+ /** Lane number (null for merge agents) */
190
+ laneNumber: number | null;
191
+ /** Task ID being executed (null before first assignment) */
192
+ taskId: string | null;
193
+ /** Repo ID the agent is operating in */
194
+ repoId: string;
195
+ /** Working directory for the Pi process */
196
+ cwd: string;
197
+ /** User prompt content */
198
+ prompt: string;
199
+ /** Optional system prompt content */
200
+ systemPrompt?: string;
201
+ /** Model identifier (e.g., "anthropic/claude-sonnet-4-20250514") */
202
+ model?: string;
203
+ /** Comma-separated tool list */
204
+ tools?: string;
205
+ /** Thinking mode override */
206
+ thinking?: string;
207
+ /** Extension paths to load */
208
+ extensions?: string[];
209
+ /** Mailbox directory for steering (null = no mailbox) */
210
+ mailboxDir?: string | null;
211
+ /** Steering-pending JSONL path (TP-090, worker-only) */
212
+ steeringPendingPath?: string | null;
213
+ /** Path to persist normalized events JSONL */
214
+ eventsPath?: string | null;
215
+ /** Path to write exit summary JSON */
216
+ exitSummaryPath?: string | null;
217
+ /** Timeout in milliseconds (0 = no timeout) */
218
+ timeoutMs?: number;
219
+ /** Delay in ms before closing stdin after agent_end (default: 100) */
220
+ closeDelayMs?: number;
221
+ /** State root for process registry (null = no registry integration) */
222
+ stateRoot?: string | null;
223
+ /** Packet paths for registry manifest (null for merge agents) */
224
+ packet?: PacketPaths | null;
225
+ /** Extra environment variables for the child process */
226
+ env?: Record<string, string>;
227
+ /**
228
+ * Callback invoked when agent_end fires, before stdin is closed.
229
+ * Receives the last assistant message text.
230
+ * Return a string to send as a new prompt (re-prompt the agent),
231
+ * or null to close the session normally.
232
+ *
233
+ * @since TP-172
234
+ */
235
+ onPrematureExit?: (assistantMessage: string) => Promise<string | null>;
236
+ /**
237
+ * Maximum number of exit interceptions before forcing session close.
238
+ * Prevents infinite loops where the callback always returns a new prompt.
239
+ * Default: 2
240
+ *
241
+ * @since TP-172
242
+ */
243
+ maxExitInterceptions?: number;
244
+ }
245
+
246
+ /**
247
+ * Accumulated telemetry from a completed agent session.
248
+ *
249
+ * @since TP-104
250
+ */
251
+ export interface AgentHostResult {
252
+ /** Process exit code (null if killed by signal) */
253
+ exitCode: number | null;
254
+ /** Signal that killed the process (null if exited normally) */
255
+ signal: string | null;
256
+ /** Wall-clock duration in milliseconds */
257
+ durationMs: number;
258
+ /** Whether the process was killed by the caller */
259
+ killed: boolean;
260
+ /** Total input tokens */
261
+ inputTokens: number;
262
+ /** Total output tokens */
263
+ outputTokens: number;
264
+ /** Cache read tokens */
265
+ cacheReadTokens: number;
266
+ /** Cache write tokens */
267
+ cacheWriteTokens: number;
268
+ /** Cumulative cost in USD */
269
+ costUsd: number;
270
+ /** Number of tool calls */
271
+ toolCalls: number;
272
+ /** Last tool call description */
273
+ lastTool: string;
274
+ /** Number of auto-retries */
275
+ retries: number;
276
+ /** Number of auto-compactions */
277
+ compactions: number;
278
+ /** Authoritative context usage from Pi */
279
+ contextUsage: { tokens: number; contextWindow: number; percent: number } | null;
280
+ /** Final error message (null if clean exit) */
281
+ error: string | null;
282
+ /** Whether agent_end was received */
283
+ agentEnded: boolean;
284
+ /** Captured stderr tail (last 2KB) */
285
+ stderrTail: string;
286
+ }
287
+
288
+ /**
289
+ * Callback for normalized agent events.
290
+ *
291
+ * @since TP-104
292
+ */
293
+ export type AgentEventCallback = (event: RuntimeAgentEvent) => void;
294
+
295
+ /**
296
+ * Callback for telemetry updates (called on each message_end).
297
+ *
298
+ * @since TP-104
299
+ */
300
+ export type AgentTelemetryCallback = (result: Partial<AgentHostResult>) => void;
301
+
302
+ // ── JSONL Helpers ────────────────────────────────────────────────────
303
+
304
+ const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply", "escalate"]);
305
+
306
+ function isValidMailboxMessage(obj: any): boolean {
307
+ if (!obj || typeof obj !== "object") return false;
308
+ return (
309
+ typeof obj.id === "string" &&
310
+ typeof obj.batchId === "string" &&
311
+ typeof obj.from === "string" &&
312
+ typeof obj.to === "string" &&
313
+ typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) &&
314
+ typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) &&
315
+ typeof obj.content === "string"
316
+ );
317
+ }
318
+
319
+ // ── Core Host Function ───────────────────────────────────────────────
320
+
321
+ /**
322
+ * Spawn and manage a Pi agent as a direct child process.
323
+ *
324
+ * Returns a promise that resolves with the full session result when
325
+ * the agent exits, plus a kill function for early termination.
326
+ *
327
+ * @param opts - Agent host options
328
+ * @param onEvent - Optional callback for normalized events
329
+ * @param onTelemetry - Optional callback for telemetry updates
330
+ * @returns Object with promise (resolves on exit) and kill function
331
+ *
332
+ * @since TP-104
333
+ */
334
+ export function spawnAgent(
335
+ opts: AgentHostOptions,
336
+ onEvent?: AgentEventCallback,
337
+ onTelemetry?: AgentTelemetryCallback,
338
+ ): { promise: Promise<AgentHostResult>; kill: () => void } {
339
+
340
+ const cliPath = resolvePiCliPath();
341
+ const closeDelayMs = opts.closeDelayMs ?? 100;
342
+ const timeoutMs = opts.timeoutMs ?? 0;
343
+ const maxExitInterceptions = opts.maxExitInterceptions ?? 3;
344
+
345
+ // Build Pi CLI arguments
346
+ const piArgs: string[] = [cliPath, "--mode", "rpc", "--no-session"];
347
+ if (opts.model) piArgs.push("--model", opts.model);
348
+ if (opts.tools) piArgs.push("--tools", opts.tools);
349
+ if (opts.systemPrompt) piArgs.push("--system-prompt", opts.systemPrompt);
350
+ // Always pass --no-extensions to prevent auto-discovery from cwd.
351
+ // Explicit -e entries are still honored by pi even with --no-extensions.
352
+ // This matches the fix from TP-095 that eliminated duplicate extension loading.
353
+ piArgs.push("--no-extensions");
354
+ if (opts.extensions && opts.extensions.length > 0) {
355
+ for (const ext of opts.extensions) {
356
+ piArgs.push("-e", ext);
357
+ }
358
+ }
359
+ piArgs.push("--no-skills");
360
+ if (opts.thinking) piArgs.push("--thinking", opts.thinking);
361
+
362
+ // Spawn directly — no shell, no terminal multiplexer
363
+ const proc = spawn(process.execPath, piArgs, {
364
+ shell: false,
365
+ cwd: opts.cwd,
366
+ stdio: ["pipe", "pipe", "pipe"],
367
+ env: { ...process.env, ...(opts.env ?? {}) },
368
+ });
369
+
370
+ // State accumulator
371
+ const startedAt = Date.now();
372
+ let killed = false;
373
+ let timedOut = false;
374
+ let agentEnded = false;
375
+ let stdinClosed = false;
376
+ let assistantMessageEnds = 0;
377
+ const STATS_REFRESH_EVERY_ASSISTANT_MESSAGES = 5;
378
+ let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
379
+ let costUsd = 0, toolCalls = 0, retries = 0, compactions = 0;
380
+ let lastTool = "", error: string | null = null;
381
+ let contextUsage: AgentHostResult["contextUsage"] = null;
382
+ let stderrBuffer = "";
383
+ const STDERR_MAX = 2048;
384
+ /** Last assistant message text captured from message_end events (TP-172) */
385
+ let lastAssistantMessage = "";
386
+ /** Number of times exit interception has occurred (TP-172) */
387
+ let exitInterceptionCount = 0;
388
+ /** Whether the current turn had any tool calls (TP-172: text-only gate) */
389
+ let currentTurnHadToolCalls = false;
390
+
391
+ // Timeout
392
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
393
+ if (timeoutMs > 0) {
394
+ timeoutHandle = setTimeout(() => {
395
+ timedOut = true;
396
+ killed = true;
397
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
398
+ }, timeoutMs);
399
+ }
400
+
401
+ const REGISTRY_REFRESH_INTERVAL_MS = 1_000;
402
+ let lastRegistryRefreshAt = 0;
403
+ const refreshRegistrySnapshot = (force: boolean = false) => {
404
+ if (!opts.stateRoot) return;
405
+ const now = Date.now();
406
+ if (!force && (now - lastRegistryRefreshAt) < REGISTRY_REFRESH_INTERVAL_MS) return;
407
+ try {
408
+ const snapshot = buildRegistrySnapshot(opts.stateRoot, opts.batchId);
409
+ writeRegistrySnapshot(opts.stateRoot, snapshot);
410
+ lastRegistryRefreshAt = now;
411
+ } catch { /* best effort */ }
412
+ };
413
+
414
+ // Registry integration: write manifest before process is considered visible
415
+ if (opts.stateRoot) {
416
+ const manifest = createManifest({
417
+ batchId: opts.batchId,
418
+ agentId: opts.agentId,
419
+ role: opts.role,
420
+ laneNumber: opts.laneNumber,
421
+ taskId: opts.taskId,
422
+ repoId: opts.repoId,
423
+ pid: proc.pid ?? 0,
424
+ parentPid: process.pid,
425
+ cwd: opts.cwd,
426
+ packet: opts.packet ?? null,
427
+ });
428
+ manifest.status = "running";
429
+ writeManifest(opts.stateRoot, manifest);
430
+ refreshRegistrySnapshot(true);
431
+ }
432
+
433
+ // Helper: close stdin safely with delay
434
+ function closeStdin() {
435
+ if (stdinClosed) return;
436
+ stdinClosed = true;
437
+ if (closeDelayMs > 0) {
438
+ setTimeout(() => {
439
+ try { proc.stdin?.end(); } catch { /* ignore */ }
440
+ }, closeDelayMs);
441
+ } else {
442
+ try { proc.stdin?.end(); } catch { /* ignore */ }
443
+ }
444
+ }
445
+
446
+ // Helper: emit normalized event
447
+ function emitEvent(type: RuntimeAgentEventType, payload: Record<string, unknown> = {}) {
448
+ const event: RuntimeAgentEvent = {
449
+ batchId: opts.batchId,
450
+ agentId: opts.agentId,
451
+ role: opts.role,
452
+ laneNumber: opts.laneNumber,
453
+ taskId: opts.taskId,
454
+ repoId: opts.repoId,
455
+ ts: Date.now(),
456
+ type,
457
+ payload,
458
+ };
459
+ if (onEvent) onEvent(event);
460
+ // Persist to events JSONL if path is provided
461
+ if (opts.eventsPath) {
462
+ try {
463
+ mkdirSync(dirname(opts.eventsPath), { recursive: true });
464
+ appendFileSync(opts.eventsPath, JSON.stringify(event) + "\n", "utf-8");
465
+ } catch { /* best effort */ }
466
+ }
467
+ }
468
+
469
+ // Helper: check mailbox and inject (own inbox + _broadcast)
470
+ function checkMailbox() {
471
+ if (!opts.mailboxDir || !proc.stdin || proc.stdin.destroyed) return;
472
+
473
+ const expectedSessionName = basename(opts.mailboxDir);
474
+ const expectedBatchId = basename(dirname(opts.mailboxDir));
475
+
476
+ // Collect messages from own inbox AND broadcast inbox
477
+ const inboxDirs: Array<{ dir: string; isBroadcast: boolean }> = [
478
+ { dir: join(opts.mailboxDir, "inbox"), isBroadcast: false },
479
+ ];
480
+ // TP-106: Also check _broadcast/inbox for broadcast messages
481
+ const broadcastInbox = join(dirname(opts.mailboxDir), "_broadcast", "inbox");
482
+ if (existsSync(broadcastInbox)) {
483
+ inboxDirs.push({ dir: broadcastInbox, isBroadcast: true });
484
+ }
485
+
486
+ for (const { dir: inboxDir, isBroadcast } of inboxDirs) {
487
+ if (!existsSync(inboxDir)) continue;
488
+
489
+ let entries: string[];
490
+ try { entries = readdirSync(inboxDir); } catch { continue; }
491
+
492
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")).sort();
493
+ if (msgFiles.length === 0) continue;
494
+
495
+ const ackDir = join(opts.mailboxDir, "ack");
496
+
497
+ for (const filename of msgFiles) {
498
+ try {
499
+ const raw = readFileSync(join(inboxDir, filename), "utf-8");
500
+ const msg = JSON.parse(raw);
501
+ if (!isValidMailboxMessage(msg)) continue;
502
+ if (msg.batchId !== expectedBatchId) continue;
503
+ // Validate 'to' field: own inbox requires exact match, broadcast accepts "_broadcast"
504
+ if (!isBroadcast && msg.to !== expectedSessionName) continue;
505
+ if (isBroadcast && msg.to !== "_broadcast") continue;
506
+
507
+ mkdirSync(ackDir, { recursive: true });
508
+ const ackPath = join(ackDir, filename);
509
+ // Broadcast fan-out: if this agent already acked this broadcast message,
510
+ // skip to avoid duplicate delivery while preserving message for peers.
511
+ if (isBroadcast && existsSync(ackPath)) continue;
512
+
513
+ proc.stdin.write(JSON.stringify({ type: "steer", message: msg.content }) + "\n");
514
+
515
+ if (isBroadcast) {
516
+ // Do NOT remove the shared broadcast inbox file. Persist a per-agent
517
+ // ack marker so all agents can consume the same broadcast exactly once.
518
+ try { writeFileSync(ackPath, raw, "utf-8"); } catch { /* best effort */ }
519
+ } else {
520
+ try { renameSync(join(inboxDir, filename), ackPath); } catch { /* race ok */ }
521
+ }
522
+
523
+ emitEvent("message_delivered", { messageId: msg.id, content: msg.content, broadcast: isBroadcast });
524
+ if (opts.stateRoot) {
525
+ appendMailboxAuditEvent(opts.stateRoot, expectedBatchId, {
526
+ type: "message_delivered",
527
+ from: msg.from,
528
+ to: isBroadcast ? expectedSessionName : msg.to,
529
+ messageId: msg.id,
530
+ messageType: msg.type,
531
+ contentPreview: msg.content.slice(0, 200),
532
+ broadcast: isBroadcast,
533
+ });
534
+ }
535
+
536
+ // TP-090: steering-pending flag
537
+ if (opts.steeringPendingPath) {
538
+ try {
539
+ appendFileSync(opts.steeringPendingPath,
540
+ JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id }) + "\n", "utf-8");
541
+ } catch { /* best effort */ }
542
+ }
543
+ } catch { /* skip malformed */ }
544
+ }
545
+ }
546
+ }
547
+
548
+ const promise = new Promise<AgentHostResult>((resolvePromise) => {
549
+ let stdoutBuf = "";
550
+ const decoder = new StringDecoder("utf8");
551
+ let finished = false;
552
+
553
+ function finish(exitCode: number | null, signal: string | null) {
554
+ if (finished) return;
555
+ finished = true;
556
+ if (timeoutHandle) clearTimeout(timeoutHandle);
557
+
558
+ const result: AgentHostResult = {
559
+ exitCode,
560
+ signal,
561
+ durationMs: Date.now() - startedAt,
562
+ killed,
563
+ inputTokens,
564
+ outputTokens,
565
+ cacheReadTokens,
566
+ cacheWriteTokens,
567
+ costUsd,
568
+ toolCalls,
569
+ lastTool,
570
+ retries,
571
+ compactions,
572
+ contextUsage,
573
+ error,
574
+ agentEnded,
575
+ stderrTail: stderrBuffer.trim().slice(-STDERR_MAX),
576
+ };
577
+
578
+ // Write exit summary if path provided
579
+ if (opts.exitSummaryPath) {
580
+ try {
581
+ mkdirSync(dirname(opts.exitSummaryPath), { recursive: true });
582
+ const summary = {
583
+ exitCode: result.exitCode,
584
+ exitSignal: result.signal,
585
+ tokens: (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens) > 0
586
+ ? { input: inputTokens, output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens }
587
+ : null,
588
+ cost: costUsd > 0 ? costUsd : null,
589
+ toolCalls,
590
+ retries,
591
+ compactions,
592
+ durationSec: Math.round(result.durationMs / 1000),
593
+ lastToolCall: lastTool || null,
594
+ error: error || null,
595
+ contextUsage: contextUsage || null,
596
+ };
597
+ writeFileSync(opts.exitSummaryPath, JSON.stringify(summary, null, 2) + "\n", "utf-8");
598
+ } catch { /* best effort */ }
599
+ }
600
+
601
+ const exitEventType: RuntimeAgentEventType =
602
+ timedOut ? "agent_timeout" :
603
+ killed ? "agent_killed" :
604
+ (exitCode === 0 && agentEnded) ? "agent_exited" :
605
+ "agent_crashed";
606
+ emitEvent(exitEventType, { exitCode, signal, durationMs: result.durationMs, timedOut });
607
+
608
+ // Registry integration: update manifest to terminal status
609
+ if (opts.stateRoot) {
610
+ const terminalStatus =
611
+ timedOut ? "timed_out" as const :
612
+ killed ? "killed" as const :
613
+ (exitCode === 0 && agentEnded) ? "exited" as const :
614
+ "crashed" as const;
615
+ updateManifestStatus(opts.stateRoot, opts.batchId, opts.agentId, terminalStatus);
616
+ refreshRegistrySnapshot(true);
617
+ }
618
+
619
+ resolvePromise(result);
620
+ }
621
+
622
+ proc.stdout.on("data", (chunk: Buffer | string) => {
623
+ stdoutBuf += typeof chunk === "string" ? chunk : decoder.write(chunk);
624
+ let idx: number;
625
+ while ((idx = stdoutBuf.indexOf("\n")) >= 0) {
626
+ let line = stdoutBuf.slice(0, idx);
627
+ stdoutBuf = stdoutBuf.slice(idx + 1);
628
+ if (line.endsWith("\r")) line = line.slice(0, -1);
629
+ if (!line.trim()) continue;
630
+
631
+ let event: any;
632
+ try { event = JSON.parse(line); } catch { continue; }
633
+ if (!event || !event.type) continue;
634
+
635
+ // Accumulate telemetry
636
+ switch (event.type) {
637
+ case "message_end": {
638
+ const usage = event.message?.usage;
639
+ if (usage) {
640
+ inputTokens += usage.input || 0;
641
+ outputTokens += usage.output || 0;
642
+ cacheReadTokens += usage.cacheRead || 0;
643
+ cacheWriteTokens += usage.cacheWrite || 0;
644
+ if (usage.cost) {
645
+ costUsd += typeof usage.cost === "object" ? (usage.cost.total || 0) : (typeof usage.cost === "number" ? usage.cost : 0);
646
+ }
647
+ }
648
+ // TP-111: Emit assistant_message with bounded content
649
+ if (event.message?.role === "assistant") {
650
+ const content = extractAssistantText(event.message);
651
+ if (content) {
652
+ emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
653
+ // TP-172: Track last assistant message for exit interception
654
+ lastAssistantMessage = content;
655
+ }
656
+ }
657
+ // Request session stats immediately on first assistant message,
658
+ // then periodically at a bounded cadence to refresh context usage.
659
+ if (event.message?.role === "assistant") {
660
+ assistantMessageEnds += 1;
661
+ if (assistantMessageEnds === 1 || assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0) {
662
+ try { proc.stdin?.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); } catch { /* ignore */ }
663
+ }
664
+ }
665
+ // Check mailbox
666
+ checkMailbox();
667
+ // Keep registry snapshot freshness while agent is active.
668
+ refreshRegistrySnapshot(false);
669
+ // Emit telemetry update
670
+ if (onTelemetry) {
671
+ onTelemetry({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, toolCalls, lastTool, contextUsage });
672
+ }
673
+ break;
674
+ }
675
+ case "tool_execution_start": {
676
+ toolCalls++;
677
+ currentTurnHadToolCalls = true;
678
+ const toolName = event.toolName || "tool";
679
+ const argPreview = typeof event.args === "string" ? event.args.slice(0, 300) :
680
+ (event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 300) : "");
681
+ lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName;
682
+ // TP-111: Bounded payload only — no raw args in durable event log
683
+ const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : "";
684
+ emitEvent("tool_call", { tool: toolName, path: toolPath, argsPreview: argPreview });
685
+ break;
686
+ }
687
+ case "tool_execution_end": {
688
+ // TP-111: Include bounded result summary for dashboard display
689
+ const toolResultSummary = typeof event.result === "string" ? event.result.slice(0, 200)
690
+ : event.output ? String(event.output).slice(0, 200) : "";
691
+ emitEvent("tool_result", { tool: event.toolName, summary: toolResultSummary });
692
+ break;
693
+ }
694
+ case "auto_retry_start": {
695
+ retries++;
696
+ emitEvent("retry_started", { attempt: event.attempt, error: event.errorMessage || event.error });
697
+ break;
698
+ }
699
+ case "auto_compaction_start": {
700
+ compactions++;
701
+ emitEvent("compaction_started", {});
702
+ break;
703
+ }
704
+ case "response": {
705
+ if (event.success === false && event.error) {
706
+ error = event.error;
707
+ }
708
+ if (event.success === true && event.data?.contextUsage) {
709
+ contextUsage = event.data.contextUsage;
710
+ emitEvent("context_usage", { ...event.data.contextUsage });
711
+ // Emit telemetry immediately so context % is live in dashboard
712
+ if (onTelemetry) {
713
+ onTelemetry({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, toolCalls, lastTool, contextUsage });
714
+ }
715
+ }
716
+ break;
717
+ }
718
+ case "agent_end": {
719
+ agentEnded = true;
720
+ // TP-172: Exit interception — intercept any exit when callback
721
+ // is provided and under limit. The callback (lane-runner) decides
722
+ // whether the worker made progress. We don't gate on tool calls
723
+ // because workers commonly use tools (reads/greps) then exit
724
+ // with a text declaration ("Now let me fix this:") without
725
+ // actually making the edit.
726
+ const shouldIntercept = opts.onPrematureExit
727
+ && exitInterceptionCount < maxExitInterceptions;
728
+ if (shouldIntercept) {
729
+ exitInterceptionCount++;
730
+ const INTERCEPTION_TIMEOUT_MS = 120_000; // 2 minute safety timeout
731
+ // Wrap in Promise.resolve().then() to catch synchronous throws
732
+ const interceptPromise = Promise.resolve().then(() =>
733
+ opts.onPrematureExit!(lastAssistantMessage));
734
+ const timeoutPromise = new Promise<null>((res) =>
735
+ setTimeout(() => res(null), INTERCEPTION_TIMEOUT_MS));
736
+ Promise.race([interceptPromise, timeoutPromise])
737
+ .then(
738
+ (newPrompt: string | null) => {
739
+ if (newPrompt && !stdinClosed && proc.stdin && !proc.stdin.destroyed) {
740
+ // Re-prompt the agent with supervisor guidance
741
+ agentEnded = false; // Reset for the new turn
742
+ currentTurnHadToolCalls = false; // Reset for new turn
743
+ proc.stdin.write(JSON.stringify({ type: "prompt", message: newPrompt }) + "\n");
744
+ emitEvent("exit_intercepted", {
745
+ interceptionCount: exitInterceptionCount,
746
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
747
+ supervisorConsulted: true,
748
+ action: "reprompt",
749
+ newPromptPreview: truncatePayload(newPrompt, MAX_CONV_PAYLOAD_CHARS),
750
+ });
751
+ } else {
752
+ // Callback returned null or stdin already closed — close session
753
+ const reason = stdinClosed ? "stdin_closed"
754
+ : newPrompt === null ? "callback_returned_null"
755
+ : "unknown";
756
+ emitEvent("exit_intercepted", {
757
+ interceptionCount: exitInterceptionCount,
758
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
759
+ supervisorConsulted: true,
760
+ action: "close",
761
+ reason,
762
+ });
763
+ closeStdin();
764
+ }
765
+ },
766
+ (err: unknown) => {
767
+ // Callback rejected — emit single diagnostic event and close
768
+ const msg = err instanceof Error ? err.message : String(err);
769
+ emitEvent("exit_intercepted", {
770
+ interceptionCount: exitInterceptionCount,
771
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
772
+ supervisorConsulted: false,
773
+ action: "close",
774
+ reason: "callback_error",
775
+ error: msg,
776
+ });
777
+ closeStdin();
778
+ },
779
+ );
780
+ } else {
781
+ // No callback, had tool calls, or interception limit reached — close normally
782
+ if (opts.onPrematureExit && exitInterceptionCount >= maxExitInterceptions) {
783
+ emitEvent("exit_intercepted", {
784
+ interceptionCount: exitInterceptionCount,
785
+ assistantMessage: truncatePayload(lastAssistantMessage, 500),
786
+ supervisorConsulted: false,
787
+ action: "close",
788
+ reason: "max_interceptions_reached",
789
+ });
790
+ }
791
+ closeStdin();
792
+ }
793
+ break;
794
+ }
795
+ }
796
+ }
797
+ });
798
+
799
+ proc.stderr?.setEncoding("utf-8");
800
+ proc.stderr?.on("data", (chunk: string) => {
801
+ stderrBuffer += chunk;
802
+ if (stderrBuffer.length > STDERR_MAX * 2) {
803
+ stderrBuffer = stderrBuffer.slice(-STDERR_MAX);
804
+ }
805
+ });
806
+
807
+ proc.on("error", (err: Error) => {
808
+ error = `spawn error: ${err.message}`;
809
+ finish(null, null);
810
+ });
811
+
812
+ proc.on("close", (code: number | null, signal: string | null) => {
813
+ finish(code, signal);
814
+ });
815
+
816
+ // Send steering mode and prompt
817
+ if (opts.mailboxDir) {
818
+ proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n");
819
+ }
820
+ proc.stdin.write(JSON.stringify({ type: "prompt", message: opts.prompt }) + "\n");
821
+
822
+ emitEvent("agent_started", { model: opts.model, cwd: opts.cwd });
823
+ // TP-111: Emit prompt_sent with bounded preview
824
+ emitEvent("prompt_sent", { text: truncatePayload(opts.prompt, MAX_CONV_PAYLOAD_CHARS) });
825
+ });
826
+
827
+ const kill = () => {
828
+ killed = true;
829
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
830
+ };
831
+
832
+ return { promise, kill };
833
+ }