@aliou/pi-processes 0.9.5 → 0.10.1

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 (174) 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 +29 -10
  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 +519 -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/process-group.ts +4 -0
  114. package/src/utils/shorten-path.ts +11 -0
  115. package/src/utils/signals.ts +22 -0
  116. package/src/commands/clear/command.ts +0 -14
  117. package/src/commands/clear/index.ts +0 -1
  118. package/src/commands/completions.ts +0 -38
  119. package/src/commands/dock/command.ts +0 -29
  120. package/src/commands/dock/index.ts +0 -1
  121. package/src/commands/index.ts +0 -33
  122. package/src/commands/kill/command.ts +0 -69
  123. package/src/commands/kill/index.ts +0 -1
  124. package/src/commands/logs/command.ts +0 -47
  125. package/src/commands/logs/index.ts +0 -1
  126. package/src/commands/pick-process.ts +0 -34
  127. package/src/commands/pin/command.ts +0 -34
  128. package/src/commands/pin/index.ts +0 -1
  129. package/src/commands/processes/command.ts +0 -39
  130. package/src/commands/processes/index.ts +0 -1
  131. package/src/commands/settings/apply-setting-change.ts +0 -72
  132. package/src/commands/settings/build-sections.ts +0 -160
  133. package/src/commands/settings/command.ts +0 -20
  134. package/src/commands/settings/index.ts +0 -1
  135. package/src/components/log-dock-component.test.ts +0 -29
  136. package/src/components/log-dock-component.ts +0 -236
  137. package/src/components/log-file-viewer.ts +0 -317
  138. package/src/components/log-overlay-component.ts +0 -546
  139. package/src/components/panel-helpers.ts +0 -37
  140. package/src/components/process-picker-component.ts +0 -166
  141. package/src/components/processes-component.ts +0 -485
  142. package/src/components/status-format.ts +0 -38
  143. package/src/config.ts +0 -107
  144. package/src/constants/index.ts +0 -16
  145. package/src/constants/types.ts +0 -119
  146. package/src/hooks/background-blocker.ts +0 -66
  147. package/src/hooks/cleanup.ts +0 -10
  148. package/src/hooks/index.ts +0 -32
  149. package/src/hooks/message-renderer.ts +0 -115
  150. package/src/hooks/process-end.ts +0 -69
  151. package/src/hooks/process-watch.ts +0 -85
  152. package/src/hooks/utils.ts +0 -23
  153. package/src/hooks/widget/index.ts +0 -2
  154. package/src/hooks/widget/setup.ts +0 -167
  155. package/src/hooks/widget/status-widget.ts +0 -105
  156. package/src/hooks/widget/types.ts +0 -20
  157. package/src/index.ts +0 -35
  158. package/src/manager.test.ts +0 -331
  159. package/src/manager.ts +0 -758
  160. package/src/tools/actions/clear.ts +0 -20
  161. package/src/tools/actions/debug.ts +0 -155
  162. package/src/tools/actions/index.ts +0 -171
  163. package/src/tools/actions/kill.ts +0 -89
  164. package/src/tools/actions/list.ts +0 -188
  165. package/src/tools/actions/logs.ts +0 -117
  166. package/src/tools/actions/output-truncate.ts +0 -187
  167. package/src/tools/actions/output.test.ts +0 -392
  168. package/src/tools/actions/output.ts +0 -447
  169. package/src/tools/actions/start.ts +0 -245
  170. package/src/tools/actions/write.ts +0 -113
  171. package/src/tools/index.ts +0 -207
  172. package/src/utils/ansi.test.ts +0 -22
  173. package/src/utils/command-executor.test.ts +0 -48
  174. package/src/utils/keybindings.ts +0 -71
@@ -0,0 +1,519 @@
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
+ // No pid means the process never started. The async spawn `error`
87
+ // event may not have been delivered yet (Node emits it on a later
88
+ // turn of the event loop, and it cannot fire while start() runs),
89
+ // so attach a handler that finalizes the record with the real
90
+ // reason when it arrives.
91
+ this.logs.appendErrorLine(managed.stderrFile, "Spawn error: missing pid");
92
+ managed.exitCode = -1;
93
+ managed.success = false;
94
+ managed.endReason = "missing_pid";
95
+ managed.errorMessage = "Spawn error: missing pid";
96
+ managed.endTime = Date.now();
97
+ this.releaseRuntimeHandles(managed);
98
+ this.transition(managed, "exited");
99
+ child.on("error", (err) => {
100
+ this.logs.appendErrorLine(
101
+ managed.stderrFile,
102
+ `Process error: ${err.message}`,
103
+ );
104
+ managed.endReason = "spawn_error";
105
+ managed.errorMessage = err.message;
106
+ });
107
+ return managed;
108
+ }
109
+
110
+ this.wireStdioHandlers(managed, child);
111
+
112
+ this.emit({ type: "process_started", info: formatProcess(managed) });
113
+ this.ensureWatcherRunning();
114
+
115
+ return managed;
116
+ }
117
+
118
+ transition(managed: ManagedProcessRecord, next: typeof managed.status): void {
119
+ if (managed.status === next) return;
120
+ managed.status = next;
121
+
122
+ if (next === "exited" || next === "killed") {
123
+ managed.completionSequence ??= ++this.completionSequence;
124
+ this.emit({ type: "process_ended", info: formatProcess(managed) });
125
+ this.reapOldestFinished();
126
+ this.scheduleFinishedReap();
127
+ }
128
+
129
+ this.ensureWatcherRunning();
130
+ this.stopWatcherIfIdle();
131
+ }
132
+
133
+ async kill(
134
+ id: string,
135
+ opts?: { signal?: NodeJS.Signals; timeoutMs?: number },
136
+ ): Promise<KillResult> {
137
+ const managed = this.registry.getRecord(id);
138
+ if (!managed) {
139
+ return {
140
+ ok: false,
141
+ info: {
142
+ id,
143
+ name: "(unknown)",
144
+ pid: -1,
145
+ command: "",
146
+ cwd: "",
147
+ startTime: 0,
148
+ endTime: null,
149
+ status: "exited",
150
+ exitCode: null,
151
+ success: false,
152
+ stdoutFile: "",
153
+ stderrFile: "",
154
+ endReason: null,
155
+ signal: null,
156
+ errorMessage: null,
157
+ },
158
+ reason: "not_found",
159
+ };
160
+ }
161
+
162
+ const signal = opts?.signal ?? "SIGTERM";
163
+ const timeoutMs = opts?.timeoutMs ?? 3000;
164
+
165
+ if (!LIVE_STATUSES.has(managed.status)) {
166
+ return { ok: true, info: formatProcess(managed) };
167
+ }
168
+
169
+ this.transition(managed, "terminating");
170
+
171
+ try {
172
+ killProcessGroup(managed.pid, signal);
173
+ managed.lastSignalSent = signal;
174
+ } catch (error) {
175
+ const err = error as NodeJS.ErrnoException;
176
+ if (err.code !== "EPERM") {
177
+ return {
178
+ ok: false,
179
+ info: formatProcess(managed),
180
+ reason: "error",
181
+ };
182
+ }
183
+ }
184
+
185
+ const graceMs = signal === "SIGKILL" ? 200 : timeoutMs;
186
+ await new Promise((r) => setTimeout(r, graceMs));
187
+
188
+ const alive = isProcessGroupAlive(managed.pid);
189
+
190
+ if (alive) {
191
+ managed.endReason = "kill_timeout";
192
+ managed.signal = formatSignalInfo(signal);
193
+ managed.success = false;
194
+ // Record the death timestamp so the subsequent `close` event does not
195
+ // re-transition the process and clobber the timeout result. Without
196
+ // this, `child.on("close")` sees a null endTime, overwrites endReason
197
+ // to "signal", and emits a second (reclassified) process_ended.
198
+ managed.endTime = Date.now();
199
+ this.transition(managed, "terminate_timeout");
200
+ return {
201
+ ok: false,
202
+ info: formatProcess(managed),
203
+ reason: "timeout",
204
+ };
205
+ }
206
+
207
+ if (!managed.endTime) {
208
+ managed.endTime = Date.now();
209
+ managed.exitCode = null;
210
+ managed.success = false;
211
+ }
212
+
213
+ if (managed.lastSignalSent) {
214
+ managed.endReason = "signal";
215
+ managed.signal = formatSignalInfo(managed.lastSignalSent);
216
+ } else {
217
+ managed.endReason = "lost";
218
+ managed.signal = null;
219
+ }
220
+
221
+ this.output.flush(managed);
222
+ this.releaseRuntimeHandles(managed);
223
+ this.transition(managed, "killed");
224
+ return { ok: true, info: formatProcess(managed) };
225
+ }
226
+
227
+ killAll(): void {
228
+ for (const p of this.registry.values()) {
229
+ if (!LIVE_STATUSES.has(p.status)) continue;
230
+ try {
231
+ killProcessGroup(p.pid, "SIGKILL");
232
+ } catch (_error) {
233
+ void _error; // Intentionally ignored - process may already be dead
234
+ }
235
+ }
236
+ }
237
+
238
+ writeToStdin(
239
+ id: string,
240
+ data: string,
241
+ opts?: { end?: boolean },
242
+ ): WriteResult {
243
+ const managed = this.registry.getRecord(id);
244
+ if (!managed) {
245
+ return {
246
+ ok: false,
247
+ reason: "not_found",
248
+ };
249
+ }
250
+
251
+ if (!LIVE_STATUSES.has(managed.status)) {
252
+ return {
253
+ ok: false,
254
+ reason: "process_exited",
255
+ };
256
+ }
257
+
258
+ if (managed.stdinClosed || !managed.stdin) {
259
+ return {
260
+ ok: false,
261
+ reason: "stdin_closed",
262
+ };
263
+ }
264
+
265
+ try {
266
+ managed.stdin.write(data);
267
+
268
+ if (opts?.end) {
269
+ managed.stdin.end();
270
+ managed.stdinClosed = true;
271
+ }
272
+
273
+ return { ok: true };
274
+ } catch (_error) {
275
+ return {
276
+ ok: false,
277
+ reason: "write_error",
278
+ };
279
+ }
280
+ }
281
+
282
+ clearFinished(): number {
283
+ let cleared = 0;
284
+ for (const managed of this.registry.values()) {
285
+ if (LIVE_STATUSES.has(managed.status)) {
286
+ continue;
287
+ }
288
+
289
+ this.removeFinishedRecord(managed);
290
+ cleared++;
291
+ }
292
+
293
+ if (cleared > 0) {
294
+ this.emit({ type: "processes_changed" });
295
+ }
296
+
297
+ this.scheduleFinishedReap();
298
+ this.stopWatcherIfIdle();
299
+ return cleared;
300
+ }
301
+
302
+ stopWatcher(): void {
303
+ if (this.watcher) {
304
+ clearInterval(this.watcher);
305
+ this.watcher = null;
306
+ }
307
+ }
308
+
309
+ stopFinishedReaper(): void {
310
+ if (!this.finishedReapTimer) return;
311
+ clearTimeout(this.finishedReapTimer);
312
+ this.finishedReapTimer = null;
313
+ }
314
+
315
+ beginShutdown(): void {
316
+ this.shuttingDown = true;
317
+ this.stopFinishedReaper();
318
+ }
319
+
320
+ /**
321
+ * Kill all live processes (used by cleanup on actual pi exit).
322
+ */
323
+ killAllLive(): void {
324
+ for (const p of this.registry.values()) {
325
+ if (!LIVE_STATUSES.has(p.status)) continue;
326
+ try {
327
+ killProcessGroup(p.pid, "SIGKILL");
328
+ } catch (_error) {
329
+ void _error; // Intentionally ignored - process may already be dead
330
+ }
331
+ }
332
+ }
333
+
334
+ [Symbol.dispose](): void {
335
+ this.stopWatcher();
336
+ this.beginShutdown();
337
+ this.killAllLive();
338
+ }
339
+
340
+ private wireStdioHandlers(
341
+ managed: ManagedProcessRecord,
342
+ child: ChildProcess,
343
+ ): void {
344
+ child.stdout?.on("data", (data: Buffer) => {
345
+ this.logs.appendStdout(managed.stdoutFile, data);
346
+ this.output.onStdoutChunk(managed, data);
347
+ });
348
+
349
+ child.stderr?.on("data", (data: Buffer) => {
350
+ this.logs.appendStderr(managed.stderrFile, data);
351
+ this.output.onStderrChunk(managed, data);
352
+ });
353
+
354
+ child.on("close", (code, signal) => {
355
+ this.releaseRuntimeHandles(managed);
356
+ if (managed.endTime) return;
357
+
358
+ managed.exitCode = code;
359
+ managed.endTime = Date.now();
360
+
361
+ this.output.flush(managed);
362
+
363
+ if (signal) {
364
+ managed.success = false;
365
+ managed.endReason = "signal";
366
+ managed.signal = formatSignalInfo(signal);
367
+ this.transition(managed, "killed");
368
+ } else {
369
+ managed.success = code === 0;
370
+ managed.endReason = "exit";
371
+ managed.signal = null;
372
+ this.transition(managed, "exited");
373
+ }
374
+ });
375
+
376
+ child.on("error", (err) => {
377
+ this.handleSpawnError(managed, err);
378
+ });
379
+ }
380
+
381
+ private handleSpawnError(
382
+ managed: ManagedProcessRecord,
383
+ err: NodeJS.ErrnoException,
384
+ ): void {
385
+ this.logs.appendErrorLine(
386
+ managed.stderrFile,
387
+ `Process error: ${err.message}`,
388
+ );
389
+
390
+ if (managed.endTime) return;
391
+
392
+ this.releaseRuntimeHandles(managed);
393
+ managed.exitCode = -1;
394
+ managed.success = false;
395
+ managed.endReason = "spawn_error";
396
+ managed.errorMessage = err.message;
397
+ managed.endTime = Date.now();
398
+ this.output.flush(managed);
399
+ this.transition(managed, "exited");
400
+ }
401
+
402
+ private ensureWatcherRunning(): void {
403
+ if (this.watcher) return;
404
+ if (!this.registry.hasAliveishProcesses()) return;
405
+
406
+ this.watcher = setInterval(() => {
407
+ this.livenessTick();
408
+ }, 5000);
409
+ }
410
+
411
+ private stopWatcherIfIdle(): void {
412
+ if (!this.watcher) return;
413
+ if (this.registry.hasAliveishProcesses()) return;
414
+
415
+ this.stopWatcher();
416
+ }
417
+
418
+ private livenessTick(): void {
419
+ for (const managed of this.registry.values()) {
420
+ if (!LIVE_STATUSES.has(managed.status)) continue;
421
+ // A timed-out process was already finalized by kill() (endReason
422
+ // kill_timeout, endTime set, terminate_timeout status, and a tool
423
+ // result carrying reason:"timeout"). The tick's job is to detect
424
+ // autonomous deaths of running processes; reclassifying a finalized
425
+ // timeout here would clobber endReason/signal to "lost" and emit a
426
+ // second process_ended. The close handler also bails via the endTime
427
+ // guard, so the timeout classification is preserved as-is.
428
+ if (managed.status === "terminate_timeout") continue;
429
+ if (!managed.pid || managed.pid <= 0) continue;
430
+
431
+ const alive = isProcessGroupAlive(managed.pid);
432
+ if (alive) continue;
433
+
434
+ if (!managed.endTime) {
435
+ managed.endTime = Date.now();
436
+ }
437
+
438
+ this.output.flush(managed);
439
+ this.releaseRuntimeHandles(managed);
440
+
441
+ managed.success = false;
442
+ managed.exitCode = null;
443
+ managed.endReason = "lost";
444
+
445
+ if (managed.lastSignalSent) {
446
+ managed.signal = formatSignalInfo(managed.lastSignalSent);
447
+ this.transition(managed, "killed");
448
+ } else {
449
+ managed.signal = null;
450
+ this.transition(managed, "exited");
451
+ }
452
+ }
453
+ }
454
+
455
+ private releaseRuntimeHandles(managed: ManagedProcessRecord): void {
456
+ managed.stdin = null;
457
+ managed.stdinClosed = true;
458
+ }
459
+
460
+ private reapOldestFinished(): void {
461
+ const finished = this.finishedRecordsByAge();
462
+ if (finished.length <= MAX_FINISHED_RECORDS) return;
463
+
464
+ const cutoff = Date.now() - FINISHED_RECORD_GRACE_MS;
465
+ let remaining = finished.length;
466
+ let reaped = 0;
467
+ for (const record of finished) {
468
+ if (remaining <= MAX_FINISHED_RECORDS) break;
469
+ if ((record.endTime ?? Number.POSITIVE_INFINITY) > cutoff) break;
470
+ this.removeFinishedRecord(record);
471
+ remaining--;
472
+ reaped++;
473
+ }
474
+
475
+ if (reaped > 0) this.emit({ type: "processes_changed" });
476
+ }
477
+
478
+ private scheduleFinishedReap(): void {
479
+ this.stopFinishedReaper();
480
+ if (this.shuttingDown) return;
481
+ const finished = this.finishedRecordsByAge();
482
+ if (finished.length <= MAX_FINISHED_RECORDS) return;
483
+
484
+ const oldest = finished[0];
485
+ const delay = Math.max(
486
+ 0,
487
+ (oldest.endTime ?? Date.now()) + FINISHED_RECORD_GRACE_MS - Date.now(),
488
+ );
489
+ this.finishedReapTimer = setTimeout(() => {
490
+ this.finishedReapTimer = null;
491
+ this.reapOldestFinished();
492
+ this.scheduleFinishedReap();
493
+ }, delay);
494
+ this.finishedReapTimer.unref?.();
495
+ }
496
+
497
+ private finishedRecordsByAge(): ManagedProcessRecord[] {
498
+ return [...this.registry.values()]
499
+ .filter(
500
+ (record) =>
501
+ !LIVE_STATUSES.has(record.status) && record.endTime !== null,
502
+ )
503
+ .sort(
504
+ (a, b) =>
505
+ (a.endTime ?? 0) - (b.endTime ?? 0) ||
506
+ (a.completionSequence ?? 0) - (b.completionSequence ?? 0),
507
+ );
508
+ }
509
+
510
+ private removeFinishedRecord(record: ManagedProcessRecord): void {
511
+ this.logs.removeLogs({
512
+ stdoutFile: record.stdoutFile,
513
+ stderrFile: record.stderrFile,
514
+ combinedFile: record.combinedFile,
515
+ });
516
+ this.output.clear(record.id);
517
+ this.registry.delete(record.id);
518
+ }
519
+ }
@@ -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
+ }