@kadi.build/core 0.8.0 → 0.11.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 (45) hide show
  1. package/README.md +424 -1
  2. package/agent.json +19 -0
  3. package/dist/agent-json.d.ts +231 -0
  4. package/dist/agent-json.d.ts.map +1 -0
  5. package/dist/agent-json.js +554 -0
  6. package/dist/agent-json.js.map +1 -0
  7. package/dist/client.d.ts +41 -8
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +102 -43
  10. package/dist/client.js.map +1 -1
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js.map +1 -1
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/process-manager.d.ts +235 -0
  19. package/dist/process-manager.d.ts.map +1 -0
  20. package/dist/process-manager.js +647 -0
  21. package/dist/process-manager.js.map +1 -0
  22. package/dist/stdio-framing.d.ts +88 -0
  23. package/dist/stdio-framing.d.ts.map +1 -0
  24. package/dist/stdio-framing.js +194 -0
  25. package/dist/stdio-framing.js.map +1 -0
  26. package/dist/transports/stdio.d.ts.map +1 -1
  27. package/dist/transports/stdio.js +3 -181
  28. package/dist/transports/stdio.js.map +1 -1
  29. package/dist/types.d.ts +274 -21
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/utils.d.ts +107 -0
  32. package/dist/utils.d.ts.map +1 -0
  33. package/dist/utils.js +212 -0
  34. package/dist/utils.js.map +1 -0
  35. package/package.json +3 -1
  36. package/scripts/symlink.mjs +131 -0
  37. package/src/agent-json.ts +655 -0
  38. package/src/client.ts +120 -46
  39. package/src/errors.ts +15 -0
  40. package/src/index.ts +32 -0
  41. package/src/process-manager.ts +821 -0
  42. package/src/stdio-framing.ts +227 -0
  43. package/src/transports/stdio.ts +4 -221
  44. package/src/types.ts +291 -23
  45. package/src/utils.ts +246 -0
@@ -0,0 +1,821 @@
1
+ /**
2
+ * ProcessManager for kadi-core
3
+ *
4
+ * Manages background child processes with three execution modes:
5
+ *
6
+ * - **headless**: Fire-and-forget. No communication channel.
7
+ * - **piped**: stdout/stderr are streamed back and buffered.
8
+ * - **bridge**: Full JSON-RPC stdio bridge (Content-Length framing).
9
+ *
10
+ * Primary use case: Running long tasks (builds, deploys, inference) in the
11
+ * background so the main agent's event loop stays free for broker heartbeats.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { ProcessManager } from '@kadi.build/core';
16
+ *
17
+ * const pm = new ProcessManager();
18
+ *
19
+ * // Headless build
20
+ * const build = await pm.spawn('build', {
21
+ * command: 'docker', args: ['build', '.'], mode: 'headless',
22
+ * });
23
+ * const status = pm.getStatus('build');
24
+ *
25
+ * // Piped deploy with live output
26
+ * const deploy = await pm.spawn('deploy', {
27
+ * command: 'kadi', args: ['deploy'], mode: 'piped',
28
+ * });
29
+ * deploy.on('stdout', (data) => console.log(data));
30
+ *
31
+ * // Bridge mode for interactive workers
32
+ * const worker = await pm.spawn('worker', {
33
+ * command: 'python3', args: ['worker.py'], mode: 'bridge',
34
+ * });
35
+ * const result = await worker.request('run-inference', { prompt: 'hello' });
36
+ * ```
37
+ */
38
+
39
+ import { spawn, type ChildProcess } from 'child_process';
40
+ import { EventEmitter } from 'events';
41
+ import type {
42
+ ProcessMode,
43
+ ProcessState,
44
+ SpawnOptions,
45
+ ProcessInfo,
46
+ ProcessExitInfo,
47
+ ProcessOutput,
48
+ ProcessListOptions,
49
+ ProcessPruneOptions,
50
+ } from './types.js';
51
+ import { KadiError } from './errors.js';
52
+ import { StdioMessageReader, StdioMessageWriter, type JsonRpcNotification } from './stdio-framing.js';
53
+ import * as protocol from './protocol.js';
54
+
55
+ // ═══════════════════════════════════════════════════════════════════════
56
+ // CONSTANTS
57
+ // ═══════════════════════════════════════════════════════════════════════
58
+
59
+ const DEFAULT_KILL_GRACE_PERIOD = 5000;
60
+ const DEFAULT_MAX_OUTPUT_BUFFER = 10 * 1024 * 1024; // 10 MB
61
+
62
+ // ═══════════════════════════════════════════════════════════════════════
63
+ // MANAGED PROCESS
64
+ // ═══════════════════════════════════════════════════════════════════════
65
+
66
+ /**
67
+ * A handle to a spawned background process.
68
+ *
69
+ * Provides event subscription, lifecycle management, and (in bridge mode)
70
+ * JSON-RPC request/response communication.
71
+ */
72
+ export class ManagedProcess extends EventEmitter {
73
+ /** Unique identifier */
74
+ readonly id: string;
75
+
76
+ /** OS process ID */
77
+ readonly pid: number;
78
+
79
+ /** Execution mode */
80
+ readonly mode: ProcessMode;
81
+
82
+ /** Internal state */
83
+ private _state: ProcessState = 'running';
84
+ private _exitCode: number | null = null;
85
+ private _signal: string | null = null;
86
+ private _startedAt: Date;
87
+ private _endedAt: Date | null = null;
88
+
89
+ /** Command info for getInfo() */
90
+ private readonly _command: string;
91
+ private readonly _args: string[];
92
+ private readonly _cwd: string;
93
+
94
+ /** Output buffering (piped mode) */
95
+ private _stdout = '';
96
+ private _stderr = '';
97
+ private readonly _maxOutputBuffer: number;
98
+
99
+ /** Bridge mode internals */
100
+ private _reader: StdioMessageReader | null = null;
101
+ private _writer: StdioMessageWriter | null = null;
102
+ private _bridgeIdCounter = 1;
103
+
104
+ /** The underlying child process */
105
+ private readonly _proc: ChildProcess;
106
+
107
+ /** Timeout timer for auto-kill */
108
+ private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
109
+
110
+ /** Kill grace period */
111
+ private readonly _killGracePeriod: number;
112
+
113
+ /** Waiters for the exit event */
114
+ private readonly _exitWaiters: Array<{
115
+ resolve: (info: ProcessExitInfo) => void;
116
+ }> = [];
117
+
118
+ constructor(
119
+ id: string,
120
+ proc: ChildProcess,
121
+ options: SpawnOptions,
122
+ ) {
123
+ super();
124
+
125
+ this.id = id;
126
+ this._proc = proc;
127
+ this.pid = proc.pid!;
128
+ this.mode = options.mode;
129
+ this._command = options.command;
130
+ this._args = options.args ?? [];
131
+ this._cwd = options.cwd ?? process.cwd();
132
+ this._startedAt = new Date();
133
+ this._killGracePeriod = options.killGracePeriod ?? DEFAULT_KILL_GRACE_PERIOD;
134
+ this._maxOutputBuffer = options.maxOutputBuffer ?? DEFAULT_MAX_OUTPUT_BUFFER;
135
+
136
+ // Set up auto-kill timeout
137
+ if (options.timeout && options.timeout > 0) {
138
+ this._timeoutTimer = setTimeout(() => {
139
+ this.kill().catch(() => {});
140
+ }, options.timeout);
141
+ }
142
+
143
+ // Set up process exit handler
144
+ proc.on('exit', (code, signal) => {
145
+ this.handleExit(code, signal);
146
+ });
147
+
148
+ proc.on('error', (error) => {
149
+ if (this._state === 'running') {
150
+ this._state = 'errored';
151
+ this._endedAt = new Date();
152
+ this.emit('error', error);
153
+ this.resolveExitWaiters();
154
+ }
155
+ });
156
+
157
+ // Set up output streaming based on mode
158
+ if (options.mode === 'piped') {
159
+ this.setupPipedMode(proc);
160
+ } else if (options.mode === 'bridge') {
161
+ this.setupBridgeMode(proc, options);
162
+ }
163
+ }
164
+
165
+ // ─────────────────────────────────────────────────────────────────
166
+ // STATE GETTERS
167
+ // ─────────────────────────────────────────────────────────────────
168
+
169
+ /** Current lifecycle state */
170
+ get state(): ProcessState { return this._state; }
171
+
172
+ /** Whether the process is still running */
173
+ get isRunning(): boolean { return this._state === 'running'; }
174
+
175
+ // ─────────────────────────────────────────────────────────────────
176
+ // MODE SETUP
177
+ // ─────────────────────────────────────────────────────────────────
178
+
179
+ private setupPipedMode(proc: ChildProcess): void {
180
+ if (proc.stdout) {
181
+ proc.stdout.on('data', (chunk: Buffer) => {
182
+ const str = chunk.toString('utf-8');
183
+ this.appendOutput('stdout', str);
184
+ this.emit('stdout', str);
185
+ });
186
+ }
187
+
188
+ if (proc.stderr) {
189
+ proc.stderr.on('data', (chunk: Buffer) => {
190
+ const str = chunk.toString('utf-8');
191
+ this.appendOutput('stderr', str);
192
+ this.emit('stderr', str);
193
+ });
194
+ }
195
+ }
196
+
197
+ private setupBridgeMode(proc: ChildProcess, _options: SpawnOptions): void {
198
+ if (!proc.stdin || !proc.stdout) {
199
+ throw new KadiError(
200
+ 'Failed to get stdio streams for bridge mode',
201
+ 'PROCESS_BRIDGE_ERROR',
202
+ { processId: this.id },
203
+ );
204
+ }
205
+
206
+ this._reader = new StdioMessageReader(proc.stdout);
207
+ this._writer = new StdioMessageWriter(proc.stdin);
208
+
209
+ // Route notifications to event emitter
210
+ this._reader.setNotificationHandler((notification: JsonRpcNotification) => {
211
+ this.emit('notification', notification.method, notification.params);
212
+ });
213
+
214
+ // Also capture stderr for debugging
215
+ if (proc.stderr) {
216
+ proc.stderr.on('data', (chunk: Buffer) => {
217
+ const str = chunk.toString('utf-8');
218
+ this.appendOutput('stderr', str);
219
+ this.emit('stderr', str);
220
+ });
221
+ }
222
+ }
223
+
224
+ // ─────────────────────────────────────────────────────────────────
225
+ // OUTPUT BUFFERING
226
+ // ─────────────────────────────────────────────────────────────────
227
+
228
+ private appendOutput(stream: 'stdout' | 'stderr', data: string): void {
229
+ if (stream === 'stdout') {
230
+ this._stdout += data;
231
+ if (this._stdout.length > this._maxOutputBuffer) {
232
+ // Ring buffer: keep the most recent data
233
+ this._stdout = this._stdout.slice(-this._maxOutputBuffer);
234
+ }
235
+ } else {
236
+ this._stderr += data;
237
+ if (this._stderr.length > this._maxOutputBuffer) {
238
+ this._stderr = this._stderr.slice(-this._maxOutputBuffer);
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Get all buffered output.
245
+ * Only available in piped and bridge modes.
246
+ */
247
+ getOutput(): ProcessOutput {
248
+ return { stdout: this._stdout, stderr: this._stderr };
249
+ }
250
+
251
+ // ─────────────────────────────────────────────────────────────────
252
+ // BRIDGE MODE API
253
+ // ─────────────────────────────────────────────────────────────────
254
+
255
+ /**
256
+ * Send a JSON-RPC request and await the response.
257
+ * Only available in bridge mode.
258
+ *
259
+ * @param method - The RPC method to invoke
260
+ * @param params - Parameters to send
261
+ * @param timeout - Timeout in ms (default: 600000)
262
+ * @returns The result from the response
263
+ *
264
+ * @example
265
+ * ```typescript
266
+ * const result = await proc.request('run-inference', { prompt: 'hello' });
267
+ * ```
268
+ */
269
+ async request(method: string, params?: unknown, timeout?: number): Promise<unknown> {
270
+ if (this.mode !== 'bridge') {
271
+ throw new KadiError(
272
+ 'request() is only available in bridge mode',
273
+ 'PROCESS_BRIDGE_ERROR',
274
+ { processId: this.id, mode: this.mode },
275
+ );
276
+ }
277
+
278
+ if (!this.isRunning) {
279
+ throw new KadiError(
280
+ `Process "${this.id}" is not running (state: ${this._state})`,
281
+ 'PROCESS_NOT_RUNNING',
282
+ { processId: this.id, state: this._state },
283
+ );
284
+ }
285
+
286
+ if (!this._reader || !this._writer) {
287
+ throw new KadiError(
288
+ 'Bridge streams not initialized',
289
+ 'PROCESS_BRIDGE_ERROR',
290
+ { processId: this.id },
291
+ );
292
+ }
293
+
294
+ const id = this._bridgeIdCounter++;
295
+ const request = protocol.request(id, method, params ?? {});
296
+
297
+ this._writer.write(request);
298
+ const response = await this._reader.waitForResponse(id, timeout);
299
+
300
+ if (response.error) {
301
+ throw new KadiError(
302
+ response.error.message,
303
+ 'PROCESS_BRIDGE_ERROR',
304
+ { processId: this.id, code: response.error.code, data: response.error.data },
305
+ );
306
+ }
307
+
308
+ return response.result;
309
+ }
310
+
311
+ /**
312
+ * Send a JSON-RPC notification (no response expected).
313
+ * Only available in bridge mode.
314
+ */
315
+ notify(method: string, params?: unknown): void {
316
+ if (this.mode !== 'bridge') {
317
+ throw new KadiError(
318
+ 'notify() is only available in bridge mode',
319
+ 'PROCESS_BRIDGE_ERROR',
320
+ { processId: this.id, mode: this.mode },
321
+ );
322
+ }
323
+
324
+ if (!this.isRunning || !this._writer) {
325
+ throw new KadiError(
326
+ `Process "${this.id}" is not running`,
327
+ 'PROCESS_NOT_RUNNING',
328
+ { processId: this.id },
329
+ );
330
+ }
331
+
332
+ // Notifications have no id
333
+ const notification = {
334
+ jsonrpc: '2.0' as const,
335
+ method,
336
+ params: params ?? {},
337
+ };
338
+
339
+ const json = JSON.stringify(notification);
340
+ const contentLength = Buffer.byteLength(json, 'utf-8');
341
+ const header = `Content-Length: ${contentLength}\r\n\r\n`;
342
+
343
+ this._proc.stdin!.write(header + json);
344
+ }
345
+
346
+ /**
347
+ * Write raw data to the process stdin.
348
+ * Available in piped and bridge modes.
349
+ */
350
+ write(data: string | Buffer): void {
351
+ if (this.mode === 'headless') {
352
+ throw new KadiError(
353
+ 'write() is not available in headless mode',
354
+ 'PROCESS_BRIDGE_ERROR',
355
+ { processId: this.id },
356
+ );
357
+ }
358
+
359
+ if (!this.isRunning || !this._proc.stdin) {
360
+ throw new KadiError(
361
+ `Process "${this.id}" is not running`,
362
+ 'PROCESS_NOT_RUNNING',
363
+ { processId: this.id },
364
+ );
365
+ }
366
+
367
+ this._proc.stdin.write(data);
368
+ }
369
+
370
+ // ─────────────────────────────────────────────────────────────────
371
+ // LIFECYCLE
372
+ // ─────────────────────────────────────────────────────────────────
373
+
374
+ /**
375
+ * Kill this process.
376
+ * Sends SIGTERM, waits for grace period, then SIGKILL.
377
+ */
378
+ async kill(signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
379
+ if (!this.isRunning) return;
380
+
381
+ this._proc.kill(signal);
382
+
383
+ await new Promise<void>((resolve) => {
384
+ const forceKillTimer = setTimeout(() => {
385
+ if (this.isRunning) {
386
+ this._proc.kill('SIGKILL');
387
+ }
388
+ resolve();
389
+ }, this._killGracePeriod);
390
+
391
+ this._proc.once('exit', () => {
392
+ clearTimeout(forceKillTimer);
393
+ resolve();
394
+ });
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Wait for the process to exit.
400
+ * Resolves immediately if already exited.
401
+ */
402
+ waitForExit(): Promise<ProcessExitInfo> {
403
+ if (!this.isRunning) {
404
+ return Promise.resolve({
405
+ exitCode: this._exitCode,
406
+ signal: this._signal,
407
+ duration: this._endedAt
408
+ ? this._endedAt.getTime() - this._startedAt.getTime()
409
+ : 0,
410
+ });
411
+ }
412
+
413
+ return new Promise((resolve) => {
414
+ this._exitWaiters.push({ resolve });
415
+ });
416
+ }
417
+
418
+ /**
419
+ * Get a snapshot of this process's info.
420
+ */
421
+ getInfo(): ProcessInfo {
422
+ const endedAt = this._endedAt;
423
+ return {
424
+ id: this.id,
425
+ pid: this.pid,
426
+ state: this._state,
427
+ mode: this.mode,
428
+ startedAt: this._startedAt,
429
+ endedAt,
430
+ exitCode: this._exitCode,
431
+ signal: this._signal,
432
+ duration: endedAt
433
+ ? endedAt.getTime() - this._startedAt.getTime()
434
+ : null,
435
+ command: this._command,
436
+ args: this._args,
437
+ cwd: this._cwd,
438
+ };
439
+ }
440
+
441
+ // ─────────────────────────────────────────────────────────────────
442
+ // INTERNAL: EXIT HANDLING
443
+ // ─────────────────────────────────────────────────────────────────
444
+
445
+ private handleExit(code: number | null, signal: string | null): void {
446
+ if (this._timeoutTimer) {
447
+ clearTimeout(this._timeoutTimer);
448
+ this._timeoutTimer = null;
449
+ }
450
+
451
+ this._exitCode = code;
452
+ this._signal = signal;
453
+ this._endedAt = new Date();
454
+
455
+ if (this._state === 'running') {
456
+ this._state = signal ? 'killed' : 'exited';
457
+ }
458
+
459
+ // Cancel any pending bridge requests
460
+ if (this._reader) {
461
+ this._reader.cancelAll(
462
+ new KadiError('Process exited', 'PROCESS_NOT_RUNNING', { processId: this.id }),
463
+ );
464
+ }
465
+
466
+ const exitInfo: ProcessExitInfo = {
467
+ exitCode: code,
468
+ signal,
469
+ duration: this._endedAt.getTime() - this._startedAt.getTime(),
470
+ };
471
+
472
+ this.emit('exit', exitInfo);
473
+ this.resolveExitWaiters();
474
+ }
475
+
476
+ private resolveExitWaiters(): void {
477
+ const info: ProcessExitInfo = {
478
+ exitCode: this._exitCode,
479
+ signal: this._signal,
480
+ duration: this._endedAt
481
+ ? this._endedAt.getTime() - this._startedAt.getTime()
482
+ : 0,
483
+ };
484
+
485
+ for (const waiter of this._exitWaiters) {
486
+ waiter.resolve(info);
487
+ }
488
+ this._exitWaiters.length = 0;
489
+ }
490
+ }
491
+
492
+ // ═══════════════════════════════════════════════════════════════════════
493
+ // PROCESS MANAGER
494
+ // ═══════════════════════════════════════════════════════════════════════
495
+
496
+ /**
497
+ * Manages background child processes.
498
+ *
499
+ * Provides spawn, status, kill, and lifecycle management across
500
+ * headless, piped, and bridge execution modes.
501
+ */
502
+ export class ProcessManager {
503
+ /** Active and recently exited processes */
504
+ private readonly processes: Map<string, ManagedProcess> = new Map();
505
+
506
+ /** Cleanup handler registered with process.on('exit') */
507
+ private cleanupRegistered = false;
508
+
509
+ constructor() {
510
+ this.registerCleanup();
511
+ }
512
+
513
+ // ─────────────────────────────────────────────────────────────────
514
+ // SPAWN
515
+ // ─────────────────────────────────────────────────────────────────
516
+
517
+ /**
518
+ * Spawn a new managed background process.
519
+ *
520
+ * @param id - Unique name for this process
521
+ * @param options - Spawn configuration (command, args, mode, etc.)
522
+ * @returns A ManagedProcess handle
523
+ * @throws PROCESS_ALREADY_EXISTS if a running process with this id exists
524
+ * @throws PROCESS_SPAWN_FAILED if the command fails to start
525
+ *
526
+ * @example
527
+ * ```typescript
528
+ * const build = await pm.spawn('build', {
529
+ * command: 'docker',
530
+ * args: ['build', '-t', 'my-app', '.'],
531
+ * cwd: '/path/to/project',
532
+ * mode: 'piped',
533
+ * });
534
+ * ```
535
+ */
536
+ async spawn(id: string, options: SpawnOptions): Promise<ManagedProcess> {
537
+ // Check for duplicate running process
538
+ const existing = this.processes.get(id);
539
+ if (existing && existing.isRunning) {
540
+ throw new KadiError(
541
+ `Process "${id}" is already running`,
542
+ 'PROCESS_ALREADY_EXISTS',
543
+ {
544
+ processId: id,
545
+ pid: existing.pid,
546
+ hint: `Kill it first with pm.kill("${id}") or use a different name`,
547
+ },
548
+ );
549
+ }
550
+
551
+ // Replace dead process entry
552
+ if (existing) {
553
+ this.processes.delete(id);
554
+ }
555
+
556
+ // Determine stdio configuration based on mode
557
+ const stdio = this.getStdioConfig(options.mode);
558
+
559
+ // Spawn the child process
560
+ let proc: ChildProcess;
561
+ try {
562
+ proc = spawn(options.command, options.args ?? [], {
563
+ stdio,
564
+ cwd: options.cwd,
565
+ env: options.env
566
+ ? { ...process.env, ...options.env }
567
+ : undefined,
568
+ detached: options.mode === 'headless', // Detach headless processes
569
+ });
570
+ } catch (error) {
571
+ throw new KadiError(
572
+ `Failed to spawn process "${id}": ${options.command}`,
573
+ 'PROCESS_SPAWN_FAILED',
574
+ {
575
+ processId: id,
576
+ command: options.command,
577
+ args: options.args,
578
+ reason: error instanceof Error ? error.message : String(error),
579
+ hint: 'Check that the command exists and is executable',
580
+ },
581
+ );
582
+ }
583
+
584
+ // Verify PID was assigned
585
+ if (proc.pid === undefined) {
586
+ throw new KadiError(
587
+ `Failed to spawn process "${id}": no PID assigned`,
588
+ 'PROCESS_SPAWN_FAILED',
589
+ { processId: id, command: options.command },
590
+ );
591
+ }
592
+
593
+ // Handle spawn errors (e.g., ENOENT — command not found)
594
+ // This must be set up synchronously before the next tick
595
+ const spawnError = await new Promise<Error | null>((resolve) => {
596
+ proc.once('error', (err) => resolve(err));
597
+ // If no error by next tick, the spawn succeeded
598
+ setImmediate(() => resolve(null));
599
+ });
600
+
601
+ if (spawnError) {
602
+ throw new KadiError(
603
+ `Failed to spawn process "${id}": ${spawnError.message}`,
604
+ 'PROCESS_SPAWN_FAILED',
605
+ {
606
+ processId: id,
607
+ command: options.command,
608
+ reason: spawnError.message,
609
+ hint: 'Check that the command exists and is executable',
610
+ },
611
+ );
612
+ }
613
+
614
+ // Create and track the managed process
615
+ const managed = new ManagedProcess(id, proc, options);
616
+ this.processes.set(id, managed);
617
+
618
+ return managed;
619
+ }
620
+
621
+ // ─────────────────────────────────────────────────────────────────
622
+ // STATUS & QUERYING
623
+ // ─────────────────────────────────────────────────────────────────
624
+
625
+ /**
626
+ * Get status information for a specific process.
627
+ *
628
+ * @param id - Process identifier
629
+ * @returns ProcessInfo snapshot
630
+ * @throws PROCESS_NOT_FOUND if no process with this id exists
631
+ */
632
+ getStatus(id: string): ProcessInfo {
633
+ const proc = this.processes.get(id);
634
+ if (!proc) {
635
+ throw new KadiError(
636
+ `Process "${id}" not found`,
637
+ 'PROCESS_NOT_FOUND',
638
+ {
639
+ processId: id,
640
+ available: Array.from(this.processes.keys()),
641
+ hint: 'Use pm.list() to see all managed processes',
642
+ },
643
+ );
644
+ }
645
+ return proc.getInfo();
646
+ }
647
+
648
+ /**
649
+ * Get the ManagedProcess handle for a specific process.
650
+ * Useful when you need to attach events or call bridge methods.
651
+ *
652
+ * @param id - Process identifier
653
+ * @returns The ManagedProcess instance
654
+ * @throws PROCESS_NOT_FOUND if no process with this id exists
655
+ */
656
+ get(id: string): ManagedProcess {
657
+ const proc = this.processes.get(id);
658
+ if (!proc) {
659
+ throw new KadiError(
660
+ `Process "${id}" not found`,
661
+ 'PROCESS_NOT_FOUND',
662
+ {
663
+ processId: id,
664
+ available: Array.from(this.processes.keys()),
665
+ },
666
+ );
667
+ }
668
+ return proc;
669
+ }
670
+
671
+ /**
672
+ * Get buffered output for a process (piped/bridge modes).
673
+ *
674
+ * @param id - Process identifier
675
+ * @returns Buffered stdout and stderr
676
+ */
677
+ getOutput(id: string): ProcessOutput {
678
+ return this.get(id).getOutput();
679
+ }
680
+
681
+ /**
682
+ * List all managed processes, optionally filtered by state.
683
+ *
684
+ * @param options - Optional filter criteria
685
+ * @returns Array of ProcessInfo snapshots
686
+ */
687
+ list(options?: ProcessListOptions): ProcessInfo[] {
688
+ const results: ProcessInfo[] = [];
689
+
690
+ for (const proc of this.processes.values()) {
691
+ const info = proc.getInfo();
692
+ if (options?.state && info.state !== options.state) continue;
693
+ if (options?.mode && info.mode !== options.mode) continue;
694
+ results.push(info);
695
+ }
696
+
697
+ return results;
698
+ }
699
+
700
+ // ─────────────────────────────────────────────────────────────────
701
+ // LIFECYCLE MANAGEMENT
702
+ // ─────────────────────────────────────────────────────────────────
703
+
704
+ /**
705
+ * Wait for a process to exit.
706
+ *
707
+ * @param id - Process identifier
708
+ * @returns Exit information
709
+ */
710
+ async waitFor(id: string): Promise<ProcessExitInfo> {
711
+ return this.get(id).waitForExit();
712
+ }
713
+
714
+ /**
715
+ * Kill a specific process.
716
+ *
717
+ * @param id - Process identifier
718
+ * @param signal - Signal to send (default: SIGTERM)
719
+ */
720
+ async kill(id: string, signal?: NodeJS.Signals): Promise<void> {
721
+ const proc = this.processes.get(id);
722
+ if (!proc) {
723
+ throw new KadiError(
724
+ `Process "${id}" not found`,
725
+ 'PROCESS_NOT_FOUND',
726
+ { processId: id },
727
+ );
728
+ }
729
+ await proc.kill(signal);
730
+ }
731
+
732
+ /**
733
+ * Kill all running managed processes.
734
+ */
735
+ async killAll(): Promise<void> {
736
+ const kills: Promise<void>[] = [];
737
+
738
+ for (const proc of this.processes.values()) {
739
+ if (proc.isRunning) {
740
+ kills.push(proc.kill());
741
+ }
742
+ }
743
+
744
+ await Promise.allSettled(kills);
745
+ }
746
+
747
+ /**
748
+ * Remove exited/errored/killed processes from tracking.
749
+ *
750
+ * @param options - Optional criteria for which processes to remove
751
+ */
752
+ prune(options?: ProcessPruneOptions): void {
753
+ const now = Date.now();
754
+
755
+ for (const [id, proc] of this.processes) {
756
+ if (proc.isRunning) continue;
757
+
758
+ const info = proc.getInfo();
759
+ if (options?.olderThan !== undefined) {
760
+ const endedAt = info.endedAt?.getTime() ?? now;
761
+ const ageSeconds = (now - endedAt) / 1000;
762
+ if (ageSeconds >= options.olderThan) {
763
+ this.processes.delete(id);
764
+ }
765
+ } else {
766
+ // No filter — remove all non-running
767
+ this.processes.delete(id);
768
+ }
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Graceful shutdown: SIGTERM all running processes, wait, then SIGKILL remainders.
774
+ */
775
+ async shutdown(): Promise<void> {
776
+ await this.killAll();
777
+ this.prune();
778
+ }
779
+
780
+ // ─────────────────────────────────────────────────────────────────
781
+ // INTERNAL
782
+ // ─────────────────────────────────────────────────────────────────
783
+
784
+ /**
785
+ * Determine stdio configuration based on process mode.
786
+ */
787
+ private getStdioConfig(mode: ProcessMode): Array<'pipe' | 'ignore' | 'inherit'> {
788
+ switch (mode) {
789
+ case 'headless':
790
+ return ['ignore', 'ignore', 'ignore'];
791
+ case 'piped':
792
+ return ['ignore', 'pipe', 'pipe'];
793
+ case 'bridge':
794
+ return ['pipe', 'pipe', 'pipe'];
795
+ }
796
+ }
797
+
798
+ /**
799
+ * Register a cleanup handler to kill child processes when the parent exits.
800
+ */
801
+ private registerCleanup(): void {
802
+ if (this.cleanupRegistered) return;
803
+ this.cleanupRegistered = true;
804
+
805
+ const cleanup = () => {
806
+ for (const proc of this.processes.values()) {
807
+ if (proc.isRunning) {
808
+ try {
809
+ proc.kill('SIGTERM').catch(() => {});
810
+ } catch {
811
+ // Best-effort cleanup
812
+ }
813
+ }
814
+ }
815
+ };
816
+
817
+ process.on('exit', cleanup);
818
+ process.on('SIGTERM', cleanup);
819
+ process.on('SIGINT', cleanup);
820
+ }
821
+ }