pi-simple-background-tasks 0.0.0-stage → 0.1.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.
@@ -0,0 +1,180 @@
1
+ import type {
2
+ BeforeAgentStartEvent,
3
+ BeforeAgentStartEventResult,
4
+ } from '@earendil-works/pi-coding-agent';
5
+ import {
6
+ type ResolvedShellPolicy,
7
+ resolveShellPolicy,
8
+ type ShellPolicySnapshot,
9
+ } from './common.js';
10
+
11
+ export const SHELL_POLICY_SECTION = 'pi_background_shell_policy';
12
+ const SHELL_POLICY_OPEN = `<${SHELL_POLICY_SECTION}>`;
13
+ const SHELL_POLICY_CLOSE = `</${SHELL_POLICY_SECTION}>`;
14
+
15
+ interface ShellPolicyPromptEvent {
16
+ readonly systemPrompt: unknown;
17
+ readonly systemPromptOptions?: unknown;
18
+ }
19
+
20
+ function promptObject(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
22
+ }
23
+
24
+ function unsupportedPrompt(field: string): Error {
25
+ return new Error(`pi_bg_shell_prompt_unsupported: invalid ${field}`);
26
+ }
27
+
28
+ export interface ShellPolicyInitializationOptions {
29
+ readonly platform?: NodeJS.Platform | undefined;
30
+ readonly env?: NodeJS.ProcessEnv | undefined;
31
+ readonly activationCwd?: string | undefined;
32
+ }
33
+
34
+ function displayedArgs(policy: ShellPolicySnapshot): string[] {
35
+ return [...policy.argvPrefix, policy.dialect === 'cmd' ? '"<command>"' : '<command>'];
36
+ }
37
+
38
+ /** Stable, non-secret guidance generated from the same selection used for spawning. */
39
+ export function shellPolicyGuidance(policy: ShellPolicySnapshot): string {
40
+ const launch = JSON.stringify({
41
+ policy: policy.policy,
42
+ executable: policy.executable,
43
+ dialect: policy.dialect,
44
+ args: displayedArgs(policy),
45
+ });
46
+ const lines = [
47
+ `bg_run and /bg execute commands with the activation shell policy ${launch}.`,
48
+ 'The executable and arguments are passed directly to process spawn; the executable path is never interpolated into another shell command.',
49
+ ];
50
+
51
+ if (policy.dialect === 'user-non-posix') {
52
+ lines.push(
53
+ 'This inherited user shell is not classified as POSIX or Bash. Do not generate Bash/POSIX syntax or assume Bash startup files for bg_run or /bg.',
54
+ 'Bash remediation: set PI_BG_POSIX_SHELL=bash before starting or reloading Pi; optionally set PI_BG_POSIX_SHELL_PATH to an absolute executable Bash path.',
55
+ );
56
+ } else if (policy.dialect === 'bash') {
57
+ lines.push(
58
+ 'Generate Bash syntax for bg_run and /bg. Commands use Bash -c, never -lc, so login-shell startup files are not loaded implicitly.',
59
+ );
60
+ } else if (policy.dialect === 'posix') {
61
+ lines.push(
62
+ 'Generate portable POSIX shell syntax for bg_run and /bg; do not assume Bash-only syntax. Commands use -c and do not request login-shell startup.',
63
+ );
64
+ } else {
65
+ lines.push(
66
+ 'Generate Windows cmd.exe syntax for bg_run and /bg. The POSIX shell-selection variables do not change Windows execution.',
67
+ );
68
+ }
69
+
70
+ return lines.join('\n');
71
+ }
72
+
73
+ export function renderShellPolicyGuidanceBlock(policy: ShellPolicySnapshot): string {
74
+ return `${SHELL_POLICY_OPEN}\n${shellPolicyGuidance(policy)}\n${SHELL_POLICY_CLOSE}`;
75
+ }
76
+
77
+ /** Replace this package's section without disturbing guidance owned by another hook. */
78
+ export function upsertShellPolicyGuidance(
79
+ systemPrompt: string,
80
+ policy: ShellPolicySnapshot,
81
+ ): string {
82
+ const block = renderShellPolicyGuidanceBlock(policy);
83
+ const start = systemPrompt.indexOf(SHELL_POLICY_OPEN);
84
+ if (start >= 0) {
85
+ const close = systemPrompt.indexOf(SHELL_POLICY_CLOSE, start + SHELL_POLICY_OPEN.length);
86
+ if (close >= 0) {
87
+ return `${systemPrompt.slice(0, start)}${block}${systemPrompt.slice(close + SHELL_POLICY_CLOSE.length)}`;
88
+ }
89
+ }
90
+ return systemPrompt.length > 0 ? `${systemPrompt}\n\n${block}` : block;
91
+ }
92
+
93
+ /**
94
+ * Array prompts are host-owned ordered sections (OMP 18.3.0). Never stringify or
95
+ * join them. Update the first complete owned block within one element, otherwise
96
+ * append one dedicated element; retain every other element, including empty ones.
97
+ */
98
+ function upsertShellPolicySections(
99
+ parts: readonly unknown[],
100
+ policy: ShellPolicySnapshot,
101
+ ): string[] {
102
+ const sections: string[] = [];
103
+ for (const part of parts) {
104
+ // Iteration visits sparse holes as undefined, unlike Array.every/map.
105
+ if (typeof part !== 'string') throw unsupportedPrompt('systemPrompt array element');
106
+ sections.push(part);
107
+ }
108
+ const index = sections.findIndex((section) => {
109
+ const start = section.indexOf(SHELL_POLICY_OPEN);
110
+ return start >= 0 && section.indexOf(SHELL_POLICY_CLOSE, start + SHELL_POLICY_OPEN.length) >= 0;
111
+ });
112
+ const existing = sections[index];
113
+ if (existing === undefined) sections.push(renderShellPolicyGuidanceBlock(policy));
114
+ else sections[index] = upsertShellPolicyGuidance(existing, policy);
115
+ return sections;
116
+ }
117
+
118
+ /**
119
+ * Preserve Pi's chained string/structured-section contracts and OMP's string[]
120
+ * contract. Optional structured state is inspected only for string prompts.
121
+ */
122
+ export function applyShellPolicyGuidance(
123
+ event: ShellPolicyPromptEvent & { readonly systemPrompt: string },
124
+ policy: ShellPolicySnapshot,
125
+ ): BeforeAgentStartEventResult | undefined;
126
+ export function applyShellPolicyGuidance(
127
+ event: ShellPolicyPromptEvent & { readonly systemPrompt: readonly string[] },
128
+ policy: ShellPolicySnapshot,
129
+ ): { systemPrompt: string[] };
130
+ export function applyShellPolicyGuidance(
131
+ event: ShellPolicyPromptEvent,
132
+ policy: ShellPolicySnapshot,
133
+ ): BeforeAgentStartEventResult | { systemPrompt: string[] } | undefined;
134
+ export function applyShellPolicyGuidance(
135
+ event: ShellPolicyPromptEvent,
136
+ policy: ShellPolicySnapshot,
137
+ ): BeforeAgentStartEventResult | { systemPrompt: string[] } | undefined {
138
+ if (!promptObject(event)) throw unsupportedPrompt('before_agent_start event');
139
+ const prompt = event.systemPrompt;
140
+ if (Array.isArray(prompt)) return { systemPrompt: upsertShellPolicySections(prompt, policy) };
141
+ if (typeof prompt !== 'string')
142
+ throw unsupportedPrompt('systemPrompt (expected string or string[])');
143
+
144
+ const options = event.systemPromptOptions;
145
+ if (options !== undefined && options !== null) {
146
+ if (!promptObject(options)) throw unsupportedPrompt('systemPromptOptions');
147
+ const sections = options.sections;
148
+ if (sections !== undefined && sections !== null) {
149
+ if (!promptObject(sections)) throw unsupportedPrompt('systemPromptOptions.sections');
150
+ const forced = options.forceSystemPrompt;
151
+ if (forced !== undefined && typeof forced !== 'string') {
152
+ throw unsupportedPrompt('systemPromptOptions.forceSystemPrompt');
153
+ }
154
+ // Validate before mutating host-owned sections. Preserve the existing Pi
155
+ // mutation contract, including forced prompts that hide structured sections.
156
+ sections[SHELL_POLICY_SECTION] = shellPolicyGuidance(policy);
157
+ if (typeof forced === 'string') {
158
+ options.forceSystemPrompt = upsertShellPolicyGuidance(forced, policy);
159
+ }
160
+ return undefined;
161
+ }
162
+ }
163
+ return { systemPrompt: upsertShellPolicyGuidance(prompt, policy) };
164
+ }
165
+
166
+ export function createShellPolicyGuidanceHandler(policy: ResolvedShellPolicy) {
167
+ return (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined =>
168
+ applyShellPolicyGuidance(event, policy);
169
+ }
170
+
171
+ /** Resolve once at extension activation; the returned object is deeply frozen. */
172
+ export function initializeShellPolicy(
173
+ options: ShellPolicyInitializationOptions = {},
174
+ ): ResolvedShellPolicy {
175
+ return resolveShellPolicy(
176
+ options.platform ?? process.platform,
177
+ options.env ?? process.env,
178
+ options.activationCwd ?? process.cwd(),
179
+ );
180
+ }
@@ -0,0 +1,146 @@
1
+ import { readdir, readFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { TERMINAL_TASK_STATUS_VALUES } from './common.js';
4
+
5
+ export type CleanupScope = 'one' | 'session' | 'all';
6
+
7
+ export interface CleanupDeleted {
8
+ id: string;
9
+ status: string;
10
+ runDir: string;
11
+ }
12
+
13
+ export interface CleanupSkipped {
14
+ id: string;
15
+ status: string;
16
+ }
17
+
18
+ export interface CleanupSummary {
19
+ scope: CleanupScope;
20
+ deleted: CleanupDeleted[];
21
+ skipped: CleanupSkipped[];
22
+ notFound: string[];
23
+ }
24
+
25
+ const TERMINAL = new Set<string>(TERMINAL_TASK_STATUS_VALUES);
26
+
27
+ async function listRunDirs(tasksDir: string): Promise<string[]> {
28
+ try {
29
+ const entries = await readdir(tasksDir, { withFileTypes: true });
30
+ return entries
31
+ .filter((entry) => entry.isDirectory())
32
+ .map((entry) => join(tasksDir, entry.name));
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ async function listTaskIds(runDir: string): Promise<string[]> {
39
+ try {
40
+ const entries = await readdir(runDir);
41
+ return entries
42
+ .filter((name) => name.endsWith('.json'))
43
+ .map((name) => name.slice(0, name.length - 5));
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ async function readTaskStatus(jsonPath: string): Promise<string | undefined> {
50
+ try {
51
+ const raw = await readFile(jsonPath, 'utf8');
52
+ const parsed = JSON.parse(raw) as { status?: unknown };
53
+ return typeof parsed.status === 'string' ? parsed.status : undefined;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ async function deleteTaskFiles(runDir: string, id: string): Promise<void> {
60
+ await rm(join(runDir, `${id}.json`), { force: true });
61
+ await rm(join(runDir, `${id}.output`), { force: true });
62
+ }
63
+
64
+ async function removeEmptyRunDir(runDir: string): Promise<void> {
65
+ let entries: string[];
66
+ try {
67
+ entries = await readdir(runDir);
68
+ } catch {
69
+ return;
70
+ }
71
+ if (entries.length > 0) return;
72
+ try {
73
+ await rm(runDir, { recursive: true });
74
+ } catch {
75
+ // Best effort: a directory that cannot be removed is harmless.
76
+ }
77
+ }
78
+ // Delete the on-disk files (.json + .output) of finished tasks only.
79
+ // Running tasks are never touched; they are reported in `skipped`.
80
+ // The run directory is removed once it becomes empty.
81
+ export async function cleanupFinishedTasks(options: {
82
+ tasksDir: string;
83
+ sessionRunDir?: string;
84
+ scope: CleanupScope;
85
+ taskId?: string;
86
+ }): Promise<CleanupSummary> {
87
+ const { tasksDir, sessionRunDir, scope, taskId } = options;
88
+ const summary: CleanupSummary = { scope, deleted: [], skipped: [], notFound: [] };
89
+
90
+ const consider = async (runDir: string, id: string): Promise<void> => {
91
+ const status = await readTaskStatus(join(runDir, `${id}.json`));
92
+ if (status === undefined) {
93
+ summary.notFound.push(id);
94
+ return;
95
+ }
96
+ if (!TERMINAL.has(status)) {
97
+ summary.skipped.push({ id, status });
98
+ return;
99
+ }
100
+ await deleteTaskFiles(runDir, id);
101
+ await removeEmptyRunDir(runDir);
102
+ summary.deleted.push({ id, status, runDir });
103
+ };
104
+
105
+ if (scope === 'one') {
106
+ if (!taskId) {
107
+ summary.notFound.push('taskId is required for scope "one"');
108
+ return summary;
109
+ }
110
+ const matches: { runDir: string; id: string }[] = [];
111
+ for (const runDir of await listRunDirs(tasksDir)) {
112
+ for (const id of await listTaskIds(runDir)) {
113
+ if (id === taskId || id.startsWith(taskId)) matches.push({ runDir, id });
114
+ }
115
+ }
116
+ if (matches.length === 0) {
117
+ summary.notFound.push(taskId);
118
+ return summary;
119
+ }
120
+ if (matches.length > 1) {
121
+ summary.notFound.push(`ambiguous taskId "${taskId}": ${matches.map((m) => m.id).join(', ')}`);
122
+ return summary;
123
+ }
124
+ const only = matches[0];
125
+ if (only) await consider(only.runDir, only.id);
126
+ return summary;
127
+ }
128
+
129
+ if (scope === 'session') {
130
+ if (!sessionRunDir) {
131
+ summary.notFound.push('no tasks have been started in this session yet');
132
+ return summary;
133
+ }
134
+ for (const id of await listTaskIds(sessionRunDir)) {
135
+ await consider(sessionRunDir, id);
136
+ }
137
+ return summary;
138
+ }
139
+
140
+ for (const runDir of await listRunDirs(tasksDir)) {
141
+ for (const id of await listTaskIds(runDir)) {
142
+ await consider(runDir, id);
143
+ }
144
+ }
145
+ return summary;
146
+ }
@@ -0,0 +1,65 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { replaceFileDurable, writeFileDurable } from './durable-fs.js';
4
+
5
+ function signalError(signal: AbortSignal): Error {
6
+ const reason: unknown = signal.reason;
7
+ return reason instanceof Error
8
+ ? reason
9
+ : new Error(`Task admission cancelled${reason === undefined ? '' : `: ${String(reason)}`}`);
10
+ }
11
+
12
+ function throwIfAborted(signal?: AbortSignal): void {
13
+ if (signal?.aborted === true) throw signalError(signal);
14
+ }
15
+
16
+ export async function writeFileFsynced(
17
+ path: string,
18
+ data: Buffer | string,
19
+ signal?: AbortSignal,
20
+ ): Promise<void> {
21
+ throwIfAborted(signal);
22
+ await mkdir(dirname(path), { recursive: true });
23
+ throwIfAborted(signal);
24
+ await writeFileDurable(path, data, signal === undefined ? {} : { signal });
25
+ }
26
+
27
+ export async function writeJsonAtomic(
28
+ path: string,
29
+ value: unknown,
30
+ signal?: AbortSignal,
31
+ ): Promise<void> {
32
+ throwIfAborted(signal);
33
+ await replaceFileDurable(
34
+ path,
35
+ `${JSON.stringify(value, null, 2)}\n`,
36
+ signal === undefined ? {} : { signal },
37
+ );
38
+ }
39
+
40
+ export async function closeAndFsyncOutputStream(
41
+ stream: NodeJS.WritableStream | undefined,
42
+ ): Promise<void> {
43
+ if (!stream) return;
44
+ await new Promise<void>((resolvePromise, reject) => {
45
+ let settled = false;
46
+ const finish = () => {
47
+ if (settled) return;
48
+ settled = true;
49
+ stream.off('error', fail);
50
+ stream.off('close', finish);
51
+ stream.off('finish', finish);
52
+ resolvePromise();
53
+ };
54
+ const fail = (error: Error) => {
55
+ if (settled) return;
56
+ settled = true;
57
+ stream.off('close', finish);
58
+ reject(error);
59
+ };
60
+ stream.once('close', finish);
61
+ stream.once('finish', finish);
62
+ stream.once('error', fail);
63
+ stream.end();
64
+ });
65
+ }
@@ -0,0 +1,263 @@
1
+ import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
2
+ import { win32 } from 'node:path';
3
+
4
+ export type WindowsKillPhase = 'terminate' | 'force';
5
+
6
+ export interface TaskkillOutcome {
7
+ readonly exitCode: number | null;
8
+ readonly signal: NodeJS.Signals | null;
9
+ readonly stdout: string;
10
+ readonly stderr: string;
11
+ readonly stdoutTruncated: boolean;
12
+ readonly stderrTruncated: boolean;
13
+ }
14
+
15
+ interface TaskkillOutputStream {
16
+ on(event: 'data', listener: (data: Buffer | string) => void): unknown;
17
+ }
18
+
19
+ interface WindowsTaskkillChildProcess {
20
+ readonly stdout?: TaskkillOutputStream | null | undefined;
21
+ readonly stderr?: TaskkillOutputStream | null | undefined;
22
+ kill(signal?: NodeJS.Signals): boolean;
23
+ on(event: 'error', listener: (error: Error) => void): unknown;
24
+ on(
25
+ event: 'close',
26
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
27
+ ): unknown;
28
+ }
29
+
30
+ type WindowsTaskkillSpawn = (
31
+ command: string,
32
+ args: string[],
33
+ options: SpawnOptions,
34
+ ) => WindowsTaskkillChildProcess;
35
+
36
+ export interface WindowsTaskkillOptions {
37
+ readonly spawn?: WindowsTaskkillSpawn;
38
+ readonly env?: NodeJS.ProcessEnv;
39
+ readonly timeoutMs?: number;
40
+ readonly signal?: AbortSignal;
41
+ readonly maxCaptureBytes?: number;
42
+ }
43
+
44
+ const DEFAULT_TIMEOUT_MS = 5000;
45
+ const DEFAULT_MAX_CAPTURE_BYTES = 8 * 1024;
46
+
47
+ class BoundedCapture {
48
+ private readonly chunks: Buffer[] = [];
49
+ private capturedBytes = 0;
50
+ private truncated = false;
51
+
52
+ constructor(private readonly maxBytes: number) {}
53
+
54
+ append(data: Buffer | string): void {
55
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
56
+ if (buffer.length === 0) return;
57
+ const remaining = this.maxBytes - this.capturedBytes;
58
+ if (remaining > 0) {
59
+ const kept = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
60
+ this.chunks.push(kept);
61
+ this.capturedBytes += kept.length;
62
+ }
63
+ if (buffer.length > Math.max(0, remaining)) this.truncated = true;
64
+ }
65
+
66
+ text(): string {
67
+ return Buffer.concat(this.chunks, this.capturedBytes).toString('utf8');
68
+ }
69
+
70
+ isTruncated(): boolean {
71
+ return this.truncated;
72
+ }
73
+ }
74
+
75
+ function lookupEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
76
+ const direct = env[name];
77
+ if (direct !== undefined) return direct;
78
+ const lowerName = name.toLowerCase();
79
+ for (const key of Object.keys(env)) {
80
+ if (key.toLowerCase() === lowerName) return env[key];
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function validateWindowsRoot(raw: string, label: string): string {
86
+ const value = raw.trim();
87
+ if (value.length === 0) throw new Error(`${label} is empty; cannot resolve taskkill.exe`);
88
+ if (value.includes('\0'))
89
+ throw new Error(`${label} contains a NUL byte; cannot resolve taskkill.exe`);
90
+ if (!win32.isAbsolute(value)) {
91
+ throw new Error(`${label} must be an absolute Windows path; cannot resolve taskkill.exe`);
92
+ }
93
+ return value;
94
+ }
95
+
96
+ export function resolveTaskkillPath(env: NodeJS.ProcessEnv = process.env): string {
97
+ const systemRoot = lookupEnv(env, 'SystemRoot');
98
+ if (systemRoot !== undefined) {
99
+ return win32.join(validateWindowsRoot(systemRoot, 'SystemRoot'), 'System32', 'taskkill.exe');
100
+ }
101
+
102
+ const windir = lookupEnv(env, 'WINDIR');
103
+ if (windir !== undefined) {
104
+ return win32.join(validateWindowsRoot(windir, 'WINDIR'), 'System32', 'taskkill.exe');
105
+ }
106
+
107
+ throw new Error(
108
+ 'Cannot resolve taskkill.exe: SystemRoot is missing and WINDIR fallback is missing',
109
+ );
110
+ }
111
+
112
+ function validatePid(pid: number): void {
113
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
114
+ throw new Error(
115
+ `Invalid Windows taskkill pid ${String(pid)}; expected a positive safe integer`,
116
+ );
117
+ }
118
+ }
119
+
120
+ function validatePhase(phase: WindowsKillPhase): void {
121
+ if (phase !== 'terminate' && phase !== 'force') {
122
+ throw new Error(`Invalid Windows taskkill phase ${String(phase)}`);
123
+ }
124
+ }
125
+
126
+ function positiveFiniteInteger(value: number | undefined, fallback: number, label: string): number {
127
+ const candidate = value ?? fallback;
128
+ if (!Number.isFinite(candidate) || candidate <= 0) {
129
+ throw new Error(`${label} must be a positive finite number`);
130
+ }
131
+ return Math.max(1, Math.floor(candidate));
132
+ }
133
+
134
+ function outcome(
135
+ exitCode: number | null,
136
+ signal: NodeJS.Signals | null,
137
+ stdout: BoundedCapture,
138
+ stderr: BoundedCapture,
139
+ ): TaskkillOutcome {
140
+ return {
141
+ exitCode,
142
+ signal,
143
+ stdout: stdout.text(),
144
+ stderr: stderr.text(),
145
+ stdoutTruncated: stdout.isTruncated(),
146
+ stderrTruncated: stderr.isTruncated(),
147
+ };
148
+ }
149
+
150
+ function defaultSpawn(
151
+ command: string,
152
+ args: string[],
153
+ options: SpawnOptions,
154
+ ): WindowsTaskkillChildProcess {
155
+ return nodeSpawn(command, args, options);
156
+ }
157
+
158
+ export function runWindowsTaskkill(
159
+ pid: number,
160
+ phase: WindowsKillPhase,
161
+ options: WindowsTaskkillOptions = {},
162
+ ): Promise<TaskkillOutcome> {
163
+ validatePid(pid);
164
+ validatePhase(phase);
165
+ const env = options.env ?? process.env;
166
+ const taskkill = resolveTaskkillPath(env);
167
+ const timeoutMs = positiveFiniteInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs');
168
+ const maxCaptureBytes = positiveFiniteInteger(
169
+ options.maxCaptureBytes,
170
+ DEFAULT_MAX_CAPTURE_BYTES,
171
+ 'maxCaptureBytes',
172
+ );
173
+ const spawn = options.spawn ?? defaultSpawn;
174
+ const abortSignal = options.signal;
175
+
176
+ const args = ['/PID', String(pid), '/T'];
177
+ if (phase === 'force') args.push('/F');
178
+
179
+ if (abortSignal?.aborted) {
180
+ const stdout = new BoundedCapture(maxCaptureBytes);
181
+ const stderr = new BoundedCapture(maxCaptureBytes);
182
+ stderr.append('Windows taskkill was aborted before launch');
183
+ return Promise.resolve(outcome(null, null, stdout, stderr));
184
+ }
185
+
186
+ return new Promise<TaskkillOutcome>((resolve) => {
187
+ const stdout = new BoundedCapture(maxCaptureBytes);
188
+ const stderr = new BoundedCapture(maxCaptureBytes);
189
+ let settled = false;
190
+ let timeout: NodeJS.Timeout | undefined;
191
+ let child: WindowsTaskkillChildProcess | undefined;
192
+ let abortListener: (() => void) | undefined;
193
+
194
+ const settle = (result: TaskkillOutcome): void => {
195
+ if (settled) return;
196
+ settled = true;
197
+ if (timeout !== undefined) clearTimeout(timeout);
198
+ if (abortSignal !== undefined && abortListener !== undefined) {
199
+ abortSignal.removeEventListener('abort', abortListener);
200
+ }
201
+ resolve(result);
202
+ };
203
+
204
+ const stopHelper = (reason: string): void => {
205
+ stderr.append(reason);
206
+ if (child !== undefined) {
207
+ try {
208
+ child.kill('SIGKILL');
209
+ } catch (error) {
210
+ stderr.append(
211
+ `; helper kill failed: ${error instanceof Error ? error.message : String(error)}`,
212
+ );
213
+ }
214
+ }
215
+ settle(outcome(null, null, stdout, stderr));
216
+ };
217
+
218
+ const spawnOptions: SpawnOptions = {
219
+ env,
220
+ shell: false,
221
+ windowsVerbatimArguments: false,
222
+ windowsHide: true,
223
+ stdio: ['ignore', 'pipe', 'pipe'],
224
+ };
225
+
226
+ try {
227
+ child = spawn(taskkill, args, spawnOptions);
228
+ } catch (error) {
229
+ stderr.append(
230
+ `Windows taskkill spawn failed: ${error instanceof Error ? error.message : String(error)}`,
231
+ );
232
+ settle(outcome(null, null, stdout, stderr));
233
+ return;
234
+ }
235
+
236
+ child.stdout?.on('data', (data) => {
237
+ stdout.append(data);
238
+ });
239
+ child.stderr?.on('data', (data) => {
240
+ stderr.append(data);
241
+ });
242
+ child.on('error', (error) => {
243
+ stderr.append(`Windows taskkill spawn error: ${error.message}`);
244
+ settle(outcome(null, null, stdout, stderr));
245
+ });
246
+ child.on('close', (code, signal) => {
247
+ settle(outcome(code, signal, stdout, stderr));
248
+ });
249
+
250
+ abortListener = () => {
251
+ stopHelper('Windows taskkill was aborted');
252
+ };
253
+ abortSignal?.addEventListener('abort', abortListener, { once: true });
254
+
255
+ // This timeout is the settlement guarantee for a taskkill helper that never
256
+ // exits. It must keep the event loop alive: an unref'd timer lets the loop
257
+ // drain first and leaves this promise pending forever. `settle()` always
258
+ // clears it, so keeping it referenced cannot leak.
259
+ timeout = setTimeout(() => {
260
+ stopHelper(`Windows taskkill timed out after ${String(timeoutMs)}ms`);
261
+ }, timeoutMs);
262
+ });
263
+ }