@aliou/pi-processes 0.11.2 → 0.12.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.
@@ -4,6 +4,7 @@ import type { ProcessManager } from "../../../src/manager";
4
4
  import type { KillResult } from "../../../src/types";
5
5
  import {
6
6
  CHANNELS,
7
+ type CommandAdoptPayload,
7
8
  type CommandClearPayload,
8
9
  type CommandKillPayload,
9
10
  type CommandStartPayload,
@@ -51,13 +52,36 @@ export function registerCommandHandlers(
51
52
 
52
53
  safeReply(command.reply, manager.clearFinished());
53
54
  }),
55
+ events.on(CHANNELS.COMMAND_ADOPT, (payload) => {
56
+ const command = payload as CommandAdoptPayload;
57
+
58
+ try {
59
+ const info = manager.adopt(
60
+ command.name,
61
+ command.command,
62
+ command.cwd,
63
+ command.child,
64
+ {
65
+ initialStdout: command.initialStdout,
66
+ initialStderr: command.initialStderr,
67
+ startTime: command.startTime,
68
+ },
69
+ );
70
+ notifications.register(info.id, {});
71
+ safeReply(command.reply, { ok: true, info });
72
+ } catch (error) {
73
+ safeReply(command.reply, {
74
+ ok: false,
75
+ error: error instanceof Error ? error.message : String(error),
76
+ });
77
+ }
78
+ }),
54
79
  ];
55
80
 
56
81
  return () => {
57
82
  for (const dispose of disposers) dispose();
58
83
  };
59
84
  }
60
-
61
85
  function safeReply<T>(reply: (result: T) => void, result: T): void {
62
86
  try {
63
87
  reply(result);
@@ -18,6 +18,9 @@ export const CHANNELS = {
18
18
  COMMAND_START: "processes:command:start",
19
19
  COMMAND_KILL: "processes:command:kill",
20
20
  COMMAND_CLEAR: "processes:command:clear",
21
+ // Other extensions emit this to hand an already-running child process
22
+ // over to the manager (e.g. backgrounding a foreground tool command).
23
+ COMMAND_ADOPT: "processes:command:adopt",
21
24
  // Pin handled by the dock extension, if loaded.
22
25
  COMMAND_PIN: "processes:command:pin",
23
26
 
@@ -1,3 +1,5 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+
1
3
  import type { KillResult, ProcessInfo } from "../../../src/types";
2
4
 
3
5
  // UI emits, core handles then calls reply.
@@ -33,3 +35,27 @@ export interface CommandPinPayload {
33
35
  }
34
36
 
35
37
  export type CommandPinResult = { ok: true } | { ok: false; error: string };
38
+
39
+ // Another extension emits this to hand an already-running child process over
40
+ // to the manager. The child must have been spawned in a detached process
41
+ // group (`detached: true`) with piped stdio — the same shape the manager's
42
+ // own spawns use — so group kill and liveness polling work on it. Payloads
43
+ // cross the event bus by reference, so the live ChildProcess handle arrives
44
+ // intact. If the processes extension is not loaded, no listener replies.
45
+ export interface CommandAdoptPayload {
46
+ name: string;
47
+ command: string;
48
+ cwd: string;
49
+ child: ChildProcess;
50
+ /** Stdout captured before handover. */
51
+ initialStdout?: Buffer;
52
+ /** Stderr captured before handover. */
53
+ initialStderr?: Buffer;
54
+ /** When the command actually started (epoch ms). */
55
+ startTime?: number;
56
+ reply: (result: CommandAdoptResult) => void;
57
+ }
58
+
59
+ export type CommandAdoptResult =
60
+ | { ok: true; info: ProcessInfo }
61
+ | { ok: false; error: string };
@@ -6,6 +6,8 @@ export type {
6
6
  } from "./broadcasts";
7
7
  export { CHANNELS } from "./channels";
8
8
  export type {
9
+ CommandAdoptPayload,
10
+ CommandAdoptResult,
9
11
  CommandClearPayload,
10
12
  CommandKillPayload,
11
13
  CommandPinPayload,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,6 +1,8 @@
1
+ import type { ChildProcess } from "node:child_process";
1
2
  import { EventEmitter } from "node:events";
2
3
 
3
4
  import type {
5
+ AdoptProcessOptions,
4
6
  KillResult,
5
7
  ManagerEvent,
6
8
  ProcessInfo,
@@ -57,6 +59,22 @@ export class ProcessManager {
57
59
  return formatProcess(managed);
58
60
  }
59
61
 
62
+ /**
63
+ * Adopt an externally spawned child process. The child must run in a
64
+ * detached process group with piped stdio (see
65
+ * ProcessRuntimeController.adopt for the full contract).
66
+ */
67
+ adopt(
68
+ name: string,
69
+ command: string,
70
+ cwd: string,
71
+ child: ChildProcess,
72
+ opts?: AdoptProcessOptions,
73
+ ): ProcessInfo {
74
+ const managed = this.runtime.adopt(name, command, cwd, child, opts);
75
+ return formatProcess(managed);
76
+ }
77
+
60
78
  list(): ProcessInfo[] {
61
79
  return this.registry.list();
62
80
  }
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs";
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
-
16
+ import { trimIncompleteUtf8Suffix } from "../utils/buffer";
17
17
  import type { ProcessLogPaths } from "./internal-types";
18
18
  import { MAX_LOG_FILE_BYTES, MAX_TAIL_READ_BYTES } from "./limits";
19
19
 
@@ -401,22 +401,3 @@ function decodeUtf8Bounded(buffer: Buffer, maxOutputBytes: number): string {
401
401
 
402
402
  return parts.join("");
403
403
  }
404
-
405
- function trimIncompleteUtf8Suffix(buffer: Buffer): Buffer {
406
- if (buffer.length === 0) return buffer;
407
- let lead = buffer.length - 1;
408
- while (lead >= 0 && (buffer[lead] & 0xc0) === 0x80) lead--;
409
- if (lead < 0) return Buffer.alloc(0);
410
- const byte = buffer[lead];
411
- const expected =
412
- byte < 0x80
413
- ? 1
414
- : (byte & 0xe0) === 0xc0
415
- ? 2
416
- : (byte & 0xf0) === 0xe0
417
- ? 3
418
- : (byte & 0xf8) === 0xf0
419
- ? 4
420
- : 1;
421
- return buffer.length - lead < expected ? buffer.subarray(0, lead) : buffer;
422
- }
@@ -1,4 +1,5 @@
1
1
  import type { ManagerEvent } from "../types";
2
+ import { trimIncompleteUtf8Suffix } from "../utils/buffer";
2
3
  import type { ManagedProcessRecord } from "./internal-types";
3
4
  import {
4
5
  MAX_LINE_BYTES,
@@ -321,25 +322,3 @@ export class ProcessOutput {
321
322
  this.clearAll();
322
323
  }
323
324
  }
324
-
325
- function trimIncompleteUtf8Suffix(buffer: Buffer): Buffer {
326
- if (buffer.length === 0) return buffer;
327
-
328
- let lead = buffer.length - 1;
329
- while (lead >= 0 && (buffer[lead] & 0xc0) === 0x80) lead--;
330
- if (lead < 0) return Buffer.alloc(0);
331
-
332
- const leadByte = buffer[lead];
333
- const expectedLength =
334
- leadByte < 0x80
335
- ? 1
336
- : (leadByte & 0xe0) === 0xc0
337
- ? 2
338
- : (leadByte & 0xf0) === 0xe0
339
- ? 3
340
- : (leadByte & 0xf8) === 0xf0
341
- ? 4
342
- : 1;
343
- const actualLength = buffer.length - lead;
344
- return actualLength < expectedLength ? buffer.subarray(0, lead) : buffer;
345
- }
@@ -1,13 +1,23 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
2
 
3
- import type { KillResult, ManagerEvent, WriteResult } from "../types";
3
+ import type {
4
+ AdoptProcessOptions,
5
+ KillResult,
6
+ ManagerEvent,
7
+ WriteResult,
8
+ } from "../types";
4
9
  import { LIVE_STATUSES } from "../types";
5
10
  import { isProcessGroupAlive, killProcessGroup } from "../utils";
11
+ import { clampToTail } from "../utils/buffer";
6
12
  import { spawnCommand } from "../utils/command-executor";
7
13
  import { formatSignalInfo } from "../utils/signals";
8
14
  import type { ManagedProcessRecord } from "./internal-types";
9
15
  import { formatProcess } from "./internal-types";
10
- import { FINISHED_RECORD_GRACE_MS, MAX_FINISHED_RECORDS } from "./limits";
16
+ import {
17
+ FINISHED_RECORD_GRACE_MS,
18
+ MAX_FINISHED_RECORDS,
19
+ MAX_TAIL_READ_BYTES,
20
+ } from "./limits";
11
21
  import type { ProcessLogStore } from "./process-log-store";
12
22
  import type { ProcessOutput } from "./process-output";
13
23
  import type { ProcessRegistry } from "./process-registry";
@@ -41,10 +51,45 @@ export class ProcessRuntimeController {
41
51
  }
42
52
 
43
53
  start(name: string, command: string, cwd: string): ManagedProcessRecord {
54
+ const child = spawnCommand(command, cwd, this.getConfiguredShellPath());
55
+ return this.register(name, command, cwd, child, {});
56
+ }
57
+
58
+ /**
59
+ * Adopt an externally spawned child process into the manager.
60
+ *
61
+ * The child must have been spawned like `spawnCommand` spawns: in a
62
+ * detached process group (`detached: true`) with piped stdio, so group
63
+ * kill and liveness polling behave identically to started processes.
64
+ *
65
+ * `initialStdout` / `initialStderr` are pre-handover output, each
66
+ * prepended to the matching log and clamped to MAX_TAIL_READ_BYTES.
67
+ * `startTime` backdates the record to when the command actually began.
68
+ */
69
+ adopt(
70
+ name: string,
71
+ command: string,
72
+ cwd: string,
73
+ child: ChildProcess,
74
+ opts?: AdoptProcessOptions,
75
+ ): ManagedProcessRecord {
76
+ return this.register(name, command, cwd, child, {
77
+ initialStdout: opts?.initialStdout,
78
+ initialStderr: opts?.initialStderr,
79
+ startTime: opts?.startTime,
80
+ });
81
+ }
82
+
83
+ private register(
84
+ name: string,
85
+ command: string,
86
+ cwd: string,
87
+ child: ChildProcess,
88
+ opts: AdoptProcessOptions,
89
+ ): ManagedProcessRecord {
44
90
  const id = this.registry.nextId();
45
91
  const logPaths = this.logs.createLogs(id);
46
92
 
47
- const child = spawnCommand(command, cwd, this.getConfiguredShellPath());
48
93
  // Spawned commands run in detached process groups so TERM/KILL can target
49
94
  // the whole tree. `unref()` keeps the manager's Node process from staying
50
95
  // alive only because a managed child still exists; extension shutdown and
@@ -57,7 +102,7 @@ export class ProcessRuntimeController {
57
102
  pid: child.pid ?? -1,
58
103
  command,
59
104
  cwd,
60
- startTime: Date.now(),
105
+ startTime: opts.startTime ?? Date.now(),
61
106
  endTime: null,
62
107
  status: "running",
63
108
  exitCode: null,
@@ -107,14 +152,64 @@ export class ProcessRuntimeController {
107
152
  return managed;
108
153
  }
109
154
 
155
+ if (opts.initialStdout && opts.initialStdout.length > 0) {
156
+ const clamped = clampToTail(opts.initialStdout, MAX_TAIL_READ_BYTES);
157
+ this.logs.appendStdout(managed.stdoutFile, clamped);
158
+ this.output.onStdoutChunk(managed, clamped);
159
+ }
160
+ if (opts.initialStderr && opts.initialStderr.length > 0) {
161
+ const clamped = clampToTail(opts.initialStderr, MAX_TAIL_READ_BYTES);
162
+ this.logs.appendStderr(managed.stderrFile, clamped);
163
+ this.output.onStderrChunk(managed, clamped);
164
+ }
165
+
110
166
  this.wireStdioHandlers(managed, child);
111
167
 
112
168
  this.emit({ type: "process_started", info: formatProcess(managed) });
169
+ this.finalizeIfAlreadyClosed(managed, child);
113
170
  this.ensureWatcherRunning();
114
171
 
115
172
  return managed;
116
173
  }
117
174
 
175
+ /**
176
+ * An adopted child may have fully exited (close event fired) before its
177
+ * handlers were attached here. In that case no close event will ever
178
+ * reach wireStdioHandlers, so replay the close classification directly.
179
+ * If the child exited but its streams are still open, the pending close
180
+ * event will finalize the record through the normal path.
181
+ */
182
+ private finalizeIfAlreadyClosed(
183
+ managed: ManagedProcessRecord,
184
+ child: ChildProcess,
185
+ ): void {
186
+ const exited = child.exitCode !== null || child.signalCode !== null;
187
+ if (!exited) return;
188
+
189
+ const streamsDone =
190
+ (child.stdout?.destroyed ?? true) && (child.stderr?.destroyed ?? true);
191
+ if (!streamsDone) return;
192
+
193
+ this.releaseRuntimeHandles(managed);
194
+ if (managed.endTime) return;
195
+
196
+ managed.exitCode = child.exitCode;
197
+ managed.endTime = Date.now();
198
+ this.output.flush(managed);
199
+
200
+ if (child.signalCode) {
201
+ managed.success = false;
202
+ managed.endReason = "signal";
203
+ managed.signal = formatSignalInfo(child.signalCode);
204
+ this.transition(managed, "killed");
205
+ } else {
206
+ managed.success = child.exitCode === 0;
207
+ managed.endReason = "exit";
208
+ managed.signal = null;
209
+ this.transition(managed, "exited");
210
+ }
211
+ }
212
+
118
213
  transition(managed: ManagedProcessRecord, next: typeof managed.status): void {
119
214
  if (managed.status === next) return;
120
215
  managed.status = next;
package/src/types.ts CHANGED
@@ -54,6 +54,16 @@ export type ManagerEvent =
54
54
  }
55
55
  | { type: "processes_changed" };
56
56
 
57
+ /** Options for adopting an externally spawned child process. */
58
+ export interface AdoptProcessOptions {
59
+ /** Pre-handover stdout; prepended to the stdout log, clamped to MAX_TAIL_READ_BYTES. */
60
+ initialStdout?: Buffer;
61
+ /** Pre-handover stderr; prepended to the stderr log, clamped to MAX_TAIL_READ_BYTES. */
62
+ initialStderr?: Buffer;
63
+ /** When the command actually started (epoch ms). Defaults to adoption time. */
64
+ startTime?: number;
65
+ }
66
+
57
67
  export type KillResult =
58
68
  | { ok: true; info: ProcessInfo }
59
69
  | { ok: false; info: ProcessInfo; reason: "not_found" | "timeout" | "error" };
@@ -0,0 +1,28 @@
1
+ /** Trim trailing bytes that form an incomplete UTF-8 code point. */
2
+ export function trimIncompleteUtf8Suffix(buffer: Buffer): Buffer {
3
+ if (buffer.length === 0) return buffer;
4
+
5
+ let lead = buffer.length - 1;
6
+ while (lead >= 0 && (buffer[lead] & 0xc0) === 0x80) lead--;
7
+ if (lead < 0) return Buffer.alloc(0);
8
+
9
+ const leadByte = buffer[lead];
10
+ const expectedLength =
11
+ leadByte < 0x80
12
+ ? 1
13
+ : (leadByte & 0xe0) === 0xc0
14
+ ? 2
15
+ : (leadByte & 0xf0) === 0xe0
16
+ ? 3
17
+ : (leadByte & 0xf8) === 0xf0
18
+ ? 4
19
+ : 1;
20
+ const actualLength = buffer.length - lead;
21
+ return actualLength < expectedLength ? buffer.subarray(0, lead) : buffer;
22
+ }
23
+
24
+ /** Keep the last `maxBytes` of a buffer, trimming any incomplete UTF-8 sequence. */
25
+ export function clampToTail(buf: Buffer, maxBytes: number): Buffer {
26
+ if (buf.length <= maxBytes) return buf;
27
+ return trimIncompleteUtf8Suffix(buf.subarray(buf.length - maxBytes));
28
+ }
@@ -1,4 +1,5 @@
1
1
  export { hasAnsi, stripAnsi } from "./ansi";
2
+ export { clampToTail, trimIncompleteUtf8Suffix } from "./buffer";
2
3
  export { resolveShellExecutable, spawnCommand } from "./command-executor";
3
4
  export { formatRuntime, formatStatus, formatTimestamp } from "./format";
4
5
  export type { LineMatchMode } from "./match-line";