@aliou/pi-processes 0.6.3 → 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,16 +123,32 @@ 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).`,
144
+ promptSnippet:
145
+ "Manage background processes without blocking the conversation",
146
+ promptGuidelines: [
147
+ "Use this tool for long-running commands such as dev servers, test watchers, build watchers, and log tails instead of bash.",
148
+ "Avoid shell background patterns such as &, nohup, disown, or setsid when the process tool fits.",
149
+ "After starting a process, continue other work instead of waiting for it.",
150
+ "Use the pi-processes skill for examples and best practices when a task depends on background processes.",
151
+ ],
91
152
 
92
153
  parameters: ProcessesParams,
93
154
 
@@ -96,53 +157,7 @@ Note: User always sees process updates in the UI. The notify flags control wheth
96
157
  },
97
158
 
98
159
  renderCall(args: ProcessesParamsType, theme: Theme) {
99
- const longArgs: Array<{ label?: string; value: string }> = [];
100
- const optionArgs: Array<{ label: string; value: string }> = [];
101
- let mainArg: string | undefined;
102
-
103
- if (args.action === "start") {
104
- if (args.name) {
105
- mainArg = `"${args.name}"`;
106
- }
107
-
108
- if (args.command) {
109
- if (!mainArg && args.command.length <= 60) {
110
- mainArg = args.command;
111
- } else if (args.command.length <= 60) {
112
- optionArgs.push({ label: "command", value: args.command });
113
- } else {
114
- longArgs.push({ label: "command", value: args.command });
115
- }
116
- }
117
- }
118
-
119
- if (
120
- (args.action === "output" ||
121
- args.action === "kill" ||
122
- args.action === "logs" ||
123
- args.action === "write") &&
124
- args.id
125
- ) {
126
- mainArg = args.id;
127
- }
128
-
129
- if (args.action === "write" && args.input) {
130
- optionArgs.push({ label: "input", value: args.input });
131
- if (args.end) {
132
- optionArgs.push({ label: "end", value: "true" });
133
- }
134
- }
135
-
136
- return new ToolCallHeader(
137
- {
138
- toolName: "Process",
139
- action: args.action,
140
- mainArg,
141
- optionArgs,
142
- longArgs,
143
- },
144
- theme,
145
- );
160
+ return renderActionCall(args, theme);
146
161
  },
147
162
 
148
163
  renderResult(
@@ -150,199 +165,39 @@ Note: User always sees process updates in the UI. The notify flags control wheth
150
165
  options: ToolRenderResultOptions,
151
166
  theme: Theme,
152
167
  ) {
153
- const { details } = result;
154
-
155
- if (!details) {
156
- const text = result.content[0];
157
- return new Text(
158
- text?.type === "text" && text.text ? text.text : "No result",
159
- 0,
160
- 0,
161
- );
168
+ if (options.isPartial) {
169
+ return new Text(theme.fg("muted", "Process: running..."), 0, 0);
162
170
  }
163
171
 
164
- const fields: Array<
165
- { label: string; value: string; showCollapsed?: boolean } | Text
166
- > = [];
167
-
168
- if (!details.success) {
169
- fields.push({
170
- label: "Error",
171
- value: theme.fg("error", details.message),
172
- showCollapsed: true,
173
- });
174
- } else if (details.action === "start" && details.process) {
175
- const process = details.process;
176
- fields.push({
177
- label: "Status",
178
- value:
179
- theme.fg("success", "Started") +
180
- ` ${theme.fg("accent", `"${process.name}"`)} (${process.id}, PID: ${process.pid})`,
181
- showCollapsed: true,
182
- });
183
- } else if (details.action === "output" && details.output) {
184
- const lines: string[] = [theme.fg("muted", details.message)];
185
- let hadAnsi = false;
186
-
187
- if (details.output.stdout.length > 0) {
188
- lines.push("", theme.fg("accent", "stdout:"));
189
- for (const line of details.output.stdout.slice(-20)) {
190
- if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
191
- lines.push(stripAnsi(line));
192
- }
193
- if (details.output.stdout.length > 20) {
194
- lines.push(
195
- theme.fg(
196
- "muted",
197
- `... (${details.output.stdout.length - 20} more lines)`,
198
- ),
199
- );
200
- }
201
- }
202
-
203
- if (details.output.stderr.length > 0) {
204
- lines.push("", theme.fg("warning", "stderr:"));
205
- for (const line of details.output.stderr.slice(-10)) {
206
- if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
207
- lines.push(theme.fg("warning", stripAnsi(line)));
208
- }
209
- if (details.output.stderr.length > 10) {
210
- lines.push(
211
- theme.fg(
212
- "muted",
213
- `... (${details.output.stderr.length - 10} more lines)`,
214
- ),
215
- );
216
- }
217
- }
218
-
219
- if (hadAnsi) {
220
- lines.push(
221
- "",
222
- theme.fg("muted", "ANSI escape codes were stripped from output"),
223
- );
224
- }
225
-
226
- fields.push(new Text(lines.join("\n"), 0, 0));
227
-
228
- // Collapsed summary
229
- const previewSource =
230
- details.output.stdout.length > 0
231
- ? details.output.stdout
232
- : details.output.stderr;
233
- const preview = previewSource
234
- .slice(-2)
235
- .map((l) => stripAnsi(l))
236
- .join("\n");
237
- fields.push({
238
- label: "Output",
239
- value: preview
240
- ? `${theme.fg("muted", preview)}`
241
- : theme.fg("muted", "(empty)"),
242
- showCollapsed: true,
243
- });
244
- } else if (
245
- details.action === "list" &&
246
- details.processes &&
247
- details.processes.length > 0
248
- ) {
249
- const lines: string[] = [
250
- theme.fg("success", `${details.processes.length} process(es):`),
251
- ];
252
-
253
- for (const process of details.processes) {
254
- let status: string;
255
- switch (process.status) {
256
- case "running":
257
- status = theme.fg("accent", "running");
258
- break;
259
- case "terminating":
260
- status = theme.fg("warning", "terminating");
261
- break;
262
- case "terminate_timeout":
263
- status = theme.fg("error", "terminate_timeout");
264
- break;
265
- case "killed":
266
- status = theme.fg("warning", "killed");
267
- break;
268
- case "exited":
269
- status = process.success
270
- ? theme.fg("success", "exit(0)")
271
- : theme.fg("error", `exit(${process.exitCode ?? "?"})`);
272
- break;
273
- default:
274
- status = theme.fg("muted", process.status);
275
- }
276
-
277
- lines.push(
278
- ` ${process.id} ${theme.fg("accent", `"${process.name}"`)}: ${truncateCmd(process.command)} [${status}] ${formatRuntime(process.startTime, process.endTime)}`,
279
- );
280
- }
281
-
282
- fields.push(new Text(lines.join("\n"), 0, 0));
172
+ const { details } = result;
283
173
 
284
- // Collapsed summary: first 3 processes
285
- const summary = details.processes
286
- .slice(0, 3)
287
- .map((p) => {
288
- const s =
289
- p.status === "running"
290
- ? theme.fg("accent", "running")
291
- : p.status === "exited" && p.success
292
- ? theme.fg("success", "exit(0)")
293
- : p.status === "exited"
294
- ? theme.fg("error", `exit(${p.exitCode ?? "?"})`)
295
- : theme.fg("muted", p.status);
296
- return `${theme.fg("accent", `"${p.name}"`)} [${s}]`;
297
- })
298
- .join(", ");
299
- const more =
300
- details.processes.length > 3
301
- ? theme.fg("muted", ` +${details.processes.length - 3} more`)
302
- : "";
303
- fields.push({
304
- label: "Processes",
305
- value: summary + more,
306
- showCollapsed: true,
307
- });
308
- } else if (details.action === "logs" && details.logFiles) {
309
- fields.push(
310
- new Text(
311
- [
312
- theme.fg("success", "Log files:"),
313
- ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
314
- ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
315
- ].join("\n"),
316
- 0,
317
- 0,
318
- ),
319
- );
320
- } else {
321
- fields.push({
322
- label: "Result",
323
- value: details.message,
324
- showCollapsed: true,
325
- });
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);
326
182
  }
327
183
 
328
- const footerItems: Array<{
329
- label: string;
330
- value: string;
331
- tone: "accent" | "success" | "error" | "warning" | "muted";
332
- }> = [];
333
184
  if (!details.success) {
334
- footerItems.push({
335
- label: "status",
336
- value: "error",
337
- tone: "error",
338
- });
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
+ );
339
198
  }
340
- const footer =
341
- footerItems.length > 0
342
- ? new ToolFooter(theme, { items: footerItems })
343
- : undefined;
344
199
 
345
- return new ToolBody({ fields, footer }, options, theme);
200
+ return renderActionResult(result, options, theme);
346
201
  },
347
202
  });
348
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";