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