@aliou/pi-processes 0.9.4 → 0.10.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/CONTRIBUTING.md +40 -42
- package/README.md +54 -84
- package/extensions/process-tabs.ts +26 -0
- package/extensions/processes/client.ts +194 -0
- package/extensions/processes/commands/clear.ts +29 -0
- package/extensions/processes/commands/kill.ts +152 -0
- package/extensions/processes/commands/overview.ts +102 -0
- package/extensions/processes/components/overview-component.ts +729 -0
- package/extensions/processes/components/overview-panel.ts +177 -0
- package/extensions/processes/config/defaults.ts +28 -0
- package/extensions/processes/config/index.ts +13 -0
- package/extensions/processes/config/loader.ts +62 -0
- package/extensions/processes/config/migrations/000-import-legacy-process-config.ts +113 -0
- package/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +184 -0
- package/extensions/processes/config/migrations/002-stamp-v0-10-0-config-version.ts +21 -0
- package/extensions/processes/config/migrations/index.ts +25 -0
- package/extensions/processes/config/schema.ts +9 -0
- package/extensions/processes/config/types.ts +72 -0
- package/extensions/processes/constants.ts +1 -0
- package/extensions/processes/handlers/commands.ts +113 -0
- package/extensions/processes/handlers/kill-process.ts +31 -0
- package/extensions/processes/handlers/notifications.ts +147 -0
- package/extensions/processes/handlers/requests.ts +114 -0
- package/extensions/processes/handlers/subscriptions.ts +144 -0
- package/extensions/processes/hooks/background-blocker.ts +201 -0
- package/extensions/processes/hooks/cleanup.ts +43 -0
- package/extensions/processes/hooks/event-bridge.ts +32 -0
- package/extensions/processes/i18n/index.ts +3 -0
- package/extensions/processes/i18n/messages.ts +27 -0
- package/extensions/processes/i18n/translator.ts +49 -0
- package/extensions/processes/index.ts +109 -0
- package/extensions/processes/message-renderer.ts +117 -0
- package/extensions/processes/notification-sender.ts +43 -0
- package/extensions/processes/notifications/classify.ts +27 -0
- package/extensions/processes/notifications/log-matchers.ts +146 -0
- package/extensions/processes/notifications/registry.ts +225 -0
- package/extensions/processes/notifications/render-content.ts +122 -0
- package/extensions/processes/notifications/service.ts +336 -0
- package/extensions/processes/notifications/types.ts +11 -0
- package/extensions/processes/settings/apply-setting-change.ts +30 -0
- package/extensions/processes/settings/build-sections.ts +420 -0
- package/extensions/processes/settings/index.ts +42 -0
- package/extensions/processes/tools/clear/index.ts +21 -0
- package/extensions/processes/tools/clear/render.ts +40 -0
- package/extensions/processes/tools/components/index.ts +4 -0
- package/extensions/processes/tools/components/process-action-header.ts +31 -0
- package/extensions/processes/tools/components/process-action-title.ts +17 -0
- package/extensions/processes/tools/components/tool-layout.ts +70 -0
- package/extensions/processes/tools/components/watch.ts +53 -0
- package/extensions/processes/tools/index.ts +265 -0
- package/extensions/processes/tools/list/index.ts +157 -0
- package/extensions/processes/tools/list/render.ts +211 -0
- package/extensions/processes/tools/notify.ts +191 -0
- package/extensions/processes/tools/output/index.ts +307 -0
- package/extensions/processes/tools/output/render.ts +229 -0
- package/extensions/processes/tools/schema.ts +263 -0
- package/extensions/processes/tools/start/index.ts +64 -0
- package/extensions/processes/tools/start/render.ts +67 -0
- package/extensions/processes/tools/stop/index.ts +35 -0
- package/extensions/processes/tools/stop/render.ts +43 -0
- package/extensions/processes/tools/update/index.ts +320 -0
- package/extensions/processes/tools/update/render.ts +251 -0
- package/extensions/processes/tools/utils.ts +112 -0
- package/extensions/processes/tools/watch-format.ts +14 -0
- package/extensions/processes/tools/write/index.ts +75 -0
- package/extensions/processes/tools/write/render.ts +68 -0
- package/extensions/processes/utils/truncate.ts +285 -0
- package/extensions/processes-dock/client.ts +60 -0
- package/extensions/processes-dock/commands/dock.ts +70 -0
- package/extensions/processes-dock/commands/pin.ts +166 -0
- package/extensions/processes-dock/components/log-dock-component.ts +375 -0
- package/extensions/processes-dock/dock-state.ts +33 -0
- package/extensions/processes-dock/index.ts +28 -0
- package/extensions/processes-dock/logs-client.ts +5 -0
- package/extensions/processes-dock/widget/setup.ts +520 -0
- package/extensions/processes-dock/widget/status.ts +149 -0
- package/extensions/processes-dock/widget/types.ts +16 -0
- package/extensions/processes-logs/client.ts +51 -0
- package/extensions/processes-logs/commands/logs.ts +108 -0
- package/extensions/processes-logs/completions.ts +31 -0
- package/extensions/processes-logs/components/log-file-viewer.ts +274 -0
- package/extensions/processes-logs/components/log-overlay-component.ts +685 -0
- package/extensions/processes-logs/index.ts +27 -0
- package/extensions/processes-logs/logs-client.ts +101 -0
- package/extensions/shared/line-buffer.ts +35 -0
- package/extensions/shared/output-payload.ts +28 -0
- package/extensions/shared/ui.ts +146 -0
- package/package.json +47 -20
- package/schema.json +134 -0
- package/skills/pi-processes/SKILL.md +496 -25
- package/src/get-manager.ts +15 -0
- package/src/manager/index.ts +182 -0
- package/src/manager/internal-types.ts +130 -0
- package/src/manager/limits.ts +15 -0
- package/src/manager/process-log-store.ts +422 -0
- package/src/manager/process-output.ts +345 -0
- package/src/manager/process-registry.ts +78 -0
- package/src/manager/process-runtime-controller.ts +499 -0
- package/src/protocol/broadcasts.ts +14 -0
- package/src/protocol/channels.ts +30 -0
- package/src/protocol/commands.ts +24 -0
- package/src/protocol/index.ts +34 -0
- package/src/protocol/logs.ts +23 -0
- package/src/protocol/notifications.ts +48 -0
- package/src/protocol/requests.ts +76 -0
- package/src/types.ts +66 -0
- package/src/utils/ansi.ts +2 -4
- package/src/utils/format.ts +24 -32
- package/src/utils/index.ts +5 -1
- package/src/utils/is-record.ts +3 -0
- package/src/utils/match-line.ts +32 -0
- package/src/utils/platform.ts +13 -0
- package/src/utils/shorten-path.ts +11 -0
- package/src/utils/signals.ts +22 -0
- package/src/commands/clear/command.ts +0 -14
- package/src/commands/clear/index.ts +0 -1
- package/src/commands/completions.ts +0 -38
- package/src/commands/dock/command.ts +0 -29
- package/src/commands/dock/index.ts +0 -1
- package/src/commands/index.ts +0 -33
- package/src/commands/kill/command.ts +0 -69
- package/src/commands/kill/index.ts +0 -1
- package/src/commands/logs/command.ts +0 -47
- package/src/commands/logs/index.ts +0 -1
- package/src/commands/pick-process.ts +0 -34
- package/src/commands/pin/command.ts +0 -34
- package/src/commands/pin/index.ts +0 -1
- package/src/commands/processes/command.ts +0 -39
- package/src/commands/processes/index.ts +0 -1
- package/src/commands/settings/apply-setting-change.ts +0 -72
- package/src/commands/settings/build-sections.ts +0 -160
- package/src/commands/settings/command.ts +0 -20
- package/src/commands/settings/index.ts +0 -1
- package/src/components/log-dock-component.test.ts +0 -29
- package/src/components/log-dock-component.ts +0 -236
- package/src/components/log-file-viewer.ts +0 -317
- package/src/components/log-overlay-component.ts +0 -546
- package/src/components/panel-helpers.ts +0 -37
- package/src/components/process-picker-component.ts +0 -166
- package/src/components/processes-component.ts +0 -485
- package/src/components/status-format.ts +0 -38
- package/src/config.ts +0 -107
- package/src/constants/index.ts +0 -16
- package/src/constants/types.ts +0 -106
- package/src/hooks/background-blocker.ts +0 -66
- package/src/hooks/cleanup.ts +0 -10
- package/src/hooks/index.ts +0 -32
- package/src/hooks/message-renderer.ts +0 -115
- package/src/hooks/process-end.ts +0 -69
- package/src/hooks/process-watch.ts +0 -85
- package/src/hooks/utils.ts +0 -23
- package/src/hooks/widget/index.ts +0 -2
- package/src/hooks/widget/setup.ts +0 -167
- package/src/hooks/widget/status-widget.ts +0 -105
- package/src/hooks/widget/types.ts +0 -20
- package/src/index.ts +0 -35
- package/src/manager.test.ts +0 -331
- package/src/manager.ts +0 -758
- package/src/tools/actions/clear.ts +0 -20
- package/src/tools/actions/debug.ts +0 -148
- package/src/tools/actions/index.ts +0 -171
- package/src/tools/actions/kill.ts +0 -89
- package/src/tools/actions/list.ts +0 -188
- package/src/tools/actions/logs.ts +0 -117
- package/src/tools/actions/output.ts +0 -269
- package/src/tools/actions/start.ts +0 -245
- package/src/tools/actions/write.ts +0 -113
- package/src/tools/index.ts +0 -207
- package/src/utils/ansi.test.ts +0 -22
- package/src/utils/command-executor.test.ts +0 -48
- package/src/utils/keybindings.ts +0 -71
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { KillResult, ManagerEvent, WriteResult } from "../types";
|
|
4
|
+
import { LIVE_STATUSES } from "../types";
|
|
5
|
+
import { isProcessGroupAlive, killProcessGroup } from "../utils";
|
|
6
|
+
import { spawnCommand } from "../utils/command-executor";
|
|
7
|
+
import { formatSignalInfo } from "../utils/signals";
|
|
8
|
+
import type { ManagedProcessRecord } from "./internal-types";
|
|
9
|
+
import { formatProcess } from "./internal-types";
|
|
10
|
+
import { FINISHED_RECORD_GRACE_MS, MAX_FINISHED_RECORDS } from "./limits";
|
|
11
|
+
import type { ProcessLogStore } from "./process-log-store";
|
|
12
|
+
import type { ProcessOutput } from "./process-output";
|
|
13
|
+
import type { ProcessRegistry } from "./process-registry";
|
|
14
|
+
|
|
15
|
+
interface ProcessRuntimeControllerDeps {
|
|
16
|
+
registry: ProcessRegistry;
|
|
17
|
+
logs: ProcessLogStore;
|
|
18
|
+
output: ProcessOutput;
|
|
19
|
+
emit: (event: ManagerEvent) => void;
|
|
20
|
+
getConfiguredShellPath: () => string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ProcessRuntimeController {
|
|
24
|
+
private registry: ProcessRegistry;
|
|
25
|
+
private logs: ProcessLogStore;
|
|
26
|
+
private output: ProcessOutput;
|
|
27
|
+
private emit: (event: ManagerEvent) => void;
|
|
28
|
+
private getConfiguredShellPath: () => string | undefined;
|
|
29
|
+
|
|
30
|
+
private watcher: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
private finishedReapTimer: ReturnType<typeof setTimeout> | null = null;
|
|
32
|
+
private completionSequence = 0;
|
|
33
|
+
private shuttingDown = false;
|
|
34
|
+
|
|
35
|
+
constructor(deps: ProcessRuntimeControllerDeps) {
|
|
36
|
+
this.registry = deps.registry;
|
|
37
|
+
this.logs = deps.logs;
|
|
38
|
+
this.output = deps.output;
|
|
39
|
+
this.emit = deps.emit;
|
|
40
|
+
this.getConfiguredShellPath = deps.getConfiguredShellPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
start(name: string, command: string, cwd: string): ManagedProcessRecord {
|
|
44
|
+
const id = this.registry.nextId();
|
|
45
|
+
const logPaths = this.logs.createLogs(id);
|
|
46
|
+
|
|
47
|
+
const child = spawnCommand(command, cwd, this.getConfiguredShellPath());
|
|
48
|
+
// Spawned commands run in detached process groups so TERM/KILL can target
|
|
49
|
+
// the whole tree. `unref()` keeps the manager's Node process from staying
|
|
50
|
+
// alive only because a managed child still exists; extension shutdown and
|
|
51
|
+
// explicit cleanup remain responsible for killing live process groups.
|
|
52
|
+
child.unref();
|
|
53
|
+
|
|
54
|
+
const managed: ManagedProcessRecord = {
|
|
55
|
+
id,
|
|
56
|
+
name,
|
|
57
|
+
pid: child.pid ?? -1,
|
|
58
|
+
command,
|
|
59
|
+
cwd,
|
|
60
|
+
startTime: Date.now(),
|
|
61
|
+
endTime: null,
|
|
62
|
+
status: "running",
|
|
63
|
+
exitCode: null,
|
|
64
|
+
success: null,
|
|
65
|
+
stdoutFile: logPaths.stdoutFile,
|
|
66
|
+
stderrFile: logPaths.stderrFile,
|
|
67
|
+
endReason: null,
|
|
68
|
+
signal: null,
|
|
69
|
+
errorMessage: null,
|
|
70
|
+
combinedFile: logPaths.combinedFile,
|
|
71
|
+
stdin: child.stdin,
|
|
72
|
+
stdinClosed: false,
|
|
73
|
+
lastSignalSent: null,
|
|
74
|
+
completionSequence: null,
|
|
75
|
+
stdoutPendingLine: Buffer.alloc(0),
|
|
76
|
+
stderrPendingLine: Buffer.alloc(0),
|
|
77
|
+
stdoutLineOverflowed: false,
|
|
78
|
+
stderrLineOverflowed: false,
|
|
79
|
+
appendedLines: [],
|
|
80
|
+
droppedLineCount: 0,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
this.registry.add(managed);
|
|
84
|
+
|
|
85
|
+
if (!child.pid) {
|
|
86
|
+
this.logs.appendErrorLine(managed.stderrFile, "Spawn error: missing pid");
|
|
87
|
+
managed.exitCode = -1;
|
|
88
|
+
managed.success = false;
|
|
89
|
+
managed.endReason = "missing_pid";
|
|
90
|
+
managed.errorMessage = "Spawn error: missing pid";
|
|
91
|
+
managed.endTime = Date.now();
|
|
92
|
+
this.releaseRuntimeHandles(managed);
|
|
93
|
+
this.transition(managed, "exited");
|
|
94
|
+
return managed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.wireStdioHandlers(managed, child);
|
|
98
|
+
|
|
99
|
+
this.emit({ type: "process_started", info: formatProcess(managed) });
|
|
100
|
+
this.ensureWatcherRunning();
|
|
101
|
+
|
|
102
|
+
return managed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
transition(managed: ManagedProcessRecord, next: typeof managed.status): void {
|
|
106
|
+
if (managed.status === next) return;
|
|
107
|
+
managed.status = next;
|
|
108
|
+
|
|
109
|
+
if (next === "exited" || next === "killed") {
|
|
110
|
+
managed.completionSequence ??= ++this.completionSequence;
|
|
111
|
+
this.emit({ type: "process_ended", info: formatProcess(managed) });
|
|
112
|
+
this.reapOldestFinished();
|
|
113
|
+
this.scheduleFinishedReap();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.ensureWatcherRunning();
|
|
117
|
+
this.stopWatcherIfIdle();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async kill(
|
|
121
|
+
id: string,
|
|
122
|
+
opts?: { signal?: NodeJS.Signals; timeoutMs?: number },
|
|
123
|
+
): Promise<KillResult> {
|
|
124
|
+
const managed = this.registry.getRecord(id);
|
|
125
|
+
if (!managed) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
info: {
|
|
129
|
+
id,
|
|
130
|
+
name: "(unknown)",
|
|
131
|
+
pid: -1,
|
|
132
|
+
command: "",
|
|
133
|
+
cwd: "",
|
|
134
|
+
startTime: 0,
|
|
135
|
+
endTime: null,
|
|
136
|
+
status: "exited",
|
|
137
|
+
exitCode: null,
|
|
138
|
+
success: false,
|
|
139
|
+
stdoutFile: "",
|
|
140
|
+
stderrFile: "",
|
|
141
|
+
endReason: null,
|
|
142
|
+
signal: null,
|
|
143
|
+
errorMessage: null,
|
|
144
|
+
},
|
|
145
|
+
reason: "not_found",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const signal = opts?.signal ?? "SIGTERM";
|
|
150
|
+
const timeoutMs = opts?.timeoutMs ?? 3000;
|
|
151
|
+
|
|
152
|
+
if (!LIVE_STATUSES.has(managed.status)) {
|
|
153
|
+
return { ok: true, info: formatProcess(managed) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.transition(managed, "terminating");
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
killProcessGroup(managed.pid, signal);
|
|
160
|
+
managed.lastSignalSent = signal;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
const err = error as NodeJS.ErrnoException;
|
|
163
|
+
if (err.code !== "EPERM") {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
info: formatProcess(managed),
|
|
167
|
+
reason: "error",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const graceMs = signal === "SIGKILL" ? 200 : timeoutMs;
|
|
173
|
+
await new Promise((r) => setTimeout(r, graceMs));
|
|
174
|
+
|
|
175
|
+
const alive = isProcessGroupAlive(managed.pid);
|
|
176
|
+
|
|
177
|
+
if (alive) {
|
|
178
|
+
managed.endReason = "kill_timeout";
|
|
179
|
+
managed.signal = formatSignalInfo(signal);
|
|
180
|
+
managed.success = false;
|
|
181
|
+
// Record the death timestamp so the subsequent `close` event does not
|
|
182
|
+
// re-transition the process and clobber the timeout result. Without
|
|
183
|
+
// this, `child.on("close")` sees a null endTime, overwrites endReason
|
|
184
|
+
// to "signal", and emits a second (reclassified) process_ended.
|
|
185
|
+
managed.endTime = Date.now();
|
|
186
|
+
this.transition(managed, "terminate_timeout");
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
info: formatProcess(managed),
|
|
190
|
+
reason: "timeout",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!managed.endTime) {
|
|
195
|
+
managed.endTime = Date.now();
|
|
196
|
+
managed.exitCode = null;
|
|
197
|
+
managed.success = false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (managed.lastSignalSent) {
|
|
201
|
+
managed.endReason = "signal";
|
|
202
|
+
managed.signal = formatSignalInfo(managed.lastSignalSent);
|
|
203
|
+
} else {
|
|
204
|
+
managed.endReason = "lost";
|
|
205
|
+
managed.signal = null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this.output.flush(managed);
|
|
209
|
+
this.releaseRuntimeHandles(managed);
|
|
210
|
+
this.transition(managed, "killed");
|
|
211
|
+
return { ok: true, info: formatProcess(managed) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
killAll(): void {
|
|
215
|
+
for (const p of this.registry.values()) {
|
|
216
|
+
if (!LIVE_STATUSES.has(p.status)) continue;
|
|
217
|
+
try {
|
|
218
|
+
killProcessGroup(p.pid, "SIGKILL");
|
|
219
|
+
} catch (_error) {
|
|
220
|
+
void _error; // Intentionally ignored - process may already be dead
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
writeToStdin(
|
|
226
|
+
id: string,
|
|
227
|
+
data: string,
|
|
228
|
+
opts?: { end?: boolean },
|
|
229
|
+
): WriteResult {
|
|
230
|
+
const managed = this.registry.getRecord(id);
|
|
231
|
+
if (!managed) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
reason: "not_found",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!LIVE_STATUSES.has(managed.status)) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
reason: "process_exited",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (managed.stdinClosed || !managed.stdin) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
reason: "stdin_closed",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
managed.stdin.write(data);
|
|
254
|
+
|
|
255
|
+
if (opts?.end) {
|
|
256
|
+
managed.stdin.end();
|
|
257
|
+
managed.stdinClosed = true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { ok: true };
|
|
261
|
+
} catch (_error) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
reason: "write_error",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
clearFinished(): number {
|
|
270
|
+
let cleared = 0;
|
|
271
|
+
for (const managed of this.registry.values()) {
|
|
272
|
+
if (LIVE_STATUSES.has(managed.status)) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.removeFinishedRecord(managed);
|
|
277
|
+
cleared++;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (cleared > 0) {
|
|
281
|
+
this.emit({ type: "processes_changed" });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
this.scheduleFinishedReap();
|
|
285
|
+
this.stopWatcherIfIdle();
|
|
286
|
+
return cleared;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
stopWatcher(): void {
|
|
290
|
+
if (this.watcher) {
|
|
291
|
+
clearInterval(this.watcher);
|
|
292
|
+
this.watcher = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
stopFinishedReaper(): void {
|
|
297
|
+
if (!this.finishedReapTimer) return;
|
|
298
|
+
clearTimeout(this.finishedReapTimer);
|
|
299
|
+
this.finishedReapTimer = null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
beginShutdown(): void {
|
|
303
|
+
this.shuttingDown = true;
|
|
304
|
+
this.stopFinishedReaper();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Kill all live processes (used by cleanup on actual pi exit).
|
|
309
|
+
*/
|
|
310
|
+
killAllLive(): void {
|
|
311
|
+
for (const p of this.registry.values()) {
|
|
312
|
+
if (!LIVE_STATUSES.has(p.status)) continue;
|
|
313
|
+
try {
|
|
314
|
+
killProcessGroup(p.pid, "SIGKILL");
|
|
315
|
+
} catch (_error) {
|
|
316
|
+
void _error; // Intentionally ignored - process may already be dead
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
[Symbol.dispose](): void {
|
|
322
|
+
this.stopWatcher();
|
|
323
|
+
this.beginShutdown();
|
|
324
|
+
this.killAllLive();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private wireStdioHandlers(
|
|
328
|
+
managed: ManagedProcessRecord,
|
|
329
|
+
child: ChildProcess,
|
|
330
|
+
): void {
|
|
331
|
+
child.stdout?.on("data", (data: Buffer) => {
|
|
332
|
+
this.logs.appendStdout(managed.stdoutFile, data);
|
|
333
|
+
this.output.onStdoutChunk(managed, data);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
child.stderr?.on("data", (data: Buffer) => {
|
|
337
|
+
this.logs.appendStderr(managed.stderrFile, data);
|
|
338
|
+
this.output.onStderrChunk(managed, data);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
child.on("close", (code, signal) => {
|
|
342
|
+
this.releaseRuntimeHandles(managed);
|
|
343
|
+
if (managed.endTime) return;
|
|
344
|
+
|
|
345
|
+
managed.exitCode = code;
|
|
346
|
+
managed.endTime = Date.now();
|
|
347
|
+
|
|
348
|
+
this.output.flush(managed);
|
|
349
|
+
|
|
350
|
+
if (signal) {
|
|
351
|
+
managed.success = false;
|
|
352
|
+
managed.endReason = "signal";
|
|
353
|
+
managed.signal = formatSignalInfo(signal);
|
|
354
|
+
this.transition(managed, "killed");
|
|
355
|
+
} else {
|
|
356
|
+
managed.success = code === 0;
|
|
357
|
+
managed.endReason = "exit";
|
|
358
|
+
managed.signal = null;
|
|
359
|
+
this.transition(managed, "exited");
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
child.on("error", (err) => {
|
|
364
|
+
this.logs.appendErrorLine(
|
|
365
|
+
managed.stderrFile,
|
|
366
|
+
`Process error: ${err.message}`,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
if (!managed.endTime) {
|
|
370
|
+
this.releaseRuntimeHandles(managed);
|
|
371
|
+
managed.exitCode = -1;
|
|
372
|
+
managed.success = false;
|
|
373
|
+
managed.endReason = "spawn_error";
|
|
374
|
+
managed.errorMessage = err.message;
|
|
375
|
+
managed.endTime = Date.now();
|
|
376
|
+
this.output.flush(managed);
|
|
377
|
+
this.transition(managed, "exited");
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private ensureWatcherRunning(): void {
|
|
383
|
+
if (this.watcher) return;
|
|
384
|
+
if (!this.registry.hasAliveishProcesses()) return;
|
|
385
|
+
|
|
386
|
+
this.watcher = setInterval(() => {
|
|
387
|
+
this.livenessTick();
|
|
388
|
+
}, 5000);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private stopWatcherIfIdle(): void {
|
|
392
|
+
if (!this.watcher) return;
|
|
393
|
+
if (this.registry.hasAliveishProcesses()) return;
|
|
394
|
+
|
|
395
|
+
this.stopWatcher();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private livenessTick(): void {
|
|
399
|
+
for (const managed of this.registry.values()) {
|
|
400
|
+
if (!LIVE_STATUSES.has(managed.status)) continue;
|
|
401
|
+
// A timed-out process was already finalized by kill() (endReason
|
|
402
|
+
// kill_timeout, endTime set, terminate_timeout status, and a tool
|
|
403
|
+
// result carrying reason:"timeout"). The tick's job is to detect
|
|
404
|
+
// autonomous deaths of running processes; reclassifying a finalized
|
|
405
|
+
// timeout here would clobber endReason/signal to "lost" and emit a
|
|
406
|
+
// second process_ended. The close handler also bails via the endTime
|
|
407
|
+
// guard, so the timeout classification is preserved as-is.
|
|
408
|
+
if (managed.status === "terminate_timeout") continue;
|
|
409
|
+
if (!managed.pid || managed.pid <= 0) continue;
|
|
410
|
+
|
|
411
|
+
const alive = isProcessGroupAlive(managed.pid);
|
|
412
|
+
if (alive) continue;
|
|
413
|
+
|
|
414
|
+
if (!managed.endTime) {
|
|
415
|
+
managed.endTime = Date.now();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
this.output.flush(managed);
|
|
419
|
+
this.releaseRuntimeHandles(managed);
|
|
420
|
+
|
|
421
|
+
managed.success = false;
|
|
422
|
+
managed.exitCode = null;
|
|
423
|
+
managed.endReason = "lost";
|
|
424
|
+
|
|
425
|
+
if (managed.lastSignalSent) {
|
|
426
|
+
managed.signal = formatSignalInfo(managed.lastSignalSent);
|
|
427
|
+
this.transition(managed, "killed");
|
|
428
|
+
} else {
|
|
429
|
+
managed.signal = null;
|
|
430
|
+
this.transition(managed, "exited");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private releaseRuntimeHandles(managed: ManagedProcessRecord): void {
|
|
436
|
+
managed.stdin = null;
|
|
437
|
+
managed.stdinClosed = true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private reapOldestFinished(): void {
|
|
441
|
+
const finished = this.finishedRecordsByAge();
|
|
442
|
+
if (finished.length <= MAX_FINISHED_RECORDS) return;
|
|
443
|
+
|
|
444
|
+
const cutoff = Date.now() - FINISHED_RECORD_GRACE_MS;
|
|
445
|
+
let remaining = finished.length;
|
|
446
|
+
let reaped = 0;
|
|
447
|
+
for (const record of finished) {
|
|
448
|
+
if (remaining <= MAX_FINISHED_RECORDS) break;
|
|
449
|
+
if ((record.endTime ?? Number.POSITIVE_INFINITY) > cutoff) break;
|
|
450
|
+
this.removeFinishedRecord(record);
|
|
451
|
+
remaining--;
|
|
452
|
+
reaped++;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (reaped > 0) this.emit({ type: "processes_changed" });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private scheduleFinishedReap(): void {
|
|
459
|
+
this.stopFinishedReaper();
|
|
460
|
+
if (this.shuttingDown) return;
|
|
461
|
+
const finished = this.finishedRecordsByAge();
|
|
462
|
+
if (finished.length <= MAX_FINISHED_RECORDS) return;
|
|
463
|
+
|
|
464
|
+
const oldest = finished[0];
|
|
465
|
+
const delay = Math.max(
|
|
466
|
+
0,
|
|
467
|
+
(oldest.endTime ?? Date.now()) + FINISHED_RECORD_GRACE_MS - Date.now(),
|
|
468
|
+
);
|
|
469
|
+
this.finishedReapTimer = setTimeout(() => {
|
|
470
|
+
this.finishedReapTimer = null;
|
|
471
|
+
this.reapOldestFinished();
|
|
472
|
+
this.scheduleFinishedReap();
|
|
473
|
+
}, delay);
|
|
474
|
+
this.finishedReapTimer.unref?.();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private finishedRecordsByAge(): ManagedProcessRecord[] {
|
|
478
|
+
return [...this.registry.values()]
|
|
479
|
+
.filter(
|
|
480
|
+
(record) =>
|
|
481
|
+
!LIVE_STATUSES.has(record.status) && record.endTime !== null,
|
|
482
|
+
)
|
|
483
|
+
.sort(
|
|
484
|
+
(a, b) =>
|
|
485
|
+
(a.endTime ?? 0) - (b.endTime ?? 0) ||
|
|
486
|
+
(a.completionSequence ?? 0) - (b.completionSequence ?? 0),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private removeFinishedRecord(record: ManagedProcessRecord): void {
|
|
491
|
+
this.logs.removeLogs({
|
|
492
|
+
stdoutFile: record.stdoutFile,
|
|
493
|
+
stderrFile: record.stderrFile,
|
|
494
|
+
combinedFile: record.combinedFile,
|
|
495
|
+
});
|
|
496
|
+
this.output.clear(record.id);
|
|
497
|
+
this.registry.delete(record.id);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ProcessInfo } from "../types";
|
|
2
|
+
|
|
3
|
+
// Core emits, UI listens.
|
|
4
|
+
|
|
5
|
+
export type ProcessesStartedPayload = ProcessInfo;
|
|
6
|
+
export type ProcessesEndedPayload = ProcessInfo;
|
|
7
|
+
export type ProcessesOutputChangedPayload = {
|
|
8
|
+
id: string;
|
|
9
|
+
appendedText?: Array<{ type: "stdout" | "stderr"; text: string }>;
|
|
10
|
+
droppedLines?: number;
|
|
11
|
+
};
|
|
12
|
+
export type ProcessesChangedPayload = {
|
|
13
|
+
reason: "started" | "ended" | "cleared";
|
|
14
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const CHANNELS = {
|
|
2
|
+
// Core broadcasts
|
|
3
|
+
STARTED: "processes:started",
|
|
4
|
+
ENDED: "processes:ended",
|
|
5
|
+
OUTPUT_CHANGED: "processes:output_changed",
|
|
6
|
+
CHANGED: "processes:changed",
|
|
7
|
+
|
|
8
|
+
// Request channels (UI -> core, sync callback)
|
|
9
|
+
REQUEST_LIST: "processes:request:list",
|
|
10
|
+
REQUEST_GET: "processes:request:get",
|
|
11
|
+
REQUEST_OUTPUT: "processes:request:output",
|
|
12
|
+
REQUEST_COMBINED_OUTPUT: "processes:request:combined_output",
|
|
13
|
+
REQUEST_LOG_FILES: "processes:request:log_files",
|
|
14
|
+
REQUEST_FILE_SIZE: "processes:request:file_size",
|
|
15
|
+
REQUEST_CONFIG: "processes:request:config",
|
|
16
|
+
|
|
17
|
+
// Command channels (UI -> core, callback)
|
|
18
|
+
COMMAND_KILL: "processes:command:kill",
|
|
19
|
+
COMMAND_CLEAR: "processes:command:clear",
|
|
20
|
+
// Pin handled by the dock extension, if loaded.
|
|
21
|
+
COMMAND_PIN: "processes:command:pin",
|
|
22
|
+
|
|
23
|
+
// Log subscription channels
|
|
24
|
+
LOGS_SUBSCRIBE: "processes:logs:subscribe",
|
|
25
|
+
LOGS_UNSUBSCRIBE: "processes:logs:unsubscribe",
|
|
26
|
+
LOGS_CHUNK: "processes:logs:chunk",
|
|
27
|
+
|
|
28
|
+
// Notification fanout (core emits, UI + core delivery listen)
|
|
29
|
+
NOTIFICATION: "processes:notification",
|
|
30
|
+
} as const;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { KillResult } from "../types";
|
|
2
|
+
|
|
3
|
+
// UI emits, core handles then calls reply.
|
|
4
|
+
|
|
5
|
+
export interface CommandKillPayload {
|
|
6
|
+
id: string;
|
|
7
|
+
signal?: NodeJS.Signals;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
reply: (result: KillResult) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CommandClearPayload {
|
|
13
|
+
reply: (cleared: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// UI emits; the dock extension (if loaded) handles it by pinning the process
|
|
17
|
+
// to the dock. Pass `id: null` to unpin. If the dock extension is not
|
|
18
|
+
// registered, no listener replies.
|
|
19
|
+
export interface CommandPinPayload {
|
|
20
|
+
id: string | null;
|
|
21
|
+
reply: (result: CommandPinResult) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type CommandPinResult = { ok: true } | { ok: false; error: string };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
ProcessesChangedPayload,
|
|
3
|
+
ProcessesEndedPayload,
|
|
4
|
+
ProcessesOutputChangedPayload,
|
|
5
|
+
ProcessesStartedPayload,
|
|
6
|
+
} from "./broadcasts";
|
|
7
|
+
export { CHANNELS } from "./channels";
|
|
8
|
+
export type {
|
|
9
|
+
CommandClearPayload,
|
|
10
|
+
CommandKillPayload,
|
|
11
|
+
CommandPinPayload,
|
|
12
|
+
CommandPinResult,
|
|
13
|
+
} from "./commands";
|
|
14
|
+
export type {
|
|
15
|
+
LogsChunkPayload,
|
|
16
|
+
LogsSubscribePayload,
|
|
17
|
+
LogsUnsubscribePayload,
|
|
18
|
+
} from "./logs";
|
|
19
|
+
export type {
|
|
20
|
+
ProcessProtocolAttention,
|
|
21
|
+
ProcessProtocolNotificationKind,
|
|
22
|
+
ProcessProtocolNotificationLogMatch,
|
|
23
|
+
ProcessProtocolNotificationPayload,
|
|
24
|
+
} from "./notifications";
|
|
25
|
+
export type {
|
|
26
|
+
ProcessProtocolConfig,
|
|
27
|
+
RequestCombinedOutputPayload,
|
|
28
|
+
RequestConfigPayload,
|
|
29
|
+
RequestFileSizePayload,
|
|
30
|
+
RequestGetPayload,
|
|
31
|
+
RequestListPayload,
|
|
32
|
+
RequestLogFilesPayload,
|
|
33
|
+
RequestOutputPayload,
|
|
34
|
+
} from "./requests";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface LogsSubscribePayload {
|
|
2
|
+
subscriberId: string;
|
|
3
|
+
processId: string;
|
|
4
|
+
tailLines?: number;
|
|
5
|
+
reply: (
|
|
6
|
+
result:
|
|
7
|
+
| {
|
|
8
|
+
ok: true;
|
|
9
|
+
initialLines: Array<{ type: "stdout" | "stderr"; text: string }>;
|
|
10
|
+
}
|
|
11
|
+
| { ok: false; error: string },
|
|
12
|
+
) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LogsUnsubscribePayload {
|
|
16
|
+
subscriberId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LogsChunkPayload {
|
|
20
|
+
subscriberId: string;
|
|
21
|
+
processId: string;
|
|
22
|
+
lines: Array<{ type: "stdout" | "stderr"; text: string }>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProcessEndReason,
|
|
3
|
+
ProcessSignalInfo,
|
|
4
|
+
ProcessStatus,
|
|
5
|
+
} from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Notification event payload broadcast on {@link CHANNELS.NOTIFICATION}.
|
|
9
|
+
*
|
|
10
|
+
* This is the protocol-safe mirror of the core extension's
|
|
11
|
+
* `ProcessNotificationDetails`. It is intentionally free of Pi imports so UI
|
|
12
|
+
* extensions (logs, dock) can observe notification events without importing
|
|
13
|
+
* core notification internals. The core extension emits this payload; the core
|
|
14
|
+
* delivery listener converts it back into a persisted custom message, while UI
|
|
15
|
+
* extensions use it for highlighting (e.g. log-match markers).
|
|
16
|
+
*/
|
|
17
|
+
export type ProcessProtocolAttention = "turn" | "context" | "ignore";
|
|
18
|
+
|
|
19
|
+
export type ProcessProtocolNotificationKind =
|
|
20
|
+
| "success"
|
|
21
|
+
| "failure"
|
|
22
|
+
| "crash"
|
|
23
|
+
| "killed"
|
|
24
|
+
| "log_match"
|
|
25
|
+
| "log_match_suppressed";
|
|
26
|
+
|
|
27
|
+
export interface ProcessProtocolNotificationLogMatch {
|
|
28
|
+
pattern: string;
|
|
29
|
+
mode: "literal" | "regex";
|
|
30
|
+
stream: "stdout" | "stderr";
|
|
31
|
+
line: string;
|
|
32
|
+
matcherIndex: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProcessProtocolNotificationPayload {
|
|
36
|
+
kind: ProcessProtocolNotificationKind;
|
|
37
|
+
processId: string;
|
|
38
|
+
processName: string;
|
|
39
|
+
command: string;
|
|
40
|
+
timestamp: number;
|
|
41
|
+
summary: string;
|
|
42
|
+
status?: ProcessStatus;
|
|
43
|
+
exitCode?: number | null;
|
|
44
|
+
endReason?: ProcessEndReason | null;
|
|
45
|
+
signal?: ProcessSignalInfo | null;
|
|
46
|
+
logMatch?: ProcessProtocolNotificationLogMatch;
|
|
47
|
+
attention: ProcessProtocolAttention;
|
|
48
|
+
}
|