@mjakl/pi-processes 0.8.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/manager.ts ADDED
@@ -0,0 +1,513 @@
1
+ import { EventEmitter } from "node:events";
2
+ import {
3
+ appendFileSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ statSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import {
13
+ type KillResult,
14
+ LIVE_STATUSES,
15
+ type ManagerEvent,
16
+ type ProcessInfo,
17
+ type ProcessStatus,
18
+ type ResolveProcessResult,
19
+ } from "./constants";
20
+ import { isProcessGroupAlive, killProcessGroup } from "./utils";
21
+ import { spawnCommand } from "./utils/command-executor";
22
+
23
+ interface ManagedProcess extends ProcessInfo {
24
+ lastSignalSent: NodeJS.Signals | null;
25
+ combinedFile: string;
26
+ triggerAgentTurnOnEnd: boolean;
27
+ }
28
+
29
+ interface ProcessManagerOptions {
30
+ getConfiguredShellPath?: () => string | undefined;
31
+ }
32
+
33
+ export class ProcessManager {
34
+ private processes: Map<string, ManagedProcess> = new Map();
35
+ private counter = 0;
36
+ private logDir: string;
37
+ private events = new EventEmitter();
38
+ private watcher: ReturnType<typeof setInterval> | null = null;
39
+ private getConfiguredShellPath: () => string | undefined;
40
+
41
+ constructor(options?: ProcessManagerOptions) {
42
+ this.logDir = join(tmpdir(), `pi-processes-${Date.now()}`);
43
+ mkdirSync(this.logDir, { recursive: true });
44
+ this.getConfiguredShellPath =
45
+ options?.getConfiguredShellPath ?? (() => undefined);
46
+ }
47
+
48
+ onEvent(listener: (event: ManagerEvent) => void): () => void {
49
+ this.events.on("event", listener);
50
+ return () => this.events.off("event", listener);
51
+ }
52
+
53
+ private emit(event: ManagerEvent): void {
54
+ this.events.emit("event", event);
55
+ }
56
+
57
+ private transition(managed: ManagedProcess, next: ProcessStatus): void {
58
+ if (managed.status === next) return;
59
+ managed.status = next;
60
+
61
+ this.emit({ type: "processes_changed" });
62
+
63
+ if (next === "exited" || next === "killed") {
64
+ this.emit({
65
+ type: "process_ended",
66
+ info: this.toProcessInfo(managed),
67
+ triggerAgentTurn: managed.triggerAgentTurnOnEnd,
68
+ });
69
+ }
70
+
71
+ this.ensureWatcherRunning();
72
+ this.stopWatcherIfIdle();
73
+ }
74
+
75
+ private ensureWatcherRunning(): void {
76
+ if (this.watcher) return;
77
+ if (!this.hasAliveishProcesses()) return;
78
+
79
+ this.watcher = setInterval(() => {
80
+ this.livenessTick();
81
+ }, 5000);
82
+ }
83
+
84
+ private stopWatcherIfIdle(): void {
85
+ if (!this.watcher) return;
86
+ if (this.hasAliveishProcesses()) return;
87
+
88
+ clearInterval(this.watcher);
89
+ this.watcher = null;
90
+ }
91
+
92
+ private hasAliveishProcesses(): boolean {
93
+ for (const p of this.processes.values()) {
94
+ if (LIVE_STATUSES.has(p.status)) return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ private livenessTick(): void {
100
+ for (const managed of this.processes.values()) {
101
+ if (!LIVE_STATUSES.has(managed.status)) continue;
102
+ if (!managed.pid || managed.pid <= 0) continue;
103
+
104
+ const alive = isProcessGroupAlive(managed.pid);
105
+ if (alive) continue;
106
+
107
+ if (!managed.endTime) {
108
+ managed.endTime = Date.now();
109
+ }
110
+
111
+ if (managed.lastSignalSent) {
112
+ managed.success = false;
113
+ managed.exitCode = null;
114
+ this.transition(managed, "killed");
115
+ } else {
116
+ managed.success = false;
117
+ managed.exitCode = null;
118
+ this.transition(managed, "exited");
119
+ }
120
+ }
121
+ }
122
+
123
+ start(name: string, command: string, cwd: string): ProcessInfo {
124
+ const id = `proc_${++this.counter}`;
125
+ const stdoutFile = join(this.logDir, `${id}-stdout.log`);
126
+ const stderrFile = join(this.logDir, `${id}-stderr.log`);
127
+ const combinedFile = join(this.logDir, `${id}-combined.log`);
128
+
129
+ appendFileSync(stdoutFile, "");
130
+ appendFileSync(stderrFile, "");
131
+ appendFileSync(combinedFile, "");
132
+
133
+ const child = spawnCommand(command, cwd, this.getConfiguredShellPath());
134
+
135
+ child.unref();
136
+
137
+ const managed: ManagedProcess = {
138
+ id,
139
+ name,
140
+ pid: child.pid ?? -1,
141
+ command,
142
+ cwd,
143
+ startTime: Date.now(),
144
+ endTime: null,
145
+ status: "running",
146
+ exitCode: null,
147
+ success: null,
148
+ stdoutFile,
149
+ stderrFile,
150
+ lastSignalSent: null,
151
+ combinedFile,
152
+ triggerAgentTurnOnEnd: true,
153
+ };
154
+
155
+ this.processes.set(id, managed);
156
+
157
+ if (!child.pid) {
158
+ try {
159
+ appendFileSync(stderrFile, "Spawn error: missing pid\n");
160
+ } catch {
161
+ // Ignore
162
+ }
163
+ managed.exitCode = -1;
164
+ managed.success = false;
165
+ managed.endTime = Date.now();
166
+ this.transition(managed, "exited");
167
+ return this.toProcessInfo(managed);
168
+ }
169
+
170
+ child.stdout?.on("data", (data: Buffer) => {
171
+ try {
172
+ appendFileSync(stdoutFile, data);
173
+ const lines = data.toString().split("\n");
174
+ // The last element after split is either empty (if data ended with \n)
175
+ // or a partial line. We write all parts with the prefix and newline.
176
+ const tagged = lines
177
+ .map((line, i) =>
178
+ i < lines.length - 1 ? `1:${line}\n` : line ? `1:${line}\n` : "",
179
+ )
180
+ .join("");
181
+ if (tagged) appendFileSync(combinedFile, tagged);
182
+ } catch {
183
+ // Ignore
184
+ }
185
+ });
186
+
187
+ child.stderr?.on("data", (data: Buffer) => {
188
+ try {
189
+ appendFileSync(stderrFile, data);
190
+ const lines = data.toString().split("\n");
191
+ const tagged = lines
192
+ .map((line, i) =>
193
+ i < lines.length - 1 ? `2:${line}\n` : line ? `2:${line}\n` : "",
194
+ )
195
+ .join("");
196
+ if (tagged) appendFileSync(combinedFile, tagged);
197
+ } catch {
198
+ // Ignore
199
+ }
200
+ });
201
+
202
+ child.on("close", (code, signal) => {
203
+ if (managed.endTime) return;
204
+
205
+ managed.exitCode = code;
206
+ managed.endTime = Date.now();
207
+ managed.success = code === 0;
208
+
209
+ if (signal) {
210
+ this.transition(managed, "killed");
211
+ } else {
212
+ this.transition(managed, "exited");
213
+ }
214
+ });
215
+
216
+ child.on("error", (err) => {
217
+ try {
218
+ appendFileSync(stderrFile, `Process error: ${err.message}\n`);
219
+ } catch {
220
+ // Ignore
221
+ }
222
+
223
+ if (!managed.endTime) {
224
+ managed.exitCode = -1;
225
+ managed.success = false;
226
+ managed.endTime = Date.now();
227
+ this.transition(managed, "exited");
228
+ }
229
+ });
230
+
231
+ this.emit({ type: "process_started", info: this.toProcessInfo(managed) });
232
+ this.ensureWatcherRunning();
233
+
234
+ return this.toProcessInfo(managed);
235
+ }
236
+
237
+ list(): ProcessInfo[] {
238
+ return Array.from(this.processes.values())
239
+ .map((p) => this.toProcessInfo(p))
240
+ .reverse();
241
+ }
242
+
243
+ get(id: string): ProcessInfo | null {
244
+ const managed = this.processes.get(id);
245
+ return managed ? this.toProcessInfo(managed) : null;
246
+ }
247
+
248
+ resolve(query: string): ResolveProcessResult {
249
+ const byId = this.processes.get(query);
250
+ if (byId) {
251
+ return { ok: true, info: this.toProcessInfo(byId) };
252
+ }
253
+
254
+ const queryLower = query.toLowerCase();
255
+ const matches = Array.from(this.processes.values())
256
+ .filter((managed) => managed.name.toLowerCase() === queryLower)
257
+ .map((managed) => this.toProcessInfo(managed));
258
+
259
+ if (matches.length === 1) {
260
+ return { ok: true, info: matches[0] };
261
+ }
262
+
263
+ if (matches.length > 1) {
264
+ return { ok: false, reason: "ambiguous", matches };
265
+ }
266
+
267
+ return { ok: false, reason: "not_found" };
268
+ }
269
+
270
+ getOutput(
271
+ id: string,
272
+ tailLines = 100,
273
+ ): { stdout: string[]; stderr: string[]; status: string } | null {
274
+ const managed = this.processes.get(id);
275
+ if (!managed) return null;
276
+
277
+ return {
278
+ stdout: this.readTailLines(managed.stdoutFile, tailLines),
279
+ stderr: this.readTailLines(managed.stderrFile, tailLines),
280
+ status: managed.status,
281
+ };
282
+ }
283
+
284
+ getCombinedOutput(
285
+ id: string,
286
+ tailLines = 100,
287
+ ): { type: "stdout" | "stderr"; text: string }[] | null {
288
+ const managed = this.processes.get(id);
289
+ if (!managed) return null;
290
+
291
+ const rawLines = this.readTailLines(managed.combinedFile, tailLines);
292
+ return rawLines.map((line) => {
293
+ if (line.startsWith("2:")) {
294
+ return { type: "stderr", text: line.slice(2) };
295
+ }
296
+ // Default to stdout (handles "1:" prefix and any malformed lines).
297
+ return {
298
+ type: "stdout",
299
+ text: line.startsWith("1:") ? line.slice(2) : line,
300
+ };
301
+ });
302
+ }
303
+
304
+ getFullOutput(id: string): { stdout: string; stderr: string } | null {
305
+ const managed = this.processes.get(id);
306
+ if (!managed) return null;
307
+
308
+ try {
309
+ return {
310
+ stdout: readFileSync(managed.stdoutFile, "utf-8"),
311
+ stderr: readFileSync(managed.stderrFile, "utf-8"),
312
+ };
313
+ } catch {
314
+ return { stdout: "", stderr: "" };
315
+ }
316
+ }
317
+
318
+ getLogFiles(
319
+ id: string,
320
+ ): { stdoutFile: string; stderrFile: string; combinedFile: string } | null {
321
+ const managed = this.processes.get(id);
322
+ if (!managed) return null;
323
+ return {
324
+ stdoutFile: managed.stdoutFile,
325
+ stderrFile: managed.stderrFile,
326
+ combinedFile: managed.combinedFile,
327
+ };
328
+ }
329
+
330
+ async kill(
331
+ id: string,
332
+ opts?: {
333
+ signal?: NodeJS.Signals;
334
+ timeoutMs?: number;
335
+ notifyOnEnd?: boolean;
336
+ },
337
+ ): Promise<KillResult> {
338
+ const managed = this.processes.get(id);
339
+ if (!managed) {
340
+ return {
341
+ ok: false,
342
+ info: {
343
+ id,
344
+ name: "(unknown)",
345
+ pid: -1,
346
+ command: "",
347
+ cwd: "",
348
+ startTime: 0,
349
+ endTime: null,
350
+ status: "exited",
351
+ exitCode: null,
352
+ success: false,
353
+ stdoutFile: "",
354
+ stderrFile: "",
355
+ },
356
+ reason: "not_found",
357
+ };
358
+ }
359
+
360
+ const signal = opts?.signal ?? "SIGTERM";
361
+ const timeoutMs = opts?.timeoutMs ?? 3000;
362
+
363
+ managed.triggerAgentTurnOnEnd = opts?.notifyOnEnd === true;
364
+
365
+ if (!LIVE_STATUSES.has(managed.status)) {
366
+ return { ok: true, info: this.toProcessInfo(managed) };
367
+ }
368
+
369
+ this.transition(managed, "terminating");
370
+
371
+ try {
372
+ killProcessGroup(managed.pid, signal);
373
+ managed.lastSignalSent = signal;
374
+ } catch (error) {
375
+ const err = error as NodeJS.ErrnoException;
376
+ if (err.code === "ESRCH") {
377
+ managed.lastSignalSent = signal;
378
+ } else if (err.code !== "EPERM") {
379
+ return {
380
+ ok: false,
381
+ info: this.toProcessInfo(managed),
382
+ reason: "error",
383
+ };
384
+ }
385
+ }
386
+
387
+ const graceMs = signal === "SIGKILL" ? 200 : timeoutMs;
388
+
389
+ await new Promise((r) => setTimeout(r, graceMs));
390
+
391
+ const alive = isProcessGroupAlive(managed.pid);
392
+
393
+ if (alive) {
394
+ this.transition(managed, "terminate_timeout");
395
+ return {
396
+ ok: false,
397
+ info: this.toProcessInfo(managed),
398
+ reason: "timeout",
399
+ };
400
+ }
401
+
402
+ if (!managed.endTime) {
403
+ managed.endTime = Date.now();
404
+ managed.exitCode = null;
405
+ managed.success = false;
406
+ }
407
+
408
+ this.transition(managed, "killed");
409
+ return { ok: true, info: this.toProcessInfo(managed) };
410
+ }
411
+
412
+ clearFinished(): number {
413
+ let cleared = 0;
414
+ for (const [id, managed] of this.processes) {
415
+ if (LIVE_STATUSES.has(managed.status)) {
416
+ continue;
417
+ }
418
+
419
+ try {
420
+ rmSync(managed.stdoutFile, { force: true });
421
+ rmSync(managed.stderrFile, { force: true });
422
+ rmSync(managed.combinedFile, { force: true });
423
+ } catch {
424
+ // Ignore
425
+ }
426
+
427
+ this.processes.delete(id);
428
+ cleared++;
429
+ }
430
+
431
+ if (cleared > 0) {
432
+ this.emit({ type: "processes_changed" });
433
+ }
434
+
435
+ this.stopWatcherIfIdle();
436
+ return cleared;
437
+ }
438
+
439
+ private shutdownKillAll(): void {
440
+ for (const p of this.processes.values()) {
441
+ if (!LIVE_STATUSES.has(p.status)) continue;
442
+ try {
443
+ killProcessGroup(p.pid, "SIGKILL");
444
+ } catch {
445
+ // Ignore - process may already be dead
446
+ }
447
+ }
448
+ }
449
+
450
+ private stopWatcher(): void {
451
+ if (this.watcher) {
452
+ clearInterval(this.watcher);
453
+ this.watcher = null;
454
+ }
455
+ }
456
+
457
+ cleanup(): void {
458
+ this.stopWatcher();
459
+ this.shutdownKillAll();
460
+
461
+ try {
462
+ rmSync(this.logDir, { recursive: true, force: true });
463
+ } catch {
464
+ // Ignore
465
+ }
466
+ }
467
+
468
+ getFileSize(id: string): { stdout: number; stderr: number } | null {
469
+ const managed = this.processes.get(id);
470
+ if (!managed) return null;
471
+
472
+ try {
473
+ return {
474
+ stdout: statSync(managed.stdoutFile).size,
475
+ stderr: statSync(managed.stderrFile).size,
476
+ };
477
+ } catch {
478
+ return { stdout: 0, stderr: 0 };
479
+ }
480
+ }
481
+
482
+ private readTailLines(filePath: string, lines: number): string[] {
483
+ try {
484
+ const content = readFileSync(filePath, "utf-8");
485
+ const allLines = content.split("\n");
486
+ if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
487
+ allLines.pop();
488
+ }
489
+ return allLines.slice(-lines);
490
+ } catch {
491
+ return [];
492
+ }
493
+ }
494
+
495
+ private toProcessInfo(managed: ManagedProcess): ProcessInfo {
496
+ return {
497
+ id: managed.id,
498
+ name: managed.name,
499
+ pid: managed.pid,
500
+ command: managed.command,
501
+ cwd: managed.cwd,
502
+ startTime: managed.startTime,
503
+ endTime: managed.endTime,
504
+ status: managed.status,
505
+ exitCode: managed.exitCode,
506
+ success: managed.success,
507
+ stdoutFile: managed.stdoutFile,
508
+ stderrFile: managed.stderrFile,
509
+ };
510
+ }
511
+ }
512
+
513
+ export type { ProcessInfo, ProcessStatus, ManagerEvent, KillResult };
@@ -0,0 +1,20 @@
1
+ import type { ExecuteResult } from "../../constants";
2
+ import type { ProcessManager } from "../../manager";
3
+
4
+ export function executeClear(manager: ProcessManager): ExecuteResult {
5
+ const cleared = manager.clearFinished();
6
+ const message =
7
+ cleared > 0
8
+ ? `Cleared ${cleared} finished process(es)`
9
+ : "No finished processes to clear";
10
+
11
+ return {
12
+ content: [{ type: "text", text: message }],
13
+ details: {
14
+ action: "clear",
15
+ success: true,
16
+ message,
17
+ cleared,
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,48 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ExecuteResult } from "../../constants";
3
+ import type { ProcessManager } from "../../manager";
4
+ import { executeClear } from "./clear";
5
+ import { executeKill } from "./kill";
6
+ import { executeList } from "./list";
7
+ import { executeLogs } from "./logs";
8
+ import { executeOutput } from "./output";
9
+ import { executeStart } from "./start";
10
+
11
+ interface ActionParams {
12
+ action: string;
13
+ command?: string;
14
+ name?: string;
15
+ id?: string;
16
+ force?: boolean;
17
+ continueAfterStart?: boolean;
18
+ }
19
+
20
+ export async function executeAction(
21
+ params: ActionParams,
22
+ manager: ProcessManager,
23
+ ctx: ExtensionContext,
24
+ ): Promise<ExecuteResult> {
25
+ switch (params.action) {
26
+ case "start":
27
+ return executeStart(params, manager, ctx);
28
+ case "list":
29
+ return executeList(manager);
30
+ case "output":
31
+ return executeOutput(params, manager);
32
+ case "logs":
33
+ return executeLogs(params, manager);
34
+ case "kill":
35
+ return executeKill(params, manager);
36
+ case "clear":
37
+ return executeClear(manager);
38
+ default:
39
+ return {
40
+ content: [{ type: "text", text: `Unknown action: ${params.action}` }],
41
+ details: {
42
+ action: params.action,
43
+ success: false,
44
+ message: `Unknown action: ${params.action}`,
45
+ },
46
+ };
47
+ }
48
+ }
@@ -0,0 +1,107 @@
1
+ import type { ExecuteResult } from "../../constants";
2
+ import type { ProcessManager } from "../../manager";
3
+
4
+ interface KillParams {
5
+ id?: string;
6
+ force?: boolean;
7
+ }
8
+
9
+ function notFoundResult(id: string): ExecuteResult {
10
+ const message = `Process not found: ${id}`;
11
+ return {
12
+ content: [{ type: "text", text: message }],
13
+ details: {
14
+ action: "kill",
15
+ success: false,
16
+ message,
17
+ },
18
+ };
19
+ }
20
+
21
+ function ambiguousResult(
22
+ id: string,
23
+ matches: Array<{ id: string; name: string }>,
24
+ ): ExecuteResult {
25
+ const choices = matches
26
+ .map((match) => `${match.id} ("${match.name}")`)
27
+ .join(", ");
28
+ const message =
29
+ `Process name is ambiguous: ${id}. ` +
30
+ `Use an exact process ID instead. Matches: ${choices}`;
31
+ return {
32
+ content: [{ type: "text", text: message }],
33
+ details: {
34
+ action: "kill",
35
+ success: false,
36
+ message,
37
+ },
38
+ };
39
+ }
40
+
41
+ export async function executeKill(
42
+ params: KillParams,
43
+ manager: ProcessManager,
44
+ ): Promise<ExecuteResult> {
45
+ if (!params.id) {
46
+ return {
47
+ content: [{ type: "text", text: "Missing required parameter: id" }],
48
+ details: {
49
+ action: "kill",
50
+ success: false,
51
+ message: "Missing required parameter: id",
52
+ },
53
+ };
54
+ }
55
+
56
+ const resolved = manager.resolve(params.id);
57
+ if (!resolved.ok) {
58
+ return resolved.reason === "ambiguous"
59
+ ? ambiguousResult(params.id, resolved.matches ?? [])
60
+ : notFoundResult(params.id);
61
+ }
62
+
63
+ const proc = resolved.info;
64
+ const force = params.force ?? false;
65
+ const signal = force ? "SIGKILL" : "SIGTERM";
66
+ const timeoutMs = force ? 200 : 3000;
67
+ const result = await manager.kill(proc.id, { signal, timeoutMs });
68
+
69
+ if (result.ok) {
70
+ const verb = force ? "Force-killed" : "Terminated";
71
+ const message = `${verb} "${proc.name}" (${proc.id})`;
72
+ return {
73
+ content: [{ type: "text", text: message }],
74
+ details: {
75
+ action: "kill",
76
+ success: true,
77
+ message,
78
+ },
79
+ };
80
+ }
81
+
82
+ if (result.reason === "timeout") {
83
+ const message = force
84
+ ? `SIGKILL timed out for "${proc.name}" (${proc.id})`
85
+ : `SIGTERM timed out for "${proc.name}" (${proc.id}). Re-run process kill with id="${proc.id}" force=true to send SIGKILL.`;
86
+ return {
87
+ content: [{ type: "text", text: message }],
88
+ details: {
89
+ action: "kill",
90
+ success: false,
91
+ message,
92
+ },
93
+ };
94
+ }
95
+
96
+ const message = force
97
+ ? `Failed to force-kill "${proc.name}" (${proc.id})`
98
+ : `Failed to terminate "${proc.name}" (${proc.id})`;
99
+ return {
100
+ content: [{ type: "text", text: message }],
101
+ details: {
102
+ action: "kill",
103
+ success: false,
104
+ message,
105
+ },
106
+ };
107
+ }