@mystilleef/pi-subagent 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { SubagentDetails } from "./types.js";
2
+ import { renderSubagentResult, type SubagentTheme } from "./ui.js";
3
+
4
+ /**
5
+ * Pi message renderer adapter for `"subagent-result"` messages.
6
+ *
7
+ * Normalizes the Pi message payload into the canonical shape expected by
8
+ * {@link renderSubagentResult}, bridging the gap between Pi's
9
+ * `MessageRenderer` contract and the internal result component.
10
+ *
11
+ * ## Rationale
12
+ *
13
+ * Pi's message renderer interface passes `content` as `unknown` — it can be
14
+ * a raw string or an array of content blocks from a tool call. The internal
15
+ * `renderSubagentResult` expects a uniform `{ type: string; text?: string }[]`
16
+ * array. This adapter handles both shapes.
17
+ *
18
+ * The first text block is extracted as `bodyOverride`. When set, it
19
+ * replaces the result body that `renderSubagentResult` would normally
20
+ * extract from `details.results[0].finalOutput`. This lets callers
21
+ * inject a pre-formatted summary (e.g., from `/run` summarization)
22
+ * while keeping the details-derived header metadata intact.
23
+ *
24
+ * The `_options` parameter is part of Pi's `MessageRenderer` contract
25
+ * but not consumed — the result card always renders at full width.
26
+ *
27
+ * @param message - Pi message with optional string/array content and details.
28
+ * `details` is cast to {@link SubagentDetails}; non-conforming payloads
29
+ * produce a fallback `"(no output)"` text block inside the callee.
30
+ * @param _options - Unused; Pi `MessageRenderer` contract.
31
+ * @param theme - TUI theme forwarded to `renderSubagentResult`.
32
+ * @returns Rendered `Component` for the subagent result card.
33
+ */
34
+ export function renderSubagentResultMessage(
35
+ message: { content?: unknown; details?: unknown },
36
+ _options: { expanded: boolean },
37
+ theme: SubagentTheme,
38
+ ) {
39
+ const content =
40
+ typeof message.content === "string"
41
+ ? [{ type: "text", text: message.content }]
42
+ : Array.isArray(message.content)
43
+ ? (message.content as { type: string; text?: string }[])
44
+ : [];
45
+ const details = message.details as SubagentDetails | undefined;
46
+ const bodyOverride = content.find((item) => item.type === "text")?.text;
47
+ return renderSubagentResult(
48
+ { content, details },
49
+ theme,
50
+ undefined,
51
+ bodyOverride,
52
+ );
53
+ }
@@ -0,0 +1,377 @@
1
+ import { type Message, StringEnum } from "@earendil-works/pi-ai";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { type Static, Type } from "typebox";
7
+ import { getCachedAgentDiscovery } from "./agent-cache.js";
8
+ import {
9
+ type AgentConfig,
10
+ type AgentScope,
11
+ discoverAgents,
12
+ type ThinkingLevel,
13
+ } from "./agents.js";
14
+ import { runSingleAgent } from "./process.js";
15
+ import {
16
+ cancelProgressState,
17
+ createProgressState,
18
+ failProgressState,
19
+ finalizeProgressState,
20
+ } from "./progress.js";
21
+ import {
22
+ createSubagentError,
23
+ getFeedbackSummaryText,
24
+ getResultDisplayText,
25
+ hasSubagentFailed,
26
+ patchProgressFromDetails,
27
+ sanitizeDetailsForDisplay,
28
+ } from "./result-details.js";
29
+ import { type RunJob, registerRunJob, removeRunJob } from "./run-registry.js";
30
+ import { formatSubagentResultForParent } from "./summary.js";
31
+ import type {
32
+ OnUpdateCallback,
33
+ SingleResult,
34
+ SubagentDetails,
35
+ SubagentToolResult,
36
+ } from "./types.js";
37
+
38
+ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
39
+ description:
40
+ 'Which agent directories to use. Default: "both" (user + project-local agents).',
41
+ default: "both",
42
+ });
43
+
44
+ export const SubagentParams = Type.Object({
45
+ agent: Type.String({
46
+ description: "Name of the agent to invoke",
47
+ }),
48
+ task: Type.Optional(
49
+ Type.String({
50
+ description: "Task to delegate. Optional for agents with defaults.",
51
+ }),
52
+ ),
53
+ agentScope: Type.Optional(AgentScopeSchema),
54
+ debug: Type.Optional(
55
+ Type.Boolean({
56
+ description:
57
+ "Internal debug option. Include full child messages in result details.",
58
+ default: false,
59
+ }),
60
+ ),
61
+ });
62
+
63
+ export type { SubagentToolResult };
64
+
65
+ type DetailsOptions = { includeMessages?: boolean; recentMessages?: Message[] };
66
+ type DetailsBuilder = (
67
+ results: SingleResult[],
68
+ options?: DetailsOptions,
69
+ ) => SubagentDetails;
70
+
71
+ function createDetailsBuilder(
72
+ agentScope: AgentScope,
73
+ projectAgentsDir: string | null,
74
+ includeDebugMessages: boolean,
75
+ ): DetailsBuilder {
76
+ return (results, options) => ({
77
+ mode: "single",
78
+ agentScope,
79
+ projectAgentsDir,
80
+ results: results.map((result) =>
81
+ sanitizeResultDetails(result, includeDebugMessages, options),
82
+ ),
83
+ });
84
+ }
85
+
86
+ function sanitizeResultDetails(
87
+ result: SingleResult,
88
+ includeDebugMessages: boolean,
89
+ options: DetailsOptions | undefined,
90
+ ): SingleResult {
91
+ const includeMessages =
92
+ includeDebugMessages && (options?.includeMessages ?? true);
93
+ const { messages, termination, progress, stderr, usage, ...core } = result;
94
+ const { contextWindowTokens, ...usageBase } = usage;
95
+
96
+ const sanitized: Record<string, unknown> = {
97
+ ...core,
98
+ stderr: includeDebugMessages ? stderr : "",
99
+ usage: { ...usageBase },
100
+ };
101
+
102
+ if (contextWindowTokens !== undefined) {
103
+ (sanitized.usage as Record<string, unknown>).contextWindowTokens =
104
+ contextWindowTokens;
105
+ }
106
+
107
+ if (progress !== undefined) {
108
+ const { activityText, lastToolPreview, ...progBase } = progress;
109
+ sanitized.progress = {
110
+ toolCalls: progBase.toolCalls.map((tc) => ({
111
+ id: tc.id,
112
+ preview: tc.preview,
113
+ })),
114
+ ...(activityText !== undefined && { activityText }),
115
+ ...(lastToolPreview !== undefined && { lastToolPreview }),
116
+ };
117
+ }
118
+
119
+ if (includeMessages) {
120
+ sanitized.messages = options?.recentMessages
121
+ ? [...options.recentMessages]
122
+ : messages !== undefined
123
+ ? [...messages]
124
+ : undefined;
125
+
126
+ if (includeDebugMessages && termination !== undefined) {
127
+ const { cancelReason, terminationSignal, fallbackCause, ...termBase } =
128
+ termination;
129
+ sanitized.termination = {
130
+ ...termBase,
131
+ ...(cancelReason !== undefined && { cancelReason }),
132
+ ...(terminationSignal !== undefined && { terminationSignal }),
133
+ ...(fallbackCause !== undefined && { fallbackCause }),
134
+ };
135
+ }
136
+ }
137
+
138
+ return sanitized as unknown as SingleResult;
139
+ }
140
+
141
+ function createProgressRenderRequester(
142
+ ctx: ExtensionContext,
143
+ requestId: string,
144
+ ): () => void {
145
+ const progressRenderKey = `subagent-progress:${requestId}`;
146
+ return () => {
147
+ ctx.ui?.setStatus?.(progressRenderKey, `${Date.now()}`);
148
+ ctx.ui?.setStatus?.(progressRenderKey, undefined);
149
+ };
150
+ }
151
+
152
+ function cancelStartedJob(job: RunJob, reason: string): void {
153
+ cancelProgressState(job.requestId, reason);
154
+ removeRunJob(job.requestId);
155
+ }
156
+
157
+ function sendSubagentResultMessage(
158
+ pi: ExtensionAPI,
159
+ content: string,
160
+ details: SubagentDetails,
161
+ ): void {
162
+ pi.sendMessage({
163
+ customType: "subagent-result",
164
+ content,
165
+ display: true,
166
+ details,
167
+ });
168
+ }
169
+
170
+ async function runSubagentWorker(
171
+ pi: ExtensionAPI,
172
+ ctx: ExtensionContext,
173
+ agents: AgentConfig[],
174
+ agentName: string,
175
+ task: string,
176
+ debug: boolean,
177
+ parentModel: { provider: string; id: string } | undefined,
178
+ parentThinking: ThinkingLevel,
179
+ makeDetails: DetailsBuilder,
180
+ requestId: string,
181
+ job: RunJob,
182
+ mergedSignal: AbortSignal,
183
+ ): Promise<void> {
184
+ const seenToolCallIds = new Set<string>();
185
+ const requestProgressRender = createProgressRenderRequester(ctx, requestId);
186
+ const onUpdate: OnUpdateCallback = (result) => {
187
+ patchProgressFromDetails(requestId, result.details, seenToolCallIds);
188
+ requestProgressRender();
189
+ };
190
+ function handleWorkerFailure(
191
+ errorMessage: string,
192
+ isAborted: boolean,
193
+ details: SubagentDetails,
194
+ ) {
195
+ if (isAborted) {
196
+ cancelProgressState(requestId, job.cancelReason ?? errorMessage);
197
+ } else {
198
+ failProgressState(requestId, errorMessage);
199
+ ctx.ui?.notify(errorMessage, "error");
200
+ const content = details.results[0]
201
+ ? formatSubagentResultForParent(details.results[0] as SingleResult) ||
202
+ "(failed)"
203
+ : errorMessage;
204
+ sendSubagentResultMessage(
205
+ pi,
206
+ content,
207
+ sanitizeDetailsForDisplay(details, debug),
208
+ );
209
+ }
210
+ }
211
+ try {
212
+ const result = await runSingleAgent(
213
+ ctx.cwd,
214
+ agents,
215
+ agentName,
216
+ task,
217
+ mergedSignal,
218
+ onUpdate,
219
+ makeDetails,
220
+ parentModel,
221
+ parentThinking,
222
+ );
223
+ if (hasSubagentFailed(result)) {
224
+ handleWorkerFailure(
225
+ mergedSignal.aborted ? "Aborted" : createSubagentError(result).message,
226
+ mergedSignal.aborted,
227
+ makeDetails([result]),
228
+ );
229
+ } else {
230
+ const toolResult: SubagentToolResult = {
231
+ content: [
232
+ {
233
+ type: "text" as const,
234
+ text: formatSubagentResultForParent(result) || "(no output)",
235
+ },
236
+ ],
237
+ details: makeDetails([result]),
238
+ };
239
+ finalizeProgressState(requestId, getFeedbackSummaryText(toolResult));
240
+ sendSubagentResultMessage(
241
+ pi,
242
+ getResultDisplayText(toolResult),
243
+ sanitizeDetailsForDisplay(toolResult.details, debug),
244
+ );
245
+ }
246
+ } catch (error) {
247
+ handleWorkerFailure(
248
+ error instanceof Error ? error.message : String(error),
249
+ mergedSignal.aborted,
250
+ makeDetails([]),
251
+ );
252
+ } finally {
253
+ requestProgressRender();
254
+ removeRunJob(requestId);
255
+ }
256
+ }
257
+
258
+ export type StartJobResult =
259
+ | { kind: "started"; requestId: string; makeDetails: DetailsBuilder }
260
+ | { kind: "cancelled"; makeDetails: DetailsBuilder }
261
+ | { kind: "not_found"; makeDetails: DetailsBuilder };
262
+
263
+ export function formatStartJobStatus(
264
+ agentName: string,
265
+ result: StartJobResult,
266
+ ): string {
267
+ if (result.kind === "not_found") return `Unknown agent: "${agentName}"`;
268
+ if (result.kind === "cancelled") return "Canceled";
269
+ return `Subagent ${agentName} started (job: ${result.requestId})`;
270
+ }
271
+
272
+ function needsProjectAgentConfirmation(
273
+ ctx: ExtensionContext,
274
+ agent: AgentConfig,
275
+ ): boolean {
276
+ return ctx.hasUI && agent.source === "project";
277
+ }
278
+
279
+ function confirmProjectAgentRun(
280
+ ctx: ExtensionContext,
281
+ agent: AgentConfig,
282
+ projectAgentsDir: string | null,
283
+ ): Promise<boolean> {
284
+ const dir = projectAgentsDir ?? "(unknown)";
285
+ return ctx.ui.confirm(
286
+ "Run project-local agent?",
287
+ `Agent: ${agent.name}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
288
+ );
289
+ }
290
+
291
+ export async function startSubagentJob(
292
+ pi: ExtensionAPI,
293
+ ctx: ExtensionContext,
294
+ params: Static<typeof SubagentParams>,
295
+ hostSignal: AbortSignal | undefined,
296
+ ): Promise<StartJobResult> {
297
+ const agentScope: AgentScope = params.agentScope ?? "both";
298
+ const discovery = getCachedAgentDiscovery(ctx.cwd, agentScope);
299
+ const agents = discovery.agents;
300
+ const debug = params.debug === true;
301
+ const makeDetails = createDetailsBuilder(
302
+ agentScope,
303
+ discovery.projectAgentsDir,
304
+ debug,
305
+ );
306
+ const requested = agents.find((a) => a.name === params.agent);
307
+ if (!requested) return { kind: "not_found", makeDetails };
308
+ if (requested.source === "project") {
309
+ const userAgents = discoverAgents(ctx.cwd, "user");
310
+ const hasUserCollision = userAgents.agents.some(
311
+ (a) => a.name === requested.name,
312
+ );
313
+ if (hasUserCollision) {
314
+ pi.sendMessage({
315
+ customType: "subagent-progress",
316
+ content: `Using project agent "${requested.name}"; user agent with same name also exists.`,
317
+ display: true,
318
+ details: {},
319
+ });
320
+ }
321
+ }
322
+ const task = params.task?.trim() ?? "";
323
+ if (needsProjectAgentConfirmation(ctx, requested)) {
324
+ const confirmed = await confirmProjectAgentRun(
325
+ ctx,
326
+ requested,
327
+ discovery.projectAgentsDir,
328
+ );
329
+ if (!confirmed) return { kind: "cancelled", makeDetails };
330
+ }
331
+ const parentModel = ctx.model
332
+ ? { provider: ctx.model.provider, id: ctx.model.id }
333
+ : undefined;
334
+ const parentThinking = pi.getThinkingLevel() as ThinkingLevel;
335
+ const requestId = crypto.randomUUID();
336
+ const controller = new AbortController();
337
+ const job: RunJob = registerRunJob({
338
+ requestId,
339
+ agentName: params.agent,
340
+ controller,
341
+ startedAt: Date.now(),
342
+ });
343
+ const mergedSignal = hostSignal
344
+ ? AbortSignal.any([hostSignal, job.controller.signal])
345
+ : job.controller.signal;
346
+ createProgressState(requestId, params.agent, task);
347
+ pi.sendMessage({
348
+ customType: "subagent-progress",
349
+ content: "",
350
+ display: true,
351
+ details: { requestId },
352
+ });
353
+ const requestProgressRender = createProgressRenderRequester(ctx, requestId);
354
+ setImmediate(() => {
355
+ if (mergedSignal.aborted) {
356
+ cancelStartedJob(job, job.cancelReason ?? "Aborted");
357
+ requestProgressRender();
358
+ return;
359
+ }
360
+ void runSubagentWorker(
361
+ pi,
362
+ ctx,
363
+ agents,
364
+ params.agent,
365
+ task,
366
+ debug,
367
+ parentModel,
368
+ parentThinking,
369
+ makeDetails,
370
+ requestId,
371
+ job,
372
+ mergedSignal,
373
+ );
374
+ });
375
+ if (mergedSignal.aborted) return { kind: "cancelled", makeDetails };
376
+ return { kind: "started", requestId, makeDetails };
377
+ }
package/src/summary.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { normalizeTerminalSentence } from "./normalize.js";
2
+ import type { SingleResult } from "./types.js";
3
+
4
+ export const FEEDBACK_UI_SUMMARY_MAX_CHARS = 120;
5
+
6
+ const FEEDBACK_UI_GENERIC_CANDIDATES = new Set([
7
+ "cause",
8
+ "done",
9
+ "next",
10
+ "output",
11
+ "project summary",
12
+ "result",
13
+ "status",
14
+ "success",
15
+ "summary",
16
+ "verification",
17
+ ]);
18
+
19
+ const FEEDBACK_UI_LABEL_PATTERN =
20
+ /^\s*(outcome|project summary|result|summary|status|output|message|error|check):\s*/i;
21
+
22
+ export function formatSubagentResultForParent(result: SingleResult): string {
23
+ return result.finalOutput;
24
+ }
25
+
26
+ export function summarizeFeedbackUiFinalOutput(finalOutput: string): string {
27
+ const candidates = finalOutput
28
+ .split(/\r?\n|(?<=[.!?])\s+/)
29
+ .map((candidate) => normalizeFeedbackUiSummaryCandidate(candidate))
30
+ .filter(({ text }) => hasSummaryValue(text));
31
+ const selected =
32
+ candidates.find(({ label }) => label === "outcome") ??
33
+ candidates.find(({ label }) => label) ??
34
+ candidates[0];
35
+ return (selected?.text ?? "completed task").toLowerCase();
36
+ }
37
+
38
+ type FeedbackUiSummaryCandidate = {
39
+ text: string;
40
+ label: string | null;
41
+ };
42
+
43
+ function normalizeFeedbackUiSummaryCandidate(
44
+ candidate: string,
45
+ ): FeedbackUiSummaryCandidate {
46
+ const label =
47
+ candidate.match(FEEDBACK_UI_LABEL_PATTERN)?.[1]?.toLowerCase() ?? null;
48
+ const text = normalizeTerminalSentence(
49
+ candidate,
50
+ FEEDBACK_UI_SUMMARY_MAX_CHARS,
51
+ );
52
+ return { text, label };
53
+ }
54
+
55
+ function hasSummaryValue(candidate: string): boolean {
56
+ const normalized = candidate.toLowerCase();
57
+ return (
58
+ !!normalized &&
59
+ !FEEDBACK_UI_GENERIC_CANDIDATES.has(normalized) &&
60
+ /[a-z]/i.test(candidate) &&
61
+ /\s/.test(candidate)
62
+ );
63
+ }
@@ -0,0 +1,209 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+
3
+ export type TerminationSignal = "SIGTERM" | "SIGKILL";
4
+
5
+ export type TerminationMetadata = {
6
+ cancelRequestedAt: number;
7
+ cancelReason?: string;
8
+ terminationSignal?: TerminationSignal;
9
+ escalated: boolean;
10
+ processTreeKilled: boolean;
11
+ target: "direct" | "tree";
12
+ fallbackCause?: string;
13
+ };
14
+
15
+ type TimerHandle = unknown;
16
+
17
+ type TerminationState = {
18
+ metadata: TerminationMetadata;
19
+ promise: Promise<TerminationMetadata>;
20
+ settled: boolean;
21
+ timer?: TimerHandle;
22
+ clearTimeout: (timer: TimerHandle) => void;
23
+ resolve: (metadata: TerminationMetadata) => void;
24
+ };
25
+
26
+ export type TerminateChildProcessOptions = {
27
+ reason?: string;
28
+ timeoutMs?: number;
29
+ tree?: boolean;
30
+ platform?: NodeJS.Platform;
31
+ now?: () => number;
32
+ setTimeout?: (callback: () => void, ms: number) => TimerHandle;
33
+ clearTimeout?: (timer: TimerHandle) => void;
34
+ killProcess?: (proc: ChildProcess, signal: TerminationSignal) => unknown;
35
+ processTreeDetached?: boolean;
36
+ killProcessTree?: (
37
+ proc: ChildProcess,
38
+ signal: TerminationSignal,
39
+ platform: NodeJS.Platform,
40
+ ) => unknown;
41
+ killProcessGroup?: (pid: number, signal: TerminationSignal) => unknown;
42
+ runTaskkill?: (args: string[]) => unknown;
43
+ };
44
+
45
+ const DEFAULT_TIMEOUT_MS = 4_000;
46
+ const terminationStates = new WeakMap<ChildProcess, TerminationState>();
47
+
48
+ export function getProcessTreeSpawnOptions(
49
+ tree: boolean,
50
+ platform: NodeJS.Platform = process.platform,
51
+ ): { detached?: boolean } {
52
+ return tree && platform !== "win32" ? { detached: true } : {};
53
+ }
54
+
55
+ function childHasExited(proc: ChildProcess): boolean {
56
+ return proc.exitCode !== null || proc.signalCode != null;
57
+ }
58
+
59
+ function hasPid(proc: ChildProcess): proc is ChildProcess & { pid: number } {
60
+ return typeof proc.pid === "number" && Number.isFinite(proc.pid);
61
+ }
62
+
63
+ function settleState(state: TerminationState): void {
64
+ if (state.settled) return;
65
+ state.settled = true;
66
+ if (state.timer) state.clearTimeout(state.timer);
67
+ state.timer = undefined;
68
+ state.resolve(state.metadata);
69
+ }
70
+
71
+ function makeState(
72
+ proc: ChildProcess,
73
+ options: TerminateChildProcessOptions,
74
+ ): TerminationState {
75
+ let resolveState!: (metadata: TerminationMetadata) => void;
76
+ const metadata: TerminationMetadata = {
77
+ cancelRequestedAt: options.now?.() ?? Date.now(),
78
+ cancelReason: options.reason,
79
+ escalated: false,
80
+ processTreeKilled: false,
81
+ target: options.tree ? "tree" : "direct",
82
+ };
83
+ const state: TerminationState = {
84
+ metadata,
85
+ promise: new Promise((resolve) => {
86
+ resolveState = resolve;
87
+ }),
88
+ settled: false,
89
+ clearTimeout:
90
+ options.clearTimeout ?? ((timer) => clearTimeout(timer as never)),
91
+ resolve: resolveState,
92
+ };
93
+ const settle = () => settleState(state);
94
+ proc.once("exit", settle);
95
+ proc.once("close", settle);
96
+ proc.once("error", settle);
97
+ return state;
98
+ }
99
+
100
+ function sendDirectSignal(
101
+ proc: ChildProcess,
102
+ signal: TerminationSignal,
103
+ state: TerminationState,
104
+ options: TerminateChildProcessOptions,
105
+ ): void {
106
+ (options.killProcess ?? ((child, nextSignal) => child.kill(nextSignal)))(
107
+ proc,
108
+ signal,
109
+ );
110
+ state.metadata.target = "direct";
111
+ state.metadata.processTreeKilled = false;
112
+ }
113
+
114
+ function sendTreeSignal(
115
+ proc: ChildProcess,
116
+ signal: TerminationSignal,
117
+ state: TerminationState,
118
+ options: TerminateChildProcessOptions,
119
+ ): void {
120
+ const platform = options.platform ?? process.platform;
121
+ if (!hasPid(proc)) throw new Error("missing child PID");
122
+ const pid = proc.pid;
123
+ if (!options.tree) {
124
+ sendDirectSignal(proc, signal, state, options);
125
+ return;
126
+ }
127
+ if (options.killProcessTree) {
128
+ options.killProcessTree(proc, signal, platform);
129
+ state.metadata.target = "tree";
130
+ state.metadata.processTreeKilled = true;
131
+ return;
132
+ }
133
+ if (platform !== "win32") {
134
+ if (!options.processTreeDetached)
135
+ throw new Error("process tree not detached");
136
+ (
137
+ options.killProcessGroup ??
138
+ ((pid, nextSignal) => process.kill(pid, nextSignal))
139
+ )(-pid, signal);
140
+ state.metadata.target = "tree";
141
+ state.metadata.processTreeKilled = true;
142
+ return;
143
+ }
144
+ if (signal === "SIGKILL") {
145
+ (options.runTaskkill ?? ((args) => Bun.spawnSync(["taskkill", ...args])))([
146
+ "/pid",
147
+ String(pid),
148
+ "/t",
149
+ "/f",
150
+ ]);
151
+ state.metadata.target = "tree";
152
+ state.metadata.processTreeKilled = true;
153
+ return;
154
+ }
155
+ throw new Error("unsupported tree termination platform");
156
+ }
157
+
158
+ function sendTerminationSignal(
159
+ proc: ChildProcess,
160
+ signal: TerminationSignal,
161
+ state: TerminationState,
162
+ options: TerminateChildProcessOptions,
163
+ ): void {
164
+ if (state.settled || childHasExited(proc) || !hasPid(proc)) {
165
+ settleState(state);
166
+ return;
167
+ }
168
+ try {
169
+ state.metadata.terminationSignal = signal;
170
+ sendTreeSignal(proc, signal, state, options);
171
+ } catch (error) {
172
+ try {
173
+ state.metadata.fallbackCause =
174
+ error instanceof Error ? error.message : "tree termination failed";
175
+ sendDirectSignal(proc, signal, state, options);
176
+ } catch {
177
+ settleState(state);
178
+ }
179
+ }
180
+ }
181
+
182
+ export function terminateChildProcess(
183
+ proc: ChildProcess,
184
+ options: TerminateChildProcessOptions = {},
185
+ ): Promise<TerminationMetadata> {
186
+ const existing = terminationStates.get(proc);
187
+ if (existing) return existing.promise;
188
+ const state = makeState(proc, options);
189
+ terminationStates.set(proc, state);
190
+ if (childHasExited(proc) || !hasPid(proc)) {
191
+ settleState(state);
192
+ return state.promise;
193
+ }
194
+ sendTerminationSignal(proc, "SIGTERM", state, options);
195
+ if (!state.settled) {
196
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
197
+ const setTimer = options.setTimeout ?? setTimeout;
198
+ state.timer = setTimer(() => {
199
+ if (state.settled || childHasExited(proc)) {
200
+ settleState(state);
201
+ return;
202
+ }
203
+ state.metadata.escalated = true;
204
+ sendTerminationSignal(proc, "SIGKILL", state, options);
205
+ }, timeoutMs);
206
+ (state.timer as { unref?: () => void } | undefined)?.unref?.();
207
+ }
208
+ return state.promise;
209
+ }