@aliou/pi-processes 0.6.4 → 0.7.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.
@@ -1,4 +1,4 @@
1
- import { ToolBody, ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
1
+ import { ToolBody } from "@aliou/pi-utils-ui";
2
2
  import { StringEnum } from "@mariozechner/pi-ai";
3
3
  import type {
4
4
  AgentToolResult,
@@ -10,17 +10,27 @@ import { Text } from "@mariozechner/pi-tui";
10
10
  import { type Static, Type } from "@sinclair/typebox";
11
11
  import type { ProcessesDetails } from "../constants";
12
12
  import type { ProcessManager } from "../manager";
13
- import { formatRuntime, hasAnsi, stripAnsi, truncateCmd } from "../utils";
14
- import { executeAction } from "./actions";
13
+ import { executeAction, renderActionCall, renderActionResult } from "./actions";
14
+
15
+ const DEBUG_PREVIEW_ENABLED = process.env.PI_PROCESSES_DEBUG_PREVIEW === "1";
16
+
17
+ const PROCESS_ACTIONS = [
18
+ "start",
19
+ "list",
20
+ "output",
21
+ "logs",
22
+ "kill",
23
+ "clear",
24
+ "write",
25
+ ...(DEBUG_PREVIEW_ENABLED ? (["debug_preview"] as const) : []),
26
+ ] as const;
15
27
 
16
28
  const ProcessesParams = Type.Object({
17
- action: StringEnum(
18
- ["start", "list", "output", "logs", "kill", "clear", "write"] as const,
19
- {
20
- description:
21
- "Action: start (run command), list (show all), output (get recent output), logs (get log file paths), kill (terminate), clear (remove finished), write (write to stdin)",
22
- },
23
- ),
29
+ action: StringEnum(PROCESS_ACTIONS, {
30
+ description: DEBUG_PREVIEW_ENABLED
31
+ ? "Action: start (run command), list (show all), output (get recent output), logs (get log file paths), kill (terminate), clear (remove finished), write (write to stdin), debug_preview (temporary UI preview, no side effects)"
32
+ : "Action: start (run command), list (show all), output (get recent output), logs (get log file paths), kill (terminate), clear (remove finished), write (write to stdin)",
33
+ }),
24
34
  command: Type.Optional(
25
35
  Type.String({ description: "Command to run (required for start)" }),
26
36
  ),
@@ -33,7 +43,7 @@ const ProcessesParams = Type.Object({
33
43
  id: Type.Optional(
34
44
  Type.String({
35
45
  description:
36
- "Process ID or name to match (required for output/kill/logs/write). Can be proc_N or friendly name.",
46
+ "Process ID, returned by start and list actions (required for output/kill/logs/write)",
37
47
  }),
38
48
  ),
39
49
  input: Type.Optional(
@@ -65,6 +75,41 @@ const ProcessesParams = Type.Object({
65
75
  "Get a turn to react when process is killed by external signal (default: false). Note: killing via tool never triggers a turn.",
66
76
  }),
67
77
  ),
78
+ ...(DEBUG_PREVIEW_ENABLED
79
+ ? {
80
+ preview: Type.Optional(
81
+ StringEnum(["start", "list", "output", "logs", "error"] as const, {
82
+ description:
83
+ "For action=debug_preview only: which rendered result variant to preview (default: start)",
84
+ }),
85
+ ),
86
+ }
87
+ : {}),
88
+ logWatches: Type.Optional(
89
+ Type.Array(
90
+ Type.Object(
91
+ {
92
+ pattern: Type.String({
93
+ description:
94
+ "Regular expression pattern to match against process output lines",
95
+ }),
96
+ stream: Type.Optional(
97
+ StringEnum(["stdout", "stderr", "both"] as const, {
98
+ description:
99
+ "Which stream to watch (default: both). Use stdout/stderr to reduce noise.",
100
+ }),
101
+ ),
102
+ repeat: Type.Optional(
103
+ Type.Boolean({
104
+ description:
105
+ "Trigger every time this pattern matches (default: false, one-time)",
106
+ }),
107
+ ),
108
+ },
109
+ { additionalProperties: false },
110
+ ),
111
+ ),
112
+ ),
68
113
  });
69
114
 
70
115
  type ProcessesParamsType = Static<typeof ProcessesParams>;
@@ -78,13 +123,21 @@ export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
78
123
  - alertOnSuccess (default: false): Get a turn to react when process completes successfully
79
124
  - alertOnFailure (default: true): Get a turn to react when process crashes/fails
80
125
  - alertOnKill (default: false): Get a turn to react if killed by external signal (killing via tool never triggers a turn)
126
+ - logWatches (optional): Runtime output watches that trigger immediate alerts while running
127
+ - pattern: regex string to match per output line
128
+ - stream: stdout | stderr | both (default both)
129
+ - repeat: false by default (single-fire). Set true for repeat alerts
81
130
  - list: Show all managed processes with their IDs and names
82
- - output: Get recent stdout/stderr (requires 'id' - can be proc_N or name match)
131
+ - output: Get recent stdout/stderr (requires 'id')
83
132
  - logs: Get log file paths to inspect with read tool (requires 'id')
84
- - kill: Terminate a process (requires 'id' - can be proc_N or name match like "backend")
133
+ - kill: Terminate a process (requires 'id')
85
134
  - clear: Remove all finished processes from the list
86
135
  - write: Write to process stdin (requires 'id' and 'input', optional 'end' to close stdin)
87
-
136
+ ${
137
+ DEBUG_PREVIEW_ENABLED
138
+ ? "- debug_preview: Temporary renderer preview for process tool UIs (no process side effects)\n - preview: start | list | output | logs | error (default: start)\n"
139
+ : ""
140
+ }
88
141
  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.
89
142
 
90
143
  Note: User always sees process updates in the UI. The notify flags control whether YOU (the agent) get a turn to react (e.g. check results, fix code, restart).`,
@@ -104,53 +157,7 @@ Note: User always sees process updates in the UI. The notify flags control wheth
104
157
  },
105
158
 
106
159
  renderCall(args: ProcessesParamsType, theme: Theme) {
107
- const longArgs: Array<{ label?: string; value: string }> = [];
108
- const optionArgs: Array<{ label: string; value: string }> = [];
109
- let mainArg: string | undefined;
110
-
111
- if (args.action === "start") {
112
- if (args.name) {
113
- mainArg = `"${args.name}"`;
114
- }
115
-
116
- if (args.command) {
117
- if (!mainArg && args.command.length <= 60) {
118
- mainArg = args.command;
119
- } else if (args.command.length <= 60) {
120
- optionArgs.push({ label: "command", value: args.command });
121
- } else {
122
- longArgs.push({ label: "command", value: args.command });
123
- }
124
- }
125
- }
126
-
127
- if (
128
- (args.action === "output" ||
129
- args.action === "kill" ||
130
- args.action === "logs" ||
131
- args.action === "write") &&
132
- args.id
133
- ) {
134
- mainArg = args.id;
135
- }
136
-
137
- if (args.action === "write" && args.input) {
138
- optionArgs.push({ label: "input", value: args.input });
139
- if (args.end) {
140
- optionArgs.push({ label: "end", value: "true" });
141
- }
142
- }
143
-
144
- return new ToolCallHeader(
145
- {
146
- toolName: "Process",
147
- action: args.action,
148
- mainArg,
149
- optionArgs,
150
- longArgs,
151
- },
152
- theme,
153
- );
160
+ return renderActionCall(args, theme);
154
161
  },
155
162
 
156
163
  renderResult(
@@ -158,199 +165,39 @@ Note: User always sees process updates in the UI. The notify flags control wheth
158
165
  options: ToolRenderResultOptions,
159
166
  theme: Theme,
160
167
  ) {
161
- const { details } = result;
162
-
163
- if (!details) {
164
- const text = result.content[0];
165
- return new Text(
166
- text?.type === "text" && text.text ? text.text : "No result",
167
- 0,
168
- 0,
169
- );
168
+ if (options.isPartial) {
169
+ return new Text(theme.fg("muted", "Process: running..."), 0, 0);
170
170
  }
171
171
 
172
- const fields: Array<
173
- { label: string; value: string; showCollapsed?: boolean } | Text
174
- > = [];
175
-
176
- if (!details.success) {
177
- fields.push({
178
- label: "Error",
179
- value: theme.fg("error", details.message),
180
- showCollapsed: true,
181
- });
182
- } else if (details.action === "start" && details.process) {
183
- const process = details.process;
184
- fields.push({
185
- label: "Status",
186
- value:
187
- theme.fg("success", "Started") +
188
- ` ${theme.fg("accent", `"${process.name}"`)} (${process.id}, PID: ${process.pid})`,
189
- showCollapsed: true,
190
- });
191
- } else if (details.action === "output" && details.output) {
192
- const lines: string[] = [theme.fg("muted", details.message)];
193
- let hadAnsi = false;
194
-
195
- if (details.output.stdout.length > 0) {
196
- lines.push("", theme.fg("accent", "stdout:"));
197
- for (const line of details.output.stdout.slice(-20)) {
198
- if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
199
- lines.push(stripAnsi(line));
200
- }
201
- if (details.output.stdout.length > 20) {
202
- lines.push(
203
- theme.fg(
204
- "muted",
205
- `... (${details.output.stdout.length - 20} more lines)`,
206
- ),
207
- );
208
- }
209
- }
210
-
211
- if (details.output.stderr.length > 0) {
212
- lines.push("", theme.fg("warning", "stderr:"));
213
- for (const line of details.output.stderr.slice(-10)) {
214
- if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
215
- lines.push(theme.fg("warning", stripAnsi(line)));
216
- }
217
- if (details.output.stderr.length > 10) {
218
- lines.push(
219
- theme.fg(
220
- "muted",
221
- `... (${details.output.stderr.length - 10} more lines)`,
222
- ),
223
- );
224
- }
225
- }
226
-
227
- if (hadAnsi) {
228
- lines.push(
229
- "",
230
- theme.fg("muted", "ANSI escape codes were stripped from output"),
231
- );
232
- }
233
-
234
- fields.push(new Text(lines.join("\n"), 0, 0));
235
-
236
- // Collapsed summary
237
- const previewSource =
238
- details.output.stdout.length > 0
239
- ? details.output.stdout
240
- : details.output.stderr;
241
- const preview = previewSource
242
- .slice(-2)
243
- .map((l) => stripAnsi(l))
244
- .join("\n");
245
- fields.push({
246
- label: "Output",
247
- value: preview
248
- ? `${theme.fg("muted", preview)}`
249
- : theme.fg("muted", "(empty)"),
250
- showCollapsed: true,
251
- });
252
- } else if (
253
- details.action === "list" &&
254
- details.processes &&
255
- details.processes.length > 0
256
- ) {
257
- const lines: string[] = [
258
- theme.fg("success", `${details.processes.length} process(es):`),
259
- ];
260
-
261
- for (const process of details.processes) {
262
- let status: string;
263
- switch (process.status) {
264
- case "running":
265
- status = theme.fg("accent", "running");
266
- break;
267
- case "terminating":
268
- status = theme.fg("warning", "terminating");
269
- break;
270
- case "terminate_timeout":
271
- status = theme.fg("error", "terminate_timeout");
272
- break;
273
- case "killed":
274
- status = theme.fg("warning", "killed");
275
- break;
276
- case "exited":
277
- status = process.success
278
- ? theme.fg("success", "exit(0)")
279
- : theme.fg("error", `exit(${process.exitCode ?? "?"})`);
280
- break;
281
- default:
282
- status = theme.fg("muted", process.status);
283
- }
284
-
285
- lines.push(
286
- ` ${process.id} ${theme.fg("accent", `"${process.name}"`)}: ${truncateCmd(process.command)} [${status}] ${formatRuntime(process.startTime, process.endTime)}`,
287
- );
288
- }
289
-
290
- fields.push(new Text(lines.join("\n"), 0, 0));
172
+ const { details } = result;
291
173
 
292
- // Collapsed summary: first 3 processes
293
- const summary = details.processes
294
- .slice(0, 3)
295
- .map((p) => {
296
- const s =
297
- p.status === "running"
298
- ? theme.fg("accent", "running")
299
- : p.status === "exited" && p.success
300
- ? theme.fg("success", "exit(0)")
301
- : p.status === "exited"
302
- ? theme.fg("error", `exit(${p.exitCode ?? "?"})`)
303
- : theme.fg("muted", p.status);
304
- return `${theme.fg("accent", `"${p.name}"`)} [${s}]`;
305
- })
306
- .join(", ");
307
- const more =
308
- details.processes.length > 3
309
- ? theme.fg("muted", ` +${details.processes.length - 3} more`)
310
- : "";
311
- fields.push({
312
- label: "Processes",
313
- value: summary + more,
314
- showCollapsed: true,
315
- });
316
- } else if (details.action === "logs" && details.logFiles) {
317
- fields.push(
318
- new Text(
319
- [
320
- theme.fg("success", "Log files:"),
321
- ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
322
- ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
323
- ].join("\n"),
324
- 0,
325
- 0,
326
- ),
327
- );
328
- } else {
329
- fields.push({
330
- label: "Result",
331
- value: details.message,
332
- showCollapsed: true,
333
- });
174
+ // Framework sets details to {} when tool throws.
175
+ // Detect by checking for missing expected fields.
176
+ if (!details?.action) {
177
+ const textBlock = result.content.find((c) => c.type === "text");
178
+ const errorMsg =
179
+ (textBlock?.type === "text" && textBlock.text) ||
180
+ "Tool execution failed";
181
+ return new Text(theme.fg("error", errorMsg), 0, 0);
334
182
  }
335
183
 
336
- const footerItems: Array<{
337
- label: string;
338
- value: string;
339
- tone: "accent" | "success" | "error" | "warning" | "muted";
340
- }> = [];
341
184
  if (!details.success) {
342
- footerItems.push({
343
- label: "status",
344
- value: "error",
345
- tone: "error",
346
- });
185
+ return new ToolBody(
186
+ {
187
+ fields: [
188
+ {
189
+ label: "Error",
190
+ value: theme.fg("error", details.message),
191
+ showCollapsed: true,
192
+ },
193
+ ],
194
+ },
195
+ options,
196
+ theme,
197
+ );
347
198
  }
348
- const footer =
349
- footerItems.length > 0
350
- ? new ToolFooter(theme, { items: footerItems })
351
- : undefined;
352
199
 
353
- return new ToolBody({ fields, footer }, options, theme);
200
+ return renderActionResult(result, options, theme);
354
201
  },
355
202
  });
356
203
  }
@@ -1,3 +1,6 @@
1
+ // Uses node:child_process directly instead of pi.exec() because process
2
+ // management requires long-lived streaming processes with stdin/stdout piping
3
+ // and detached process groups, which pi.exec() does not support.
1
4
  import { type ChildProcess, spawn } from "node:child_process";
2
5
  import { existsSync } from "node:fs";
3
6
  import { isAbsolute } from "node:path";
@@ -1,3 +1,4 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
1
2
  import type { ProcessInfo } from "../constants";
2
3
 
3
4
  export function formatRuntime(
@@ -40,3 +41,34 @@ export function truncateCmd(cmd: string, max = 40): string {
40
41
  if (cmd.length <= max) return cmd;
41
42
  return `${cmd.slice(0, max - 3)}...`;
42
43
  }
44
+
45
+ export function formatTimestamp(ts: number | null): string {
46
+ if (!ts) return "-";
47
+ return new Date(ts).toISOString().replace("T", " ").slice(0, 19);
48
+ }
49
+
50
+ export function formatStatusTag(
51
+ process: {
52
+ status: string;
53
+ success: boolean | null;
54
+ exitCode: number | null;
55
+ },
56
+ theme: Theme,
57
+ ): string {
58
+ switch (process.status) {
59
+ case "running":
60
+ return theme.fg("accent", "running");
61
+ case "terminating":
62
+ return theme.fg("warning", "terminating");
63
+ case "terminate_timeout":
64
+ return theme.fg("error", "terminate_timeout");
65
+ case "killed":
66
+ return theme.fg("warning", "killed");
67
+ case "exited":
68
+ return process.success
69
+ ? theme.fg("success", "exit(0)")
70
+ : theme.fg("error", `exit(${process.exitCode ?? "?"})`);
71
+ default:
72
+ return theme.fg("muted", process.status);
73
+ }
74
+ }
@@ -1,3 +1,9 @@
1
1
  export { hasAnsi, stripAnsi } from "./ansi";
2
- export { formatRuntime, formatStatus, truncateCmd } from "./format";
2
+ export {
3
+ formatRuntime,
4
+ formatStatus,
5
+ formatStatusTag,
6
+ formatTimestamp,
7
+ truncateCmd,
8
+ } from "./format";
3
9
  export { isProcessGroupAlive, killProcessGroup } from "./process-group";