@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.
Files changed (171) hide show
  1. package/CONTRIBUTING.md +40 -42
  2. package/README.md +54 -84
  3. package/extensions/process-tabs.ts +26 -0
  4. package/extensions/processes/client.ts +194 -0
  5. package/extensions/processes/commands/clear.ts +29 -0
  6. package/extensions/processes/commands/kill.ts +152 -0
  7. package/extensions/processes/commands/overview.ts +102 -0
  8. package/extensions/processes/components/overview-component.ts +729 -0
  9. package/extensions/processes/components/overview-panel.ts +177 -0
  10. package/extensions/processes/config/defaults.ts +28 -0
  11. package/extensions/processes/config/index.ts +13 -0
  12. package/extensions/processes/config/loader.ts +62 -0
  13. package/extensions/processes/config/migrations/000-import-legacy-process-config.ts +113 -0
  14. package/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +184 -0
  15. package/extensions/processes/config/migrations/002-stamp-v0-10-0-config-version.ts +21 -0
  16. package/extensions/processes/config/migrations/index.ts +25 -0
  17. package/extensions/processes/config/schema.ts +9 -0
  18. package/extensions/processes/config/types.ts +72 -0
  19. package/extensions/processes/constants.ts +1 -0
  20. package/extensions/processes/handlers/commands.ts +113 -0
  21. package/extensions/processes/handlers/kill-process.ts +31 -0
  22. package/extensions/processes/handlers/notifications.ts +147 -0
  23. package/extensions/processes/handlers/requests.ts +114 -0
  24. package/extensions/processes/handlers/subscriptions.ts +144 -0
  25. package/extensions/processes/hooks/background-blocker.ts +201 -0
  26. package/extensions/processes/hooks/cleanup.ts +43 -0
  27. package/extensions/processes/hooks/event-bridge.ts +32 -0
  28. package/extensions/processes/i18n/index.ts +3 -0
  29. package/extensions/processes/i18n/messages.ts +27 -0
  30. package/extensions/processes/i18n/translator.ts +49 -0
  31. package/extensions/processes/index.ts +109 -0
  32. package/extensions/processes/message-renderer.ts +117 -0
  33. package/extensions/processes/notification-sender.ts +43 -0
  34. package/extensions/processes/notifications/classify.ts +27 -0
  35. package/extensions/processes/notifications/log-matchers.ts +146 -0
  36. package/extensions/processes/notifications/registry.ts +225 -0
  37. package/extensions/processes/notifications/render-content.ts +122 -0
  38. package/extensions/processes/notifications/service.ts +336 -0
  39. package/extensions/processes/notifications/types.ts +11 -0
  40. package/extensions/processes/settings/apply-setting-change.ts +30 -0
  41. package/extensions/processes/settings/build-sections.ts +420 -0
  42. package/extensions/processes/settings/index.ts +42 -0
  43. package/extensions/processes/tools/clear/index.ts +21 -0
  44. package/extensions/processes/tools/clear/render.ts +40 -0
  45. package/extensions/processes/tools/components/index.ts +4 -0
  46. package/extensions/processes/tools/components/process-action-header.ts +31 -0
  47. package/extensions/processes/tools/components/process-action-title.ts +17 -0
  48. package/extensions/processes/tools/components/tool-layout.ts +70 -0
  49. package/extensions/processes/tools/components/watch.ts +53 -0
  50. package/extensions/processes/tools/index.ts +265 -0
  51. package/extensions/processes/tools/list/index.ts +157 -0
  52. package/extensions/processes/tools/list/render.ts +211 -0
  53. package/extensions/processes/tools/notify.ts +191 -0
  54. package/extensions/processes/tools/output/index.ts +307 -0
  55. package/extensions/processes/tools/output/render.ts +229 -0
  56. package/extensions/processes/tools/schema.ts +263 -0
  57. package/extensions/processes/tools/start/index.ts +64 -0
  58. package/extensions/processes/tools/start/render.ts +67 -0
  59. package/extensions/processes/tools/stop/index.ts +35 -0
  60. package/extensions/processes/tools/stop/render.ts +43 -0
  61. package/extensions/processes/tools/update/index.ts +320 -0
  62. package/extensions/processes/tools/update/render.ts +251 -0
  63. package/extensions/processes/tools/utils.ts +112 -0
  64. package/extensions/processes/tools/watch-format.ts +14 -0
  65. package/extensions/processes/tools/write/index.ts +75 -0
  66. package/extensions/processes/tools/write/render.ts +68 -0
  67. package/extensions/processes/utils/truncate.ts +285 -0
  68. package/extensions/processes-dock/client.ts +60 -0
  69. package/extensions/processes-dock/commands/dock.ts +70 -0
  70. package/extensions/processes-dock/commands/pin.ts +166 -0
  71. package/extensions/processes-dock/components/log-dock-component.ts +375 -0
  72. package/extensions/processes-dock/dock-state.ts +33 -0
  73. package/extensions/processes-dock/index.ts +28 -0
  74. package/extensions/processes-dock/logs-client.ts +5 -0
  75. package/extensions/processes-dock/widget/setup.ts +520 -0
  76. package/extensions/processes-dock/widget/status.ts +149 -0
  77. package/extensions/processes-dock/widget/types.ts +16 -0
  78. package/extensions/processes-logs/client.ts +51 -0
  79. package/extensions/processes-logs/commands/logs.ts +108 -0
  80. package/extensions/processes-logs/completions.ts +31 -0
  81. package/extensions/processes-logs/components/log-file-viewer.ts +274 -0
  82. package/extensions/processes-logs/components/log-overlay-component.ts +685 -0
  83. package/extensions/processes-logs/index.ts +27 -0
  84. package/extensions/processes-logs/logs-client.ts +101 -0
  85. package/extensions/shared/line-buffer.ts +35 -0
  86. package/extensions/shared/output-payload.ts +28 -0
  87. package/extensions/shared/ui.ts +146 -0
  88. package/package.json +47 -20
  89. package/schema.json +134 -0
  90. package/skills/pi-processes/SKILL.md +496 -25
  91. package/src/get-manager.ts +15 -0
  92. package/src/manager/index.ts +182 -0
  93. package/src/manager/internal-types.ts +130 -0
  94. package/src/manager/limits.ts +15 -0
  95. package/src/manager/process-log-store.ts +422 -0
  96. package/src/manager/process-output.ts +345 -0
  97. package/src/manager/process-registry.ts +78 -0
  98. package/src/manager/process-runtime-controller.ts +499 -0
  99. package/src/protocol/broadcasts.ts +14 -0
  100. package/src/protocol/channels.ts +30 -0
  101. package/src/protocol/commands.ts +24 -0
  102. package/src/protocol/index.ts +34 -0
  103. package/src/protocol/logs.ts +23 -0
  104. package/src/protocol/notifications.ts +48 -0
  105. package/src/protocol/requests.ts +76 -0
  106. package/src/types.ts +66 -0
  107. package/src/utils/ansi.ts +2 -4
  108. package/src/utils/format.ts +24 -32
  109. package/src/utils/index.ts +5 -1
  110. package/src/utils/is-record.ts +3 -0
  111. package/src/utils/match-line.ts +32 -0
  112. package/src/utils/platform.ts +13 -0
  113. package/src/utils/shorten-path.ts +11 -0
  114. package/src/utils/signals.ts +22 -0
  115. package/src/commands/clear/command.ts +0 -14
  116. package/src/commands/clear/index.ts +0 -1
  117. package/src/commands/completions.ts +0 -38
  118. package/src/commands/dock/command.ts +0 -29
  119. package/src/commands/dock/index.ts +0 -1
  120. package/src/commands/index.ts +0 -33
  121. package/src/commands/kill/command.ts +0 -69
  122. package/src/commands/kill/index.ts +0 -1
  123. package/src/commands/logs/command.ts +0 -47
  124. package/src/commands/logs/index.ts +0 -1
  125. package/src/commands/pick-process.ts +0 -34
  126. package/src/commands/pin/command.ts +0 -34
  127. package/src/commands/pin/index.ts +0 -1
  128. package/src/commands/processes/command.ts +0 -39
  129. package/src/commands/processes/index.ts +0 -1
  130. package/src/commands/settings/apply-setting-change.ts +0 -72
  131. package/src/commands/settings/build-sections.ts +0 -160
  132. package/src/commands/settings/command.ts +0 -20
  133. package/src/commands/settings/index.ts +0 -1
  134. package/src/components/log-dock-component.test.ts +0 -29
  135. package/src/components/log-dock-component.ts +0 -236
  136. package/src/components/log-file-viewer.ts +0 -317
  137. package/src/components/log-overlay-component.ts +0 -546
  138. package/src/components/panel-helpers.ts +0 -37
  139. package/src/components/process-picker-component.ts +0 -166
  140. package/src/components/processes-component.ts +0 -485
  141. package/src/components/status-format.ts +0 -38
  142. package/src/config.ts +0 -107
  143. package/src/constants/index.ts +0 -16
  144. package/src/constants/types.ts +0 -106
  145. package/src/hooks/background-blocker.ts +0 -66
  146. package/src/hooks/cleanup.ts +0 -10
  147. package/src/hooks/index.ts +0 -32
  148. package/src/hooks/message-renderer.ts +0 -115
  149. package/src/hooks/process-end.ts +0 -69
  150. package/src/hooks/process-watch.ts +0 -85
  151. package/src/hooks/utils.ts +0 -23
  152. package/src/hooks/widget/index.ts +0 -2
  153. package/src/hooks/widget/setup.ts +0 -167
  154. package/src/hooks/widget/status-widget.ts +0 -105
  155. package/src/hooks/widget/types.ts +0 -20
  156. package/src/index.ts +0 -35
  157. package/src/manager.test.ts +0 -331
  158. package/src/manager.ts +0 -758
  159. package/src/tools/actions/clear.ts +0 -20
  160. package/src/tools/actions/debug.ts +0 -148
  161. package/src/tools/actions/index.ts +0 -171
  162. package/src/tools/actions/kill.ts +0 -89
  163. package/src/tools/actions/list.ts +0 -188
  164. package/src/tools/actions/logs.ts +0 -117
  165. package/src/tools/actions/output.ts +0 -269
  166. package/src/tools/actions/start.ts +0 -245
  167. package/src/tools/actions/write.ts +0 -113
  168. package/src/tools/index.ts +0 -207
  169. package/src/utils/ansi.test.ts +0 -22
  170. package/src/utils/command-executor.test.ts +0 -48
  171. 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
+ }