@aliou/pi-processes 0.1.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/tools/index.ts ADDED
@@ -0,0 +1,532 @@
1
+ import { StringEnum } from "@mariozechner/pi-ai";
2
+ import type {
3
+ AgentToolResult,
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ Theme,
7
+ ToolRenderResultOptions,
8
+ } from "@mariozechner/pi-coding-agent";
9
+ import { Text } from "@mariozechner/pi-tui";
10
+ import { type Static, Type } from "@sinclair/typebox";
11
+ import type { ProcessInfo, ProcessManager } from "../manager";
12
+
13
+ const ProcessesParams = Type.Object({
14
+ action: StringEnum(
15
+ ["start", "list", "output", "logs", "kill", "clear"] as const,
16
+ {
17
+ description:
18
+ "Action: start (run command), list (show all), output (get recent output), logs (get log file paths), kill (terminate), clear (remove finished)",
19
+ },
20
+ ),
21
+ command: Type.Optional(
22
+ Type.String({ description: "Command to run (required for start)" }),
23
+ ),
24
+ name: Type.Optional(
25
+ Type.String({
26
+ description:
27
+ "Friendly name for the process (optional for start, e.g. 'backend-dev', 'test-runner')",
28
+ }),
29
+ ),
30
+ id: Type.Optional(
31
+ Type.String({
32
+ description:
33
+ "Process ID or name to match (required for output/kill/logs). Can be proc_N or friendly name.",
34
+ }),
35
+ ),
36
+ notifyOnSuccess: Type.Optional(
37
+ Type.Boolean({
38
+ description:
39
+ "Notify when process completes successfully (default: false). Use for builds/tests where you need confirmation.",
40
+ }),
41
+ ),
42
+ notifyOnFailure: Type.Optional(
43
+ Type.Boolean({
44
+ description:
45
+ "Notify when process fails/crashes (default: true). Use to be alerted of unexpected failures.",
46
+ }),
47
+ ),
48
+ notifyOnKill: Type.Optional(
49
+ Type.Boolean({
50
+ description:
51
+ "Notify when process is killed by external signal (default: false). Note: killing via tool never notifies.",
52
+ }),
53
+ ),
54
+ });
55
+
56
+ type ProcessesParamsType = Static<typeof ProcessesParams>;
57
+
58
+ interface ProcessesDetails {
59
+ action: string;
60
+ success: boolean;
61
+ message: string;
62
+ process?: ProcessInfo;
63
+ processes?: ProcessInfo[];
64
+ output?: { stdout: string[]; stderr: string[]; status: string };
65
+ logFiles?: { stdoutFile: string; stderrFile: string };
66
+ cleared?: number;
67
+ }
68
+
69
+ interface ExecuteResult {
70
+ content: Array<{ type: "text"; text: string }>;
71
+ details: ProcessesDetails;
72
+ }
73
+
74
+ function formatRuntime(startTime: number, endTime: number | null): string {
75
+ const end = endTime ?? Date.now();
76
+ const ms = end - startTime;
77
+ const seconds = Math.floor(ms / 1000);
78
+ const minutes = Math.floor(seconds / 60);
79
+ const hours = Math.floor(minutes / 60);
80
+
81
+ if (hours > 0) {
82
+ return `${hours}h ${minutes % 60}m`;
83
+ }
84
+ if (minutes > 0) {
85
+ return `${minutes}m ${seconds % 60}s`;
86
+ }
87
+ return `${seconds}s`;
88
+ }
89
+
90
+ function formatStatus(proc: ProcessInfo): string {
91
+ if (proc.status === "running") return "running";
92
+ if (proc.status === "killed") return "killed";
93
+ if (proc.success) return "exited(0)";
94
+ return `exited(${proc.exitCode ?? "?"})`;
95
+ }
96
+
97
+ function truncateCmd(cmd: string, max = 40): string {
98
+ if (cmd.length <= max) return cmd;
99
+ return `${cmd.slice(0, max - 3)}...`;
100
+ }
101
+
102
+ export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
103
+ pi.registerTool<typeof ProcessesParams, ProcessesDetails>({
104
+ name: "processes",
105
+ label: "Processes",
106
+ description: `Manage background processes. Actions:
107
+ - start: Run command in background (requires 'command', optional 'name' for friendly display name)
108
+ - notifyOnSuccess (default: false): Get notified when process completes successfully
109
+ - notifyOnFailure (default: true): Get notified when process crashes/fails
110
+ - notifyOnKill (default: false): Get notified if killed by external signal (killing via tool never notifies)
111
+ - list: Show all managed processes with their IDs and names
112
+ - output: Get recent stdout/stderr (requires 'id' - can be proc_N or name match)
113
+ - logs: Get log file paths to inspect with read tool (requires 'id')
114
+ - kill: Terminate a process (requires 'id' - can be proc_N or name match like "backend")
115
+ - clear: Remove all finished processes from the list
116
+
117
+ 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.
118
+
119
+ Note: User always sees notifications in UI. Notification preferences only control whether YOU (the agent) are informed.`,
120
+
121
+ parameters: ProcessesParams,
122
+
123
+ async execute(
124
+ _toolCallId: string,
125
+ params: ProcessesParamsType,
126
+ _onUpdate: unknown,
127
+ ctx: ExtensionContext,
128
+ _signal?: AbortSignal,
129
+ ): Promise<ExecuteResult> {
130
+ switch (params.action) {
131
+ case "start": {
132
+ if (!params.command) {
133
+ return {
134
+ content: [
135
+ { type: "text", text: "Missing required parameter: command" },
136
+ ],
137
+ details: {
138
+ action: "start",
139
+ success: false,
140
+ message: "Missing required parameter: command",
141
+ },
142
+ };
143
+ }
144
+ const proc = manager.start(params.command, ctx.cwd, params.name, {
145
+ notifyOnSuccess: params.notifyOnSuccess,
146
+ notifyOnFailure: params.notifyOnFailure,
147
+ notifyOnKill: params.notifyOnKill,
148
+ });
149
+ const message = `Started "${proc.name}" (${proc.id}, PID: ${proc.pid})\nLogs: ${proc.stdoutFile}`;
150
+ return {
151
+ content: [{ type: "text", text: message }],
152
+ details: {
153
+ action: "start",
154
+ success: true,
155
+ message,
156
+ process: proc,
157
+ },
158
+ };
159
+ }
160
+
161
+ case "list": {
162
+ const processes = manager.list();
163
+ if (processes.length === 0) {
164
+ return {
165
+ content: [
166
+ { type: "text", text: "No background processes running" },
167
+ ],
168
+ details: {
169
+ action: "list",
170
+ success: true,
171
+ message: "No background processes running",
172
+ processes: [],
173
+ },
174
+ };
175
+ }
176
+ const summary = processes
177
+ .map(
178
+ (p) =>
179
+ `${p.id} "${p.name}": ${truncateCmd(p.command)} [${formatStatus(p)}] ${formatRuntime(p.startTime, p.endTime)}`,
180
+ )
181
+ .join("\n");
182
+ const message = `${processes.length} process(es):\n${summary}`;
183
+ return {
184
+ content: [{ type: "text", text: message }],
185
+ details: {
186
+ action: "list",
187
+ success: true,
188
+ message,
189
+ processes,
190
+ },
191
+ };
192
+ }
193
+
194
+ case "output": {
195
+ if (!params.id) {
196
+ return {
197
+ content: [
198
+ { type: "text", text: "Missing required parameter: id" },
199
+ ],
200
+ details: {
201
+ action: "output",
202
+ success: false,
203
+ message: "Missing required parameter: id",
204
+ },
205
+ };
206
+ }
207
+ const proc = manager.find(params.id);
208
+ if (!proc) {
209
+ const message = `Process not found: ${params.id}`;
210
+ return {
211
+ content: [{ type: "text", text: message }],
212
+ details: {
213
+ action: "output",
214
+ success: false,
215
+ message,
216
+ },
217
+ };
218
+ }
219
+ const output = manager.getOutput(proc.id);
220
+ if (!output) {
221
+ const message = `Could not read output for: ${proc.id}`;
222
+ return {
223
+ content: [{ type: "text", text: message }],
224
+ details: {
225
+ action: "output",
226
+ success: false,
227
+ message,
228
+ },
229
+ };
230
+ }
231
+ const stdoutLines = output.stdout.length;
232
+ const stderrLines = output.stderr.length;
233
+ const message = `"${proc.name}" (${proc.id}) [${formatStatus(proc)}]: ${stdoutLines} stdout lines, ${stderrLines} stderr lines`;
234
+
235
+ const outputParts: string[] = [message];
236
+ if (output.stdout.length > 0) {
237
+ outputParts.push("\n--- stdout (last 100 lines) ---");
238
+ outputParts.push(...output.stdout.slice(-100));
239
+ }
240
+ if (output.stderr.length > 0) {
241
+ outputParts.push("\n--- stderr (last 100 lines) ---");
242
+ outputParts.push(...output.stderr.slice(-100));
243
+ }
244
+
245
+ return {
246
+ content: [{ type: "text", text: outputParts.join("\n") }],
247
+ details: {
248
+ action: "output",
249
+ success: true,
250
+ message,
251
+ output,
252
+ },
253
+ };
254
+ }
255
+
256
+ case "logs": {
257
+ if (!params.id) {
258
+ return {
259
+ content: [
260
+ { type: "text", text: "Missing required parameter: id" },
261
+ ],
262
+ details: {
263
+ action: "logs",
264
+ success: false,
265
+ message: "Missing required parameter: id",
266
+ },
267
+ };
268
+ }
269
+ const proc = manager.find(params.id);
270
+ if (!proc) {
271
+ const message = `Process not found: ${params.id}`;
272
+ return {
273
+ content: [{ type: "text", text: message }],
274
+ details: {
275
+ action: "logs",
276
+ success: false,
277
+ message,
278
+ },
279
+ };
280
+ }
281
+ const logFiles = manager.getLogFiles(proc.id);
282
+ if (!logFiles) {
283
+ const message = `Could not get log files for: ${proc.id}`;
284
+ return {
285
+ content: [{ type: "text", text: message }],
286
+ details: {
287
+ action: "logs",
288
+ success: false,
289
+ message,
290
+ },
291
+ };
292
+ }
293
+ const message = `Log files for "${proc.name}" (${proc.id}):\n stdout: ${logFiles.stdoutFile}\n stderr: ${logFiles.stderrFile}\n\nUse the read tool to inspect these files.`;
294
+ return {
295
+ content: [{ type: "text", text: message }],
296
+ details: {
297
+ action: "logs",
298
+ success: true,
299
+ message,
300
+ logFiles,
301
+ },
302
+ };
303
+ }
304
+
305
+ case "kill": {
306
+ if (!params.id) {
307
+ return {
308
+ content: [
309
+ { type: "text", text: "Missing required parameter: id" },
310
+ ],
311
+ details: {
312
+ action: "kill",
313
+ success: false,
314
+ message: "Missing required parameter: id",
315
+ },
316
+ };
317
+ }
318
+ const proc = manager.find(params.id);
319
+ if (!proc) {
320
+ const message = `Process not found: ${params.id}`;
321
+ return {
322
+ content: [{ type: "text", text: message }],
323
+ details: {
324
+ action: "kill",
325
+ success: false,
326
+ message,
327
+ },
328
+ };
329
+ }
330
+ const killed = manager.kill(proc.id);
331
+ if (killed) {
332
+ const message = `Killed "${proc.name}" (${proc.id})`;
333
+ return {
334
+ content: [{ type: "text", text: message }],
335
+ details: {
336
+ action: "kill",
337
+ success: true,
338
+ message,
339
+ },
340
+ };
341
+ }
342
+ const message = `Failed to kill "${proc.name}" (${proc.id})`;
343
+ return {
344
+ content: [{ type: "text", text: message }],
345
+ details: {
346
+ action: "kill",
347
+ success: false,
348
+ message,
349
+ },
350
+ };
351
+ }
352
+
353
+ case "clear": {
354
+ const cleared = manager.clearFinished();
355
+ const message =
356
+ cleared > 0
357
+ ? `Cleared ${cleared} finished process(es)`
358
+ : "No finished processes to clear";
359
+ return {
360
+ content: [{ type: "text", text: message }],
361
+ details: {
362
+ action: "clear",
363
+ success: true,
364
+ message,
365
+ cleared,
366
+ },
367
+ };
368
+ }
369
+
370
+ default:
371
+ return {
372
+ content: [
373
+ { type: "text", text: `Unknown action: ${params.action}` },
374
+ ],
375
+ details: {
376
+ action: params.action,
377
+ success: false,
378
+ message: `Unknown action: ${params.action}`,
379
+ },
380
+ };
381
+ }
382
+ },
383
+
384
+ renderCall(args: ProcessesParamsType, theme: Theme): Text {
385
+ let text = theme.fg("toolTitle", theme.bold("processes "));
386
+ text += theme.fg("accent", args.action);
387
+
388
+ switch (args.action) {
389
+ case "start":
390
+ if (args.name) {
391
+ text += ` ${theme.fg("accent", `"${args.name}"`)}`;
392
+ }
393
+ if (args.command) {
394
+ text += ` ${theme.fg("muted", args.command.slice(0, 40))}`;
395
+ }
396
+ break;
397
+ case "output":
398
+ case "kill":
399
+ case "logs":
400
+ if (args.id) {
401
+ text += ` ${theme.fg("muted", args.id)}`;
402
+ }
403
+ break;
404
+ }
405
+
406
+ return new Text(text, 0, 0);
407
+ },
408
+
409
+ renderResult(
410
+ result: AgentToolResult<ProcessesDetails>,
411
+ _options: ToolRenderResultOptions,
412
+ theme: Theme,
413
+ ): Text {
414
+ const { details } = result;
415
+
416
+ if (!details) {
417
+ const text = result.content[0];
418
+ return new Text(
419
+ text?.type === "text" && text.text ? text.text : "No result",
420
+ 0,
421
+ 0,
422
+ );
423
+ }
424
+
425
+ if (!details.success) {
426
+ return new Text(theme.fg("error", details.message), 0, 0);
427
+ }
428
+
429
+ // For start action
430
+ if (details.action === "start" && details.process) {
431
+ const p = details.process;
432
+ return new Text(
433
+ theme.fg("success", "\u2713 Started ") +
434
+ theme.fg("accent", `"${p.name}"`) +
435
+ ` (${p.id}, PID: ${p.pid})`,
436
+ 0,
437
+ 0,
438
+ );
439
+ }
440
+
441
+ // For output action
442
+ if (details.action === "output" && details.output) {
443
+ const lines: string[] = [];
444
+ lines.push(theme.fg("muted", details.message));
445
+
446
+ if (details.output.stdout.length > 0) {
447
+ lines.push("");
448
+ lines.push(theme.fg("accent", "stdout:"));
449
+ const stdoutLines = details.output.stdout.slice(-20);
450
+ for (const line of stdoutLines) {
451
+ lines.push(line);
452
+ }
453
+ if (details.output.stdout.length > 20) {
454
+ lines.push(
455
+ theme.fg(
456
+ "muted",
457
+ `... (${details.output.stdout.length - 20} more lines)`,
458
+ ),
459
+ );
460
+ }
461
+ }
462
+
463
+ if (details.output.stderr.length > 0) {
464
+ lines.push("");
465
+ lines.push(theme.fg("warning", "stderr:"));
466
+ const stderrLines = details.output.stderr.slice(-10);
467
+ for (const line of stderrLines) {
468
+ lines.push(theme.fg("warning", line));
469
+ }
470
+ if (details.output.stderr.length > 10) {
471
+ lines.push(
472
+ theme.fg(
473
+ "muted",
474
+ `... (${details.output.stderr.length - 10} more lines)`,
475
+ ),
476
+ );
477
+ }
478
+ }
479
+
480
+ return new Text(lines.join("\n"), 0, 0);
481
+ }
482
+
483
+ // For list action
484
+ if (
485
+ details.action === "list" &&
486
+ details.processes &&
487
+ details.processes.length > 0
488
+ ) {
489
+ const lines: string[] = [];
490
+ lines.push(
491
+ theme.fg("success", `${details.processes.length} process(es):`),
492
+ );
493
+ for (const p of details.processes) {
494
+ const status =
495
+ p.status === "running"
496
+ ? theme.fg("accent", "running")
497
+ : p.success
498
+ ? theme.fg("success", "exit(0)")
499
+ : theme.fg("error", `exit(${p.exitCode})`);
500
+ lines.push(
501
+ ` ${p.id} ${theme.fg("accent", `"${p.name}"`)}: ${truncateCmd(p.command)} [${status}] ${formatRuntime(p.startTime, p.endTime)}`,
502
+ );
503
+ }
504
+ return new Text(lines.join("\n"), 0, 0);
505
+ }
506
+
507
+ // For logs action
508
+ if (details.action === "logs" && details.logFiles) {
509
+ const lines: string[] = [];
510
+ lines.push(theme.fg("success", "Log files:"));
511
+ lines.push(
512
+ ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
513
+ );
514
+ lines.push(
515
+ ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
516
+ );
517
+ return new Text(lines.join("\n"), 0, 0);
518
+ }
519
+
520
+ // For clear action
521
+ if (details.action === "clear") {
522
+ return new Text(
523
+ theme.fg("success", "\u2713 ") + theme.fg("muted", details.message),
524
+ 0,
525
+ 0,
526
+ );
527
+ }
528
+
529
+ return new Text(details.message, 0, 0);
530
+ },
531
+ });
532
+ }