@aliou/pi-processes 0.4.5 → 0.4.7

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
@@ -20,8 +20,8 @@ pi install git:github.com/aliou/pi-processes
20
20
 
21
21
  ## Features
22
22
 
23
- - **Tool**: `processes` with actions: `start`, `list`, `output`, `logs`, `kill`, `clear`
24
- - **Command**: `/processes` - interactive panel to view and manage processes
23
+ - **Tool**: `process` with actions: `start`, `list`, `output`, `logs`, `kill`, `clear`
24
+ - **Commands**: `/process:list` (interactive panel), `/process:stream` (stream logs), `/process:logs` (log paths), `/process:kill` (kill process), `/process:clear` (clear finished)
25
25
  - Auto-cleanup on session exit
26
26
  - File-based logging (logs written to temp files, not memory)
27
27
  - Friendly process names (auto-inferred or custom)
@@ -31,48 +31,55 @@ pi install git:github.com/aliou/pi-processes
31
31
  ### Tool (for agent)
32
32
 
33
33
  ```
34
- processes start "pnpm dev" name="backend-dev"
35
- processes start "pnpm build" name="build" notifyOnSuccess=true
36
- processes start "pnpm test" notifyOnFailure=true
37
- processes list
38
- processes output id="backend"
39
- processes logs id="proc_1"
40
- processes kill id="backend"
41
- processes clear
34
+ process start "pnpm dev" name="backend-dev"
35
+ process start "pnpm build" name="build" alertOnSuccess=true
36
+ process start "pnpm test" alertOnFailure=true
37
+ process list
38
+ process output id="backend"
39
+ process logs id="proc_1"
40
+ process kill id="backend"
41
+ process clear
42
42
  ```
43
43
 
44
- **Notification parameters** (for `start` action):
45
- - `notifyOnSuccess` (default: false) - Get notified when process completes successfully. Use for builds/tests where you need confirmation.
46
- - `notifyOnFailure` (default: true) - Get notified when process crashes/fails. Use to be alerted of unexpected failures.
47
- - `notifyOnKill` (default: false) - Get notified if killed by external signal. Note: killing via tool never notifies.
44
+ **Alert parameters** (for `start` action):
45
+ - `alertOnSuccess` (default: false) - Get a turn to react when process completes successfully. Use for builds/tests where you need confirmation.
46
+ - `alertOnFailure` (default: true) - Get a turn to react when process fails/crashes. Use to be alerted of unexpected failures.
47
+ - `alertOnKill` (default: false) - Get a turn to react if killed by external signal. Note: killing via tool never triggers a turn.
48
48
 
49
49
  **Important:** You don't need to poll or wait for processes. Notifications arrive automatically based on your preferences. Start processes and continue with other work - you'll be informed if something requires attention.
50
50
 
51
- Note: User always sees notifications in UI. Notification preferences only control whether the agent is informed.
51
+ Note: User always sees process updates in the UI. The alert flags control whether the agent gets a turn to react (e.g. check results, fix code, restart).
52
52
 
53
- ### Command (interactive)
53
+ ### Commands (interactive)
54
54
 
55
- Run `/processes` to open the panel:
55
+ Run `/process:list` to open the panel:
56
56
  - `j/k` - select process
57
57
  - `J/K` - scroll logs
58
+ - `enter` - stream logs for selected process
58
59
  - `x` - kill selected process
59
60
  - `c` - clear finished processes
60
61
  - `q` - quit
61
62
 
63
+ Other commands:
64
+ - `/process:stream [id|name]` - stream logs from a running process
65
+ - `/process:logs [id|name]` - show log file paths
66
+ - `/process:kill [id|name]` - kill a running process
67
+ - `/process:clear` - clear finished processes
68
+
62
69
  ## Test Scripts
63
70
 
64
- Test scripts in `test/` directory:
71
+ Test scripts in `src/test/` directory:
65
72
 
66
73
  ```bash
67
- ./test/test-output.sh # Continuous output (80 chars/sec)
68
- ./test/test-exit-success.sh 5 # Exits successfully after 5s
69
- ./test/test-exit-failure.sh 5 # Exits with code 1 after 5s
70
- ./test/test-exit-crash.sh 5 # Exits with code 137 after 5s
74
+ ./src/test/test-output.sh # Continuous output (80 chars/sec)
75
+ ./src/test/test-exit-success.sh 5 # Exits successfully after 5s
76
+ ./src/test/test-exit-failure.sh 5 # Exits with code 1 after 5s
77
+ ./src/test/test-exit-crash.sh 5 # Exits with code 137 after 5s
71
78
  ```
72
79
 
73
80
  ## Future Improvements
74
81
 
75
- - [ ] **Expandable log view**: Allow toggling between collapsed (current fixed height) and expanded (full height) log view in the `/processes` panel.
82
+ - [ ] **Expandable log view**: Allow toggling between collapsed (current fixed height) and expanded (full height) log view in the `/process:list` panel.
76
83
 
77
84
  - [ ] **Copy log file path**: Add keyboard shortcut to copy the stdout/stderr log file path to clipboard for easy access.
78
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -47,6 +47,11 @@
47
47
  "typescript": "^5.9.3",
48
48
  "vitest": "^4.0.18"
49
49
  },
50
+ "peerDependenciesMeta": {
51
+ "@mariozechner/pi-coding-agent": {
52
+ "optional": true
53
+ }
54
+ },
50
55
  "scripts": {
51
56
  "typecheck": "tsc --noEmit",
52
57
  "lint": "biome check",
@@ -0,0 +1,334 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ Theme,
5
+ } from "@mariozechner/pi-coding-agent";
6
+ import { LogStreamComponent } from "../components/log-stream-component";
7
+ import { ProcessPickerComponent } from "../components/process-picker-component";
8
+ import { ProcessesComponent } from "../components/processes-component";
9
+ import { LIVE_STATUSES, type ProcessInfo } from "../constants";
10
+ import type { ProcessManager } from "../manager";
11
+
12
+ const LOG_STREAM_WIDGET_ID = "processes-log-stream";
13
+
14
+ function runningProcessCompletions(manager: ProcessManager) {
15
+ return (prefix: string) => {
16
+ const processes = manager.list();
17
+ const lower = prefix.toLowerCase();
18
+ return processes
19
+ .filter(
20
+ (p) =>
21
+ LIVE_STATUSES.has(p.status) &&
22
+ (p.id.toLowerCase().startsWith(lower) ||
23
+ p.name.toLowerCase().startsWith(lower)),
24
+ )
25
+ .map((p) => ({
26
+ value: p.id,
27
+ label: p.id,
28
+ description: p.name,
29
+ }));
30
+ };
31
+ }
32
+
33
+ function allProcessCompletions(manager: ProcessManager) {
34
+ return (prefix: string) => {
35
+ const processes = manager.list();
36
+ const lower = prefix.toLowerCase();
37
+ return processes
38
+ .filter(
39
+ (p) =>
40
+ p.id.toLowerCase().startsWith(lower) ||
41
+ p.name.toLowerCase().startsWith(lower),
42
+ )
43
+ .map((p) => ({
44
+ value: p.id,
45
+ label: p.id,
46
+ description: p.name,
47
+ }));
48
+ };
49
+ }
50
+
51
+ export function setupProcessesCommands(
52
+ pi: ExtensionAPI,
53
+ manager: ProcessManager,
54
+ ): void {
55
+ let streamingProcessId: string | null = null;
56
+
57
+ // ── /process:list ──────────────────────────────────────────────────
58
+ // Registered first so it appears first in autocomplete.
59
+ pi.registerCommand("process:list", {
60
+ description: "View and manage background processes",
61
+ handler: async (_args, ctx) => {
62
+ if (!ctx.hasUI) {
63
+ ctx.ui.notify("/process:list requires interactive mode", "error");
64
+ return;
65
+ }
66
+
67
+ // If currently streaming, dismiss the stream widget and show the list.
68
+ if (streamingProcessId) {
69
+ ctx.ui.setWidget(LOG_STREAM_WIDGET_ID, undefined);
70
+ streamingProcessId = null;
71
+ }
72
+
73
+ const result = await ctx.ui.custom<string | null>(
74
+ (tui, theme, _keybindings, done) => {
75
+ return new ProcessesComponent(
76
+ tui,
77
+ theme,
78
+ (processId?: string) => {
79
+ if (processId) {
80
+ done(processId);
81
+ } else {
82
+ done(null);
83
+ }
84
+ },
85
+ manager,
86
+ );
87
+ },
88
+ );
89
+
90
+ // RPC fallback.
91
+ if (result === undefined) {
92
+ ctx.ui.notify("/process:list requires interactive mode", "info");
93
+ return;
94
+ }
95
+
96
+ // User dismissed with Escape/q.
97
+ if (result === null) {
98
+ return;
99
+ }
100
+
101
+ // User selected a process — start streaming its logs.
102
+ startStreaming(ctx.ui, manager, result);
103
+ },
104
+ });
105
+
106
+ // ── /process:stream [id|name] ──────────────────────────────────────
107
+ pi.registerCommand("process:stream", {
108
+ description: "Stream logs from a running process",
109
+ getArgumentCompletions: runningProcessCompletions(manager),
110
+ handler: async (args, ctx) => {
111
+ const arg = args.trim();
112
+
113
+ // Explicit argument: stream that process.
114
+ if (arg) {
115
+ const proc = manager.find(arg);
116
+ if (!proc) {
117
+ ctx.ui.notify(`Process not found: ${arg}`, "error");
118
+ return;
119
+ }
120
+ if (!LIVE_STATUSES.has(proc.status)) {
121
+ ctx.ui.notify(
122
+ `${proc.name} (${proc.id}) is not running (${proc.status})`,
123
+ "info",
124
+ );
125
+ return;
126
+ }
127
+ startStreaming(ctx.ui, manager, proc.id);
128
+ return;
129
+ }
130
+
131
+ // No argument + currently streaming: dismiss.
132
+ if (streamingProcessId) {
133
+ ctx.ui.setWidget(LOG_STREAM_WIDGET_ID, undefined);
134
+ streamingProcessId = null;
135
+ return;
136
+ }
137
+
138
+ // No argument + not streaming: pick from running processes.
139
+ const running = manager.list().filter((p) => LIVE_STATUSES.has(p.status));
140
+
141
+ // No running processes.
142
+ if (running.length === 0) {
143
+ ctx.ui.notify("No running processes", "info");
144
+ return;
145
+ }
146
+
147
+ // Single running process: auto-select.
148
+ if (running.length === 1 && running[0]) {
149
+ startStreaming(ctx.ui, manager, running[0].id);
150
+ return;
151
+ }
152
+
153
+ // Multiple running processes: show picker.
154
+ const processId = await pickProcess(
155
+ ctx,
156
+ manager,
157
+ "Select process to stream",
158
+ (p) => LIVE_STATUSES.has(p.status),
159
+ );
160
+ if (!processId) return;
161
+ startStreaming(ctx.ui, manager, processId);
162
+ },
163
+ });
164
+
165
+ // ── /process:logs [id|name] ────────────────────────────────────────
166
+ pi.registerCommand("process:logs", {
167
+ description: "Show log file paths for a process",
168
+ getArgumentCompletions: allProcessCompletions(manager),
169
+ handler: async (args, ctx) => {
170
+ const arg = args.trim();
171
+
172
+ let processId: string | undefined;
173
+
174
+ if (arg) {
175
+ const proc = manager.find(arg);
176
+ if (!proc) {
177
+ ctx.ui.notify(`Process not found: ${arg}`, "error");
178
+ return;
179
+ }
180
+ processId = proc.id;
181
+ } else {
182
+ // No argument: show picker.
183
+ processId = await pickProcess(ctx, manager, "Select process for logs");
184
+ if (!processId) return;
185
+ }
186
+
187
+ const logFiles = manager.getLogFiles(processId);
188
+ const proc = manager.get(processId);
189
+ if (!logFiles || !proc) {
190
+ ctx.ui.notify(`Process not found: ${processId}`, "error");
191
+ return;
192
+ }
193
+
194
+ ctx.ui.notify(
195
+ `${proc.name} (${proc.id})\nstdout: ${logFiles.stdoutFile}\nstderr: ${logFiles.stderrFile}`,
196
+ "info",
197
+ );
198
+ },
199
+ });
200
+
201
+ // ── /process:kill [id|name] ────────────────────────────────────────
202
+ pi.registerCommand("process:kill", {
203
+ description: "Kill a running background process",
204
+ getArgumentCompletions: runningProcessCompletions(manager),
205
+ handler: async (args, ctx) => {
206
+ const arg = args.trim();
207
+
208
+ let processId: string | undefined;
209
+
210
+ if (arg) {
211
+ const proc = manager.find(arg);
212
+ if (!proc) {
213
+ ctx.ui.notify(`Process not found: ${arg}`, "error");
214
+ return;
215
+ }
216
+ if (!LIVE_STATUSES.has(proc.status)) {
217
+ ctx.ui.notify(
218
+ `${proc.name} (${proc.id}) is not running (${proc.status})`,
219
+ "info",
220
+ );
221
+ return;
222
+ }
223
+ processId = proc.id;
224
+ } else {
225
+ // No argument: show picker (only running processes).
226
+ const running = manager
227
+ .list()
228
+ .filter((p) => LIVE_STATUSES.has(p.status));
229
+
230
+ if (running.length === 0) {
231
+ ctx.ui.notify("No running processes to kill", "info");
232
+ return;
233
+ }
234
+
235
+ if (running.length === 1 && running[0]) {
236
+ processId = running[0].id;
237
+ } else {
238
+ processId = await pickProcess(
239
+ ctx,
240
+ manager,
241
+ "Select process to kill",
242
+ (p) => LIVE_STATUSES.has(p.status),
243
+ );
244
+ if (!processId) return;
245
+ }
246
+ }
247
+
248
+ const proc = manager.get(processId);
249
+ if (!proc) {
250
+ ctx.ui.notify(`Process not found: ${processId}`, "error");
251
+ return;
252
+ }
253
+
254
+ const signal =
255
+ proc.status === "terminate_timeout" ? "SIGKILL" : "SIGTERM";
256
+ const timeoutMs = signal === "SIGKILL" ? 200 : 3000;
257
+ const result = await manager.kill(processId, { signal, timeoutMs });
258
+
259
+ if (result.ok) {
260
+ ctx.ui.notify(`Killed ${proc.name} (${proc.id})`, "info");
261
+ } else {
262
+ ctx.ui.notify(
263
+ `Failed to kill ${proc.name} (${proc.id}): ${result.reason}`,
264
+ "error",
265
+ );
266
+ }
267
+ },
268
+ });
269
+
270
+ // ── /process:clear ─────────────────────────────────────────────────
271
+ pi.registerCommand("process:clear", {
272
+ description: "Clear finished processes",
273
+ handler: async (_args, ctx) => {
274
+ const cleared = manager.clearFinished();
275
+ if (cleared > 0) {
276
+ ctx.ui.notify(
277
+ `Cleared ${cleared} finished process${cleared > 1 ? "es" : ""}`,
278
+ "info",
279
+ );
280
+ } else {
281
+ ctx.ui.notify("No finished processes to clear", "info");
282
+ }
283
+ },
284
+ });
285
+
286
+ // ── Helpers ────────────────────────────────────────────────────────
287
+
288
+ function startStreaming(
289
+ ui: ExtensionCommandContext["ui"],
290
+ mgr: ProcessManager,
291
+ processId: string,
292
+ ) {
293
+ streamingProcessId = processId;
294
+ ui.setWidget(
295
+ LOG_STREAM_WIDGET_ID,
296
+ (tui: { requestRender: () => void }, theme: Theme) => {
297
+ return new LogStreamComponent(tui, theme, mgr, processId);
298
+ },
299
+ { placement: "aboveEditor" },
300
+ );
301
+ }
302
+ }
303
+
304
+ async function pickProcess(
305
+ ctx: ExtensionCommandContext,
306
+ manager: ProcessManager,
307
+ title: string,
308
+ filter?: (proc: ProcessInfo) => boolean,
309
+ ): Promise<string | undefined> {
310
+ if (!ctx.hasUI) {
311
+ ctx.ui.notify("Interactive mode required", "error");
312
+ return undefined;
313
+ }
314
+
315
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
316
+ return new ProcessPickerComponent(
317
+ tui,
318
+ theme,
319
+ (processId?: string) => {
320
+ done(processId ?? null);
321
+ },
322
+ manager,
323
+ title,
324
+ filter,
325
+ );
326
+ });
327
+
328
+ // RPC fallback or user cancelled.
329
+ if (result === undefined || result === null) {
330
+ return undefined;
331
+ }
332
+
333
+ return result;
334
+ }
@@ -0,0 +1,157 @@
1
+ import {
2
+ registerSettingsCommand,
3
+ type SettingsSection,
4
+ } from "@aliou/pi-utils-settings";
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import type { ProcessesConfig, ResolvedProcessesConfig } from "../config";
7
+ import { configLoader } from "../config";
8
+
9
+ export function registerProcessesSettings(
10
+ pi: ExtensionAPI,
11
+ onSave?: () => void,
12
+ ): void {
13
+ registerSettingsCommand<ProcessesConfig, ResolvedProcessesConfig>(pi, {
14
+ commandName: "process:settings",
15
+ title: "Processes Settings",
16
+ configStore: configLoader,
17
+ buildSections: (
18
+ tabConfig: ProcessesConfig | null,
19
+ resolved: ResolvedProcessesConfig,
20
+ ): SettingsSection[] => {
21
+ return [
22
+ {
23
+ label: "Process List",
24
+ items: [
25
+ {
26
+ id: "processList.maxVisibleProcesses",
27
+ label: "Max visible processes",
28
+ description:
29
+ "Maximum processes shown in the /processes list before scrolling",
30
+ currentValue: String(
31
+ tabConfig?.processList?.maxVisibleProcesses ??
32
+ resolved.processList.maxVisibleProcesses,
33
+ ),
34
+ values: ["4", "6", "8", "12", "16"],
35
+ },
36
+ {
37
+ id: "processList.maxPreviewLines",
38
+ label: "Max preview lines",
39
+ description: "Log preview lines shown below the selected process",
40
+ currentValue: String(
41
+ tabConfig?.processList?.maxPreviewLines ??
42
+ resolved.processList.maxPreviewLines,
43
+ ),
44
+ values: ["6", "8", "12", "16", "24"],
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ label: "Output Limits",
50
+ items: [
51
+ {
52
+ id: "output.defaultTailLines",
53
+ label: "Default tail lines",
54
+ description:
55
+ "Number of tail lines returned to the agent by default",
56
+ currentValue: String(
57
+ tabConfig?.output?.defaultTailLines ??
58
+ resolved.output.defaultTailLines,
59
+ ),
60
+ values: ["50", "100", "200", "500"],
61
+ },
62
+ {
63
+ id: "output.maxOutputLines",
64
+ label: "Max output lines",
65
+ description: "Hard cap on output lines returned to the agent",
66
+ currentValue: String(
67
+ tabConfig?.output?.maxOutputLines ??
68
+ resolved.output.maxOutputLines,
69
+ ),
70
+ values: ["100", "200", "500", "1000"],
71
+ },
72
+ ],
73
+ },
74
+ {
75
+ label: "Execution",
76
+ items: [
77
+ {
78
+ id: "execution.shellPath",
79
+ label: "Shell path",
80
+ description:
81
+ "Absolute shell path override used to execute commands",
82
+ currentValue:
83
+ tabConfig?.execution?.shellPath ??
84
+ resolved.execution.shellPath ??
85
+ "auto",
86
+ values: [
87
+ "auto",
88
+ "/run/current-system/sw/bin/bash",
89
+ "/bin/bash",
90
+ "/usr/bin/bash",
91
+ "/usr/local/bin/bash",
92
+ ],
93
+ },
94
+ ],
95
+ },
96
+ {
97
+ label: "Widget",
98
+ items: [
99
+ {
100
+ id: "widget.showStatusWidget",
101
+ label: "Show status widget",
102
+ description: "Show process status widget below the editor",
103
+ currentValue:
104
+ (tabConfig?.widget?.showStatusWidget ??
105
+ resolved.widget.showStatusWidget)
106
+ ? "on"
107
+ : "off",
108
+ values: ["on", "off"],
109
+ },
110
+ ],
111
+ },
112
+ ];
113
+ },
114
+ onSettingChange: (id, newValue, config) => {
115
+ const updated = structuredClone(config);
116
+ // Boolean fields.
117
+ if (id === "widget.showStatusWidget") {
118
+ if (!updated.widget) updated.widget = {};
119
+ updated.widget.showStatusWidget = newValue === "on";
120
+ return updated;
121
+ }
122
+ if (id === "execution.shellPath") {
123
+ if (!updated.execution) updated.execution = {};
124
+ updated.execution.shellPath =
125
+ newValue === "auto" ? undefined : newValue;
126
+ return updated;
127
+ }
128
+
129
+ // Numeric fields.
130
+ const num = Number.parseInt(newValue, 10);
131
+ if (Number.isNaN(num)) return null;
132
+
133
+ switch (id) {
134
+ case "processList.maxVisibleProcesses":
135
+ if (!updated.processList) updated.processList = {};
136
+ updated.processList.maxVisibleProcesses = num;
137
+ break;
138
+ case "processList.maxPreviewLines":
139
+ if (!updated.processList) updated.processList = {};
140
+ updated.processList.maxPreviewLines = num;
141
+ break;
142
+ case "output.defaultTailLines":
143
+ if (!updated.output) updated.output = {};
144
+ updated.output.defaultTailLines = num;
145
+ break;
146
+ case "output.maxOutputLines":
147
+ if (!updated.output) updated.output = {};
148
+ updated.output.maxOutputLines = num;
149
+ break;
150
+ default:
151
+ return null;
152
+ }
153
+ return updated;
154
+ },
155
+ onSave,
156
+ });
157
+ }