@aliou/pi-processes 0.1.1 → 0.2.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/README.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  Manage background processes from Pi. Start long-running commands (dev servers, build watchers, log tailers) without blocking the conversation.
4
4
 
5
+ ## Installation
6
+
7
+ Install via npm:
8
+
9
+ ```bash
10
+ pi install npm:@aliou/pi-processes
11
+ ```
12
+
13
+ Or via the pi-extensions package:
14
+
15
+ ```bash
16
+ pi install git:github.com/aliou/pi-extensions
17
+ ```
18
+
19
+ Or selectively in your `settings.json`:
20
+
21
+ ```json
22
+ {
23
+ "packages": [
24
+ {
25
+ "source": "git:github.com/aliou/pi-extensions",
26
+ "extensions": ["extensions/processes"]
27
+ }
28
+ ]
29
+ }
30
+ ```
31
+
5
32
  ## Features
6
33
 
7
34
  - **Tool**: `processes` with actions: `start`, `list`, `output`, `logs`, `kill`, `clear`
package/commands/index.ts CHANGED
@@ -1,449 +1,6 @@
1
- import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
2
- import { type Component, matchesKey, visibleWidth } from "@mariozechner/pi-tui";
3
- import type { ProcessInfo, ProcessManager } from "../manager";
4
-
5
- // Max visible processes in the list (scrollable if more)
6
- const MAX_VISIBLE_PROCESSES = 8;
7
- // Max log lines shown
8
- const MAX_LOG_LINES = 12;
9
-
10
- function formatRuntime(startTime: number, endTime: number | null): string {
11
- const end = endTime ?? Date.now();
12
- const ms = end - startTime;
13
- const seconds = Math.floor(ms / 1000);
14
- const minutes = Math.floor(seconds / 60);
15
- const hours = Math.floor(minutes / 60);
16
-
17
- if (hours > 0) {
18
- return `${hours}h ${minutes % 60}m`;
19
- }
20
- if (minutes > 0) {
21
- return `${minutes}m ${seconds % 60}s`;
22
- }
23
- return `${seconds}s`;
24
- }
25
-
26
- function formatBytes(bytes: number): string {
27
- if (bytes >= 1024 * 1024) {
28
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
29
- }
30
- if (bytes >= 1024) {
31
- return `${(bytes / 1024).toFixed(1)}KB`;
32
- }
33
- return `${bytes}B`;
34
- }
35
-
36
- function truncate(str: string, maxLen: number): string {
37
- if (maxLen <= 3) return str.slice(0, maxLen);
38
- if (str.length <= maxLen) return str;
39
- return `${str.slice(0, maxLen - 3)}...`;
40
- }
41
-
42
- class ProcessesComponent implements Component {
43
- private tui: { requestRender: () => void };
44
- private theme: Theme;
45
- private onClose: () => void;
46
- private manager: ProcessManager;
47
-
48
- private selectedIndex = 0;
49
- private processScrollOffset = 0;
50
- private logScrollOffset = 0;
51
- private scrollInfo = { above: 0, below: 0 };
52
- private cachedLines: string[] = [];
53
- private cachedWidth = 0;
54
- private refreshInterval: ReturnType<typeof setInterval> | null = null;
55
-
56
- constructor(
57
- tui: { requestRender: () => void },
58
- theme: Theme,
59
- onClose: () => void,
60
- manager: ProcessManager,
61
- ) {
62
- this.tui = tui;
63
- this.theme = theme;
64
- this.onClose = onClose;
65
- this.manager = manager;
66
-
67
- this.refreshInterval = setInterval(() => {
68
- this.invalidate();
69
- this.tui.requestRender();
70
- }, 1000);
71
- }
72
-
73
- handleInput(data: string): boolean {
74
- const processes = this.manager.list();
75
-
76
- // Navigation
77
- if (matchesKey(data, "down") || data === "j") {
78
- if (processes.length > 0) {
79
- this.selectedIndex = Math.min(
80
- this.selectedIndex + 1,
81
- processes.length - 1,
82
- );
83
- this.logScrollOffset = 0;
84
- this.ensureProcessVisible(processes.length);
85
- this.invalidate();
86
- this.tui.requestRender();
87
- }
88
- return true;
89
- }
90
-
91
- if (matchesKey(data, "up") || data === "k") {
92
- if (processes.length > 0) {
93
- this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
94
- this.logScrollOffset = 0;
95
- this.ensureProcessVisible(processes.length);
96
- this.invalidate();
97
- this.tui.requestRender();
98
- }
99
- return true;
100
- }
101
-
102
- // Scroll logs
103
- if (data === "J") {
104
- this.logScrollOffset = Math.max(0, this.logScrollOffset - 5);
105
- this.invalidate();
106
- this.tui.requestRender();
107
- return true;
108
- }
109
-
110
- if (data === "K") {
111
- this.logScrollOffset += 5;
112
- this.invalidate();
113
- this.tui.requestRender();
114
- return true;
115
- }
116
-
117
- // Kill selected process
118
- if (data === "x" || data === "X") {
119
- if (processes.length > 0 && this.selectedIndex < processes.length) {
120
- const proc = processes[this.selectedIndex];
121
- if (proc && proc.status === "running") {
122
- this.manager.kill(proc.id);
123
- this.invalidate();
124
- this.tui.requestRender();
125
- }
126
- }
127
- return true;
128
- }
129
-
130
- // Clear finished processes
131
- if (data === "c" || data === "C") {
132
- const cleared = this.manager.clearFinished();
133
- if (cleared > 0) {
134
- // Adjust selection if needed
135
- const remaining = this.manager.list();
136
- if (this.selectedIndex >= remaining.length) {
137
- this.selectedIndex = Math.max(0, remaining.length - 1);
138
- }
139
- this.ensureProcessVisible(remaining.length);
140
- this.invalidate();
141
- this.tui.requestRender();
142
- }
143
- return true;
144
- }
145
-
146
- // Close
147
- if (matchesKey(data, "escape") || data === "q" || data === "Q") {
148
- if (this.refreshInterval) {
149
- clearInterval(this.refreshInterval);
150
- this.refreshInterval = null;
151
- }
152
- this.onClose();
153
- return true;
154
- }
155
-
156
- return true;
157
- }
158
-
159
- private ensureProcessVisible(totalProcesses: number): void {
160
- const visibleCount = Math.min(MAX_VISIBLE_PROCESSES, totalProcesses);
161
- if (this.selectedIndex < this.processScrollOffset) {
162
- this.processScrollOffset = this.selectedIndex;
163
- } else if (this.selectedIndex >= this.processScrollOffset + visibleCount) {
164
- this.processScrollOffset = this.selectedIndex - visibleCount + 1;
165
- }
166
- this.processScrollOffset = Math.max(
167
- 0,
168
- Math.min(this.processScrollOffset, totalProcesses - visibleCount),
169
- );
170
- }
171
-
172
- invalidate(): void {
173
- this.cachedWidth = 0;
174
- this.cachedLines = [];
175
- }
176
-
177
- render(width: number): string[] {
178
- if (width === this.cachedWidth && this.cachedLines.length > 0) {
179
- return this.cachedLines;
180
- }
181
-
182
- const theme = this.theme;
183
- const dim = (s: string) => theme.fg("dim", s);
184
- const accent = (s: string) => theme.fg("accent", s);
185
- const warning = (s: string) => theme.fg("warning", s);
186
- const bold = (s: string) => theme.bold(s);
187
- const border = (s: string) => theme.fg("dim", s);
188
-
189
- const lines: string[] = [];
190
- const processes = this.manager.list();
191
- const innerWidth = width - 2; // 1 char padding each side
192
-
193
- // Helper to pad line
194
- const padLine = (content: string): string => {
195
- const len = visibleWidth(content);
196
- return ` ${content}${" ".repeat(Math.max(0, innerWidth - len))} `;
197
- };
198
-
199
- // Top border with title
200
- const title = " Background Processes ";
201
- const titleLen = title.length;
202
- const borderLen = Math.max(0, width - titleLen);
203
- const leftBorder = Math.floor(borderLen / 2);
204
- const rightBorder = borderLen - leftBorder;
205
- lines.push(
206
- border("─".repeat(leftBorder)) +
207
- accent(bold(title)) +
208
- border("─".repeat(rightBorder)),
209
- );
210
-
211
- if (processes.length === 0) {
212
- lines.push(padLine(""));
213
- lines.push(padLine(dim("No background processes")));
214
- lines.push(padLine(dim("Use the processes tool to start commands")));
215
- lines.push(padLine(""));
216
- } else {
217
- // Calculate column widths
218
- const prefixWidth = 2; // "> " or " "
219
- const idWidth = 9;
220
- const nameWidth = 15;
221
- const statusWidth = 14;
222
- const timeWidth = 8;
223
- const sizeWidth = 8;
224
- const cmdWidth = Math.max(
225
- 20,
226
- innerWidth -
227
- prefixWidth -
228
- idWidth -
229
- nameWidth -
230
- statusWidth -
231
- timeWidth -
232
- sizeWidth,
233
- );
234
-
235
- // Header with scroll indicator if needed
236
- const hasProcessScroll = processes.length > MAX_VISIBLE_PROCESSES;
237
- const headerSuffix = hasProcessScroll
238
- ? dim(
239
- ` [${this.processScrollOffset + 1}-${Math.min(this.processScrollOffset + MAX_VISIBLE_PROCESSES, processes.length)}/${processes.length}]`,
240
- )
241
- : "";
242
-
243
- lines.push(padLine(""));
244
- const header =
245
- " " +
246
- dim("ID".padEnd(idWidth)) +
247
- dim("Name".padEnd(nameWidth)) +
248
- dim("Command".padEnd(cmdWidth)) +
249
- dim("Status".padEnd(statusWidth)) +
250
- dim("Time".padEnd(timeWidth)) +
251
- dim("Size".padStart(sizeWidth)) +
252
- headerSuffix;
253
- lines.push(padLine(header));
254
- lines.push(border("─".repeat(width)));
255
-
256
- // Process rows (limited to MAX_VISIBLE_PROCESSES)
257
- const visibleProcessCount = Math.min(
258
- MAX_VISIBLE_PROCESSES,
259
- processes.length,
260
- );
261
- const startIdx = this.processScrollOffset;
262
- const endIdx = startIdx + visibleProcessCount;
263
-
264
- for (let i = startIdx; i < endIdx; i++) {
265
- const proc = processes[i];
266
- if (!proc) continue;
267
- const isSelected = i === this.selectedIndex;
268
- const sizes = this.manager.getFileSize(proc.id);
269
- const totalSize = sizes ? sizes.stdout + sizes.stderr : 0;
270
-
271
- const statusText = this.formatStatus(proc);
272
- const statusPadding =
273
- statusWidth + (statusText.length - visibleWidth(statusText));
274
-
275
- const row =
276
- (isSelected
277
- ? accent(proc.id.padEnd(idWidth))
278
- : proc.id.padEnd(idWidth)) +
279
- truncate(proc.name, nameWidth - 1).padEnd(nameWidth) +
280
- truncate(proc.command, cmdWidth - 1).padEnd(cmdWidth) +
281
- statusText.padEnd(statusPadding) +
282
- formatRuntime(proc.startTime, proc.endTime).padEnd(timeWidth) +
283
- formatBytes(totalSize).padStart(sizeWidth);
284
-
285
- if (isSelected) {
286
- lines.push(padLine(`${accent(">")} ${row}`));
287
- } else {
288
- lines.push(padLine(` ${row}`));
289
- }
290
- }
291
-
292
- // Pad process list to fixed height
293
- for (let i = visibleProcessCount; i < MAX_VISIBLE_PROCESSES; i++) {
294
- lines.push(padLine(""));
295
- }
296
-
297
- // Output section for selected process
298
- if (this.selectedIndex < processes.length) {
299
- const selected = processes[this.selectedIndex];
300
- if (!selected) {
301
- this.cachedLines = lines;
302
- this.cachedWidth = width;
303
- return this.cachedLines;
304
- }
305
- const output = this.manager.getOutput(selected.id, 200);
306
- const sizes = this.manager.getFileSize(selected.id);
307
-
308
- lines.push(border("─".repeat(width)));
309
-
310
- // Output header with size info
311
- const logTitle = `Output: ${accent(selected.name)} ${dim(`(${selected.id})`)}`;
312
- const sizeInfo = sizes
313
- ? dim(
314
- ` stdout: ${formatBytes(sizes.stdout)}, stderr: ${formatBytes(sizes.stderr)}`,
315
- )
316
- : "";
317
- lines.push(padLine(logTitle + sizeInfo));
318
- lines.push(padLine(""));
319
-
320
- let renderedLines = 0;
321
-
322
- if (output) {
323
- const logLines: { type: "stdout" | "stderr"; text: string }[] = [];
324
- for (const line of output.stdout) {
325
- logLines.push({ type: "stdout", text: line });
326
- }
327
- for (const line of output.stderr) {
328
- logLines.push({ type: "stderr", text: line });
329
- }
330
-
331
- if (logLines.length === 0) {
332
- lines.push(padLine(dim("(no output yet)")));
333
- renderedLines = 1;
334
- } else {
335
- const startIdx = Math.max(
336
- 0,
337
- logLines.length - MAX_LOG_LINES - this.logScrollOffset,
338
- );
339
- const endIdx = Math.max(0, logLines.length - this.logScrollOffset);
340
- const visibleLines = logLines.slice(startIdx, endIdx);
341
-
342
- // Track scroll info for footer
343
- this.scrollInfo.above = startIdx;
344
- this.scrollInfo.below =
345
- this.logScrollOffset > 0 ? logLines.length - endIdx : 0;
346
-
347
- for (const line of visibleLines) {
348
- const displayLine = truncate(line.text, innerWidth - 2);
349
- if (line.type === "stderr") {
350
- lines.push(padLine(warning(displayLine)));
351
- } else {
352
- lines.push(padLine(displayLine));
353
- }
354
- renderedLines++;
355
- }
356
- }
357
- }
358
-
359
- // Pad to fixed height
360
- while (renderedLines < MAX_LOG_LINES) {
361
- lines.push(padLine(""));
362
- renderedLines++;
363
- }
364
- }
365
- }
366
-
367
- // Footer divider
368
- lines.push(border("─".repeat(width)));
369
-
370
- // Footer with controls
371
- const footerLeft =
372
- `${dim("j/k")} select ` +
373
- `${dim("x")} kill ` +
374
- `${dim("c")} clear ` +
375
- `${dim("q")} quit`;
376
-
377
- let footerRight = "";
378
- if (this.scrollInfo.above > 0 || this.scrollInfo.below > 0) {
379
- const parts: string[] = [];
380
- if (this.scrollInfo.above > 0) {
381
- parts.push(`↑${this.scrollInfo.above}`);
382
- }
383
- if (this.scrollInfo.below > 0) {
384
- parts.push(`↓${this.scrollInfo.below}`);
385
- }
386
- footerRight = `${dim("J/K")} scroll ${dim(parts.join(" "))}`;
387
- }
388
-
389
- const footerLeftLen = visibleWidth(footerLeft);
390
- const footerRightLen = visibleWidth(footerRight);
391
- const footerGap = Math.max(2, innerWidth - footerLeftLen - footerRightLen);
392
- const footer = footerLeft + " ".repeat(footerGap) + footerRight;
393
-
394
- lines.push(padLine(footer));
395
-
396
- // Bottom border
397
- lines.push(border("─".repeat(width)));
398
-
399
- this.cachedLines = lines;
400
- this.cachedWidth = width;
401
-
402
- return this.cachedLines;
403
- }
404
-
405
- private formatStatus(proc: ProcessInfo): string {
406
- const theme = this.theme;
407
- const dim = (s: string) => theme.fg("dim", s);
408
- const success = (s: string) => theme.fg("success", s);
409
- const warning = (s: string) => theme.fg("warning", s);
410
- const error = (s: string) => theme.fg("error", s);
411
-
412
- const icon = this.getStatusIcon(proc.status, proc.success);
413
-
414
- switch (proc.status) {
415
- case "running":
416
- return success(`${icon} running`);
417
- case "exited":
418
- if (proc.success) {
419
- return dim(`${icon} exit(0)`);
420
- }
421
- return error(`${icon} exit(${proc.exitCode ?? "?"})`);
422
- case "killed":
423
- return warning(`${icon} killed`);
424
- default:
425
- return proc.status;
426
- }
427
- }
428
-
429
- private getStatusIcon(
430
- status: ProcessInfo["status"],
431
- success: boolean | null,
432
- ): string {
433
- switch (status) {
434
- case "running":
435
- return "\u25CF"; // filled circle
436
- case "exited":
437
- return success ? "\u2713" : "\u2717"; // check or x
438
- case "killed":
439
- return "\u2717"; // x mark
440
- default:
441
- return "?";
442
- }
443
- }
444
- }
445
-
446
- const WIDGET_ID = "processes-status";
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { ProcessesComponent } from "../components/processes-component";
3
+ import type { ProcessManager } from "../manager";
447
4
 
448
5
  export function setupProcessesCommands(
449
6
  pi: ExtensionAPI,
@@ -466,23 +23,4 @@ export function setupProcessesCommands(
466
23
  });
467
24
  },
468
25
  });
469
-
470
- pi.registerCommand("processes:clear", {
471
- description: "Clear finished processes and hide widget",
472
- handler: async (_args, ctx) => {
473
- const cleared = manager.clearFinished();
474
- const remaining = manager.list();
475
-
476
- // Hide widget if no processes remain
477
- if (remaining.length === 0 && ctx.hasUI) {
478
- ctx.ui.setWidget(WIDGET_ID, undefined);
479
- }
480
-
481
- if (cleared > 0) {
482
- ctx.ui.notify(`Cleared ${cleared} finished process(es)`, "info");
483
- } else {
484
- ctx.ui.notify("No finished processes to clear", "info");
485
- }
486
- },
487
- });
488
26
  }
package/hooks/cleanup.ts CHANGED
@@ -2,7 +2,9 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import type { ProcessManager } from "../manager";
3
3
 
4
4
  export function setupCleanupHook(pi: ExtensionAPI, manager: ProcessManager) {
5
- pi.on("session_shutdown", async () => {
5
+ pi.on("session_shutdown", () => {
6
+ manager.stopWatcher();
7
+ manager.shutdownKillAll();
6
8
  manager.cleanup();
7
9
  });
8
10
  }
@@ -2,24 +2,9 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@mariozechner/pi-coding-agent";
5
- import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants";
6
- import type { ProcessInfo, ProcessManager } from "../manager";
7
-
8
- function formatRuntime(startTime: number, endTime: number | null): string {
9
- const end = endTime ?? Date.now();
10
- const ms = end - startTime;
11
- const seconds = Math.floor(ms / 1000);
12
- const minutes = Math.floor(seconds / 60);
13
- const hours = Math.floor(minutes / 60);
14
-
15
- if (hours > 0) {
16
- return `${hours}h ${minutes % 60}m`;
17
- }
18
- if (minutes > 0) {
19
- return `${minutes}m ${seconds % 60}s`;
20
- }
21
- return `${seconds}s`;
22
- }
5
+ import { MESSAGE_TYPE_PROCESS_UPDATE, type ProcessInfo } from "../constants";
6
+ import type { ProcessManager } from "../manager";
7
+ import { formatRuntime } from "../utils";
23
8
 
24
9
  interface ProcessUpdateDetails {
25
10
  processId: string;
@@ -47,8 +32,11 @@ export function setupProcessEndHook(pi: ExtensionAPI, manager: ProcessManager) {
47
32
  latestContext = ctx;
48
33
  });
49
34
 
50
- // Set callback for process end events
51
- manager.onProcessEnd = (info: ProcessInfo) => {
35
+ manager.onEvent((event) => {
36
+ if (event.type !== "process_ended") return;
37
+
38
+ const info: ProcessInfo = event.info;
39
+
52
40
  // Check notification preferences
53
41
  const shouldNotify =
54
42
  (info.status === "killed" && info.notifyOnKill) ||
@@ -99,5 +87,5 @@ export function setupProcessEndHook(pi: ExtensionAPI, manager: ProcessManager) {
99
87
  { triggerTurn: false },
100
88
  );
101
89
  }
102
- };
90
+ });
103
91
  }