@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.
- package/extensions/processes/handlers/commands.ts +25 -1
- package/extensions/processes/notification-sender.ts +8 -3
- package/extensions/processes/tools/index.ts +1 -1
- package/extensions/processes/tools/notify.ts +4 -6
- package/extensions/processes/tools/schema.ts +1 -1
- package/extensions/shared/protocol/channels.ts +3 -0
- package/extensions/shared/protocol/commands.ts +26 -0
- package/extensions/shared/protocol/index.ts +2 -0
- package/package.json +1 -1
- package/skills/pi-processes/SKILL.md +11 -12
- package/src/manager/index.ts +18 -0
- package/src/manager/process-log-store.ts +1 -20
- package/src/manager/process-output.ts +1 -22
- package/src/manager/process-runtime-controller.ts +99 -4
- package/src/types.ts +10 -0
- package/src/utils/buffer.ts +28 -0
- package/src/utils/index.ts +1 -0
|
@@ -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
|
-
/**
|
|
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: "
|
|
28
|
+
return { triggerTurn: false, deliverAs: "nextTurn" };
|
|
24
29
|
case "ignore":
|
|
25
|
-
return { triggerTurn: false, deliverAs: "
|
|
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
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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 };
|
package/package.json
CHANGED
|
@@ -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
|
|
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`.
|
|
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` —
|
|
236
|
-
- `context` —
|
|
237
|
-
- `ignore` —
|
|
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`
|
|
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
|
-
- **
|
|
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
|
{
|
package/src/manager/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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";
|