@aliou/pi-processes 0.11.1 → 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);
@@ -12,7 +12,12 @@ export interface ProcessNotificationSendOptions {
12
12
  deliverAs: "steer" | "followUp" | "nextTurn";
13
13
  }
14
14
 
15
- /** Maps a notification attention level to Pi send-message options. */
15
+ /**
16
+ * Maps a notification attention level to Pi send-message options.
17
+ *
18
+ * `turn` wakes or steers the agent. Other emitted notifications wait for the
19
+ * next user prompt so they cannot split a tool call from its result.
20
+ */
16
21
  export function attentionToSendOptions(
17
22
  attention: Attention,
18
23
  ): ProcessNotificationSendOptions {
@@ -20,9 +25,9 @@ export function attentionToSendOptions(
20
25
  case "turn":
21
26
  return { triggerTurn: true, deliverAs: "steer" };
22
27
  case "context":
23
- return { triggerTurn: false, deliverAs: "steer" };
28
+ return { triggerTurn: false, deliverAs: "nextTurn" };
24
29
  case "ignore":
25
- return { triggerTurn: false, deliverAs: "steer" };
30
+ return { triggerTurn: false, deliverAs: "nextTurn" };
26
31
  }
27
32
  }
28
33
 
@@ -56,7 +56,7 @@ export function registerProcessTool(
56
56
  promptGuidelines: [
57
57
  "process tool: use process start for long-running commands (dev servers, watchers, builds) instead of shell background patterns like &, nohup, or setsid; give each process a specific name and check process list first when a duplicate would be noisy.",
58
58
  "process tool: after process start, do not sleep, poll, or hold your turn. End your turn or move on. Exits and notify.logMatches matches bring you back.",
59
- "process tool: attention turn wakes you when idle; context only reaches you if you are still working; ignore never notifies. Keep turn for anything whose result you need.",
59
+ "process tool: attention turn wakes or steers you now; context waits for the next user prompt. Ignore suppresses successful exits and external kills but retains log matches as context. Failures always notify, with ignore downgraded to context. Keep turn for anything whose result you need immediately.",
60
60
  "process tool: use notify.logMatches to get brought back on readiness or error signals instead of polling process output. If a watch is too noisy, use process update (watches.mode append/replace/remove/clear) to fix it without restarting.",
61
61
  "process tool: for the full lifecycle (start, list, output, update, write, stop, clear), notify options, use cases, and noisy-watch handling, read the pi-processes skill.",
62
62
  ],
@@ -6,12 +6,10 @@ import type { LogMatcherConfig, NotifyConfig } from "../notifications/registry";
6
6
  import type { NotifyLogMatchParamsType, NotifyParamsType } from "./schema";
7
7
 
8
8
  const DEFAULT_NOTIFY_CONFIG = {
9
- // A backgrounded process usually outlives the turn that started it, and
10
- // "context" only reaches the agent if it happens to still be streaming when
11
- // the process ends. Builds, tests, and other one-shot commands are started
12
- // precisely because the agent needs the result, so success defaults to a
13
- // turn. Long-running servers rarely exit 0, and callers that do not want the
14
- // interruption can pass onSuccess: "context".
9
+ // Builds, tests, and other one-shot commands are started because the agent
10
+ // needs the result, so success defaults to a turn. Long-running servers
11
+ // rarely exit 0, and callers that only need the result as future context can
12
+ // pass onSuccess: "context".
15
13
  onSuccess: "turn",
16
14
  onFailure: "turn",
17
15
  // External kills (outside this manager) surface as context by default so
@@ -142,7 +142,7 @@ const NotifyProperties = {
142
142
 
143
143
  export const NotifyParams = Type.Object(NotifyProperties, {
144
144
  description:
145
- "Notify settings. Attention: turn wakes an idle agent, context only reaches an agent still working, ignore never notifies.",
145
+ "Notify settings. Attention: turn wakes or steers the agent; context waits for the next user prompt; ignore suppresses successful exits and external kills but retains log matches as context. Failures always notify, with ignore downgraded to context.",
146
146
  });
147
147
 
148
148
  export const ProcessesParams = Type.Object({
@@ -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.1",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -16,10 +16,9 @@ A started process runs in the background and the manager brings you back when so
16
16
  3. The process notifies you when:
17
17
  - a `logMatches` pattern hits (readiness, error, progress),
18
18
  - the process exits successfully (`onSuccess`, default `turn`),
19
- - the process fails or crashes (`onFailure`, default `turn`),
20
- - the process is killed externally (`onKilled`, default `context`, which does not wake an idle agent).
21
-
22
- Stopping a process yourself never notifies.
19
+ - the process fails or crashes (`onFailure`, default `turn`; failures always notify),
20
+ - the process is killed externally (`onKilled`, default `context`),
21
+ - you stop the process intentionally (always `context`; notify config does not apply).
23
22
  4. When a watch is too noisy or wrong, fix it with `process update` — do not restart the process just to change watches.
24
23
  5. `process stop` obsolete live processes and `process clear` finished entries when they are no longer useful.
25
24
 
@@ -52,7 +51,7 @@ Good:
52
51
  }
53
52
  ```
54
53
 
55
- `onSuccess: "context"` here because a dev server exiting cleanly needs no reaction. Keep the default `turn` for builds, tests, and other one-shot commands whose result you need.
54
+ `onSuccess: "context"` here because a dev server exiting cleanly needs no immediate reaction; the result becomes context on the next user prompt. Keep the default `turn` for builds, tests, and other one-shot commands whose result you need immediately.
56
55
 
57
56
  Optional `cwd` sets the working directory for the spawned command. Omit it to inherit the agent's current working directory.
58
57
 
@@ -219,8 +218,8 @@ Good:
219
218
  Exit attention:
220
219
 
221
220
  - `notify.onSuccess` — clean exit. Defaults to `turn`.
222
- - `notify.onFailure` — failure or crash. Defaults to `turn`.
223
- - `notify.onKilled` — killed from outside the tool. Defaults to `context`. Stopping a process yourself never notifies.
221
+ - `notify.onFailure` — failure or crash. Defaults to `turn`; `ignore` is downgraded to `context` because failures always notify.
222
+ - `notify.onKilled` — killed from outside the tool. Defaults to `context`. Intentional stops always produce context and bypass this setting.
224
223
 
225
224
  Log match watches (`notify.logMatches`, up to 20, each pattern up to 500 chars):
226
225
 
@@ -232,11 +231,11 @@ Log match watches (`notify.logMatches`, up to 20, each pattern up to 500 chars):
232
231
 
233
232
  Attention levels:
234
233
 
235
- - `turn` — starts an agent turn. Reaches you even when you are idle.
236
- - `context` — recorded in the transcript, no turn. It reaches you only if you are still working when the event fires; an idle agent is not woken and sees it on the next user message.
237
- - `ignore` — recorded, never notifies.
234
+ - `turn` — wakes an idle agent or steers an active run at the next safe boundary.
235
+ - `context` — queued for the next user prompt; it does not wake or steer the agent.
236
+ - `ignore` — suppresses successful-exit and external-kill notifications. Log matches are retained for the next user prompt. Failure and crash notifications are also retained because failures always notify.
238
237
 
239
- Use `context` only when nothing needs to happen in response.
238
+ Use `context` when nothing needs to happen immediately.
240
239
 
241
240
  ## Use cases
242
241
 
@@ -368,7 +367,7 @@ Then pick a `watches.mode`:
368
367
  }
369
368
  ```
370
369
 
371
- - **Silence without removing** — set the watch's `on` to `ignore` so matches are recorded but do not interrupt.
370
+ - **Retain without interrupting** — set the watch's `on` to `ignore` so matches become context on the next user prompt.
372
371
 
373
372
  ```json
374
373
  {
@@ -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";