@aliou/pi-processes 0.4.5 → 0.4.6

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.
@@ -0,0 +1,76 @@
1
+ import type { ExecuteResult } from "../../constants";
2
+ import type { ProcessManager } from "../../manager";
3
+
4
+ interface KillParams {
5
+ id?: string;
6
+ }
7
+
8
+ export async function executeKill(
9
+ params: KillParams,
10
+ manager: ProcessManager,
11
+ ): Promise<ExecuteResult> {
12
+ if (!params.id) {
13
+ return {
14
+ content: [{ type: "text", text: "Missing required parameter: id" }],
15
+ details: {
16
+ action: "kill",
17
+ success: false,
18
+ message: "Missing required parameter: id",
19
+ },
20
+ };
21
+ }
22
+
23
+ const proc = manager.find(params.id);
24
+ if (!proc) {
25
+ const message = `Process not found: ${params.id}`;
26
+ return {
27
+ content: [{ type: "text", text: message }],
28
+ details: {
29
+ action: "kill",
30
+ success: false,
31
+ message,
32
+ },
33
+ };
34
+ }
35
+
36
+ const result = await manager.kill(proc.id, {
37
+ signal: "SIGTERM",
38
+ timeoutMs: 3000,
39
+ });
40
+
41
+ if (result.ok) {
42
+ const message = `Terminated "${proc.name}" (${proc.id})`;
43
+ return {
44
+ content: [{ type: "text", text: message }],
45
+ details: {
46
+ action: "kill",
47
+ success: true,
48
+ message,
49
+ },
50
+ };
51
+ }
52
+
53
+ if (result.reason === "timeout") {
54
+ const message =
55
+ `SIGTERM timed out for "${proc.name}" (${proc.id}). ` +
56
+ "Run /process:list and press x on terminate_timeout to force kill (SIGKILL).";
57
+ return {
58
+ content: [{ type: "text", text: message }],
59
+ details: {
60
+ action: "kill",
61
+ success: false,
62
+ message,
63
+ },
64
+ };
65
+ }
66
+
67
+ const message = `Failed to terminate "${proc.name}" (${proc.id})`;
68
+ return {
69
+ content: [{ type: "text", text: message }],
70
+ details: {
71
+ action: "kill",
72
+ success: false,
73
+ message,
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,37 @@
1
+ import type { ExecuteResult } from "../../constants";
2
+ import type { ProcessManager } from "../../manager";
3
+ import { formatRuntime, formatStatus, truncateCmd } from "../../utils";
4
+
5
+ export function executeList(manager: ProcessManager): ExecuteResult {
6
+ const processes = manager.list();
7
+
8
+ if (processes.length === 0) {
9
+ return {
10
+ content: [{ type: "text", text: "No background processes running" }],
11
+ details: {
12
+ action: "list",
13
+ success: true,
14
+ message: "No background processes running",
15
+ processes: [],
16
+ },
17
+ };
18
+ }
19
+
20
+ const summary = processes
21
+ .map(
22
+ (p) =>
23
+ `${p.id} "${p.name}": ${truncateCmd(p.command)} [${formatStatus(p)}] ${formatRuntime(p.startTime, p.endTime)}`,
24
+ )
25
+ .join("\n");
26
+
27
+ const message = `${processes.length} process(es):\n${summary}`;
28
+ return {
29
+ content: [{ type: "text", text: message }],
30
+ details: {
31
+ action: "list",
32
+ success: true,
33
+ message,
34
+ processes,
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,59 @@
1
+ import type { ExecuteResult } from "../../constants";
2
+ import type { ProcessManager } from "../../manager";
3
+
4
+ interface LogsParams {
5
+ id?: string;
6
+ }
7
+
8
+ export function executeLogs(
9
+ params: LogsParams,
10
+ manager: ProcessManager,
11
+ ): ExecuteResult {
12
+ if (!params.id) {
13
+ return {
14
+ content: [{ type: "text", text: "Missing required parameter: id" }],
15
+ details: {
16
+ action: "logs",
17
+ success: false,
18
+ message: "Missing required parameter: id",
19
+ },
20
+ };
21
+ }
22
+
23
+ const proc = manager.find(params.id);
24
+ if (!proc) {
25
+ const message = `Process not found: ${params.id}`;
26
+ return {
27
+ content: [{ type: "text", text: message }],
28
+ details: {
29
+ action: "logs",
30
+ success: false,
31
+ message,
32
+ },
33
+ };
34
+ }
35
+
36
+ const logFiles = manager.getLogFiles(proc.id);
37
+ if (!logFiles) {
38
+ const message = `Could not get log files for: ${proc.id}`;
39
+ return {
40
+ content: [{ type: "text", text: message }],
41
+ details: {
42
+ action: "logs",
43
+ success: false,
44
+ message,
45
+ },
46
+ };
47
+ }
48
+
49
+ 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.`;
50
+ return {
51
+ content: [{ type: "text", text: message }],
52
+ details: {
53
+ action: "logs",
54
+ success: true,
55
+ message,
56
+ logFiles,
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,144 @@
1
+ import { configLoader } from "../../config";
2
+ import type { ExecuteResult } from "../../constants";
3
+ import type { ProcessManager } from "../../manager";
4
+ import { formatStatus, stripAnsi } from "../../utils";
5
+
6
+ const MAX_BYTES = 50 * 1024; // 50KB
7
+
8
+ interface OutputParams {
9
+ id?: string;
10
+ }
11
+
12
+ export function executeOutput(
13
+ params: OutputParams,
14
+ manager: ProcessManager,
15
+ ): ExecuteResult {
16
+ if (!params.id) {
17
+ return {
18
+ content: [{ type: "text", text: "Missing required parameter: id" }],
19
+ details: {
20
+ action: "output",
21
+ success: false,
22
+ message: "Missing required parameter: id",
23
+ },
24
+ };
25
+ }
26
+
27
+ const proc = manager.find(params.id);
28
+ if (!proc) {
29
+ const message = `Process not found: ${params.id}`;
30
+ return {
31
+ content: [{ type: "text", text: message }],
32
+ details: {
33
+ action: "output",
34
+ success: false,
35
+ message,
36
+ },
37
+ };
38
+ }
39
+
40
+ const { defaultTailLines } = configLoader.getConfig().output;
41
+ const output = manager.getOutput(proc.id, defaultTailLines);
42
+ if (!output) {
43
+ const message = `Could not read output for: ${proc.id}`;
44
+ return {
45
+ content: [{ type: "text", text: message }],
46
+ details: {
47
+ action: "output",
48
+ success: false,
49
+ message,
50
+ },
51
+ };
52
+ }
53
+
54
+ const logFiles = manager.getLogFiles(proc.id);
55
+ const stdoutLines = output.stdout.length;
56
+ const stderrLines = output.stderr.length;
57
+ const message = `"${proc.name}" (${proc.id}) [${formatStatus(proc)}]: ${stdoutLines} stdout lines, ${stderrLines} stderr lines`;
58
+
59
+ // Build the full text content (ANSI-stripped), then truncate from the tail
60
+ // like bash does, so the agent sees the most recent output.
61
+ const outputParts: string[] = [message];
62
+ if (output.stdout.length > 0) {
63
+ outputParts.push("\nstdout:");
64
+ outputParts.push(...output.stdout.map(stripAnsi));
65
+ }
66
+ if (output.stderr.length > 0) {
67
+ outputParts.push("\nstderr:");
68
+ outputParts.push(...output.stderr.map(stripAnsi));
69
+ }
70
+
71
+ const fullText = outputParts.join("\n");
72
+ const { maxOutputLines } = configLoader.getConfig().output;
73
+ const contentText = truncateTail(fullText, logFiles, maxOutputLines);
74
+
75
+ return {
76
+ content: [{ type: "text", text: contentText }],
77
+ details: {
78
+ action: "output",
79
+ success: true,
80
+ message,
81
+ output,
82
+ },
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Truncate text from the tail (keep last N lines / MAX_BYTES), matching
88
+ * the behaviour of pi's built-in bash tool. When truncated, appends a
89
+ * notice pointing the agent to the full log files.
90
+ */
91
+ function truncateTail(
92
+ text: string,
93
+ logFiles: { stdoutFile: string; stderrFile: string } | null,
94
+ maxLines: number,
95
+ ): string {
96
+ const totalBytes = Buffer.byteLength(text, "utf-8");
97
+ const lines = text.split("\n");
98
+ const totalLines = lines.length;
99
+
100
+ if (totalLines <= maxLines && totalBytes <= MAX_BYTES) {
101
+ return text;
102
+ }
103
+
104
+ // Work backwards, collecting lines that fit
105
+ const kept: string[] = [];
106
+ let keptBytes = 0;
107
+ let hitBytes = false;
108
+
109
+ for (let i = lines.length - 1; i >= 0 && kept.length < maxLines; i--) {
110
+ const line = lines[i] ?? "";
111
+ const lineBytes =
112
+ Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0);
113
+
114
+ if (keptBytes + lineBytes > MAX_BYTES) {
115
+ hitBytes = true;
116
+ break;
117
+ }
118
+
119
+ kept.unshift(line);
120
+ keptBytes += lineBytes;
121
+ }
122
+
123
+ let result = kept.join("\n");
124
+
125
+ // Append a notice so the agent knows output was truncated
126
+ const shownLines = kept.length;
127
+ const startLine = totalLines - shownLines + 1;
128
+ const sizeNote = hitBytes ? ` (${formatSize(MAX_BYTES)} limit)` : "";
129
+ result += `\n\n[Showing lines ${startLine}-${totalLines} of ${totalLines}${sizeNote}.`;
130
+
131
+ if (logFiles) {
132
+ result += ` Full logs: ${logFiles.stdoutFile} , ${logFiles.stderrFile}`;
133
+ }
134
+
135
+ result += "]";
136
+
137
+ return result;
138
+ }
139
+
140
+ function formatSize(bytes: number): string {
141
+ if (bytes < 1024) return `${bytes}B`;
142
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
143
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
144
+ }
@@ -0,0 +1,55 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { ExecuteResult } from "../../constants";
3
+ import type { ProcessManager } from "../../manager";
4
+
5
+ interface StartParams {
6
+ name?: string;
7
+ command?: string;
8
+ alertOnSuccess?: boolean;
9
+ alertOnFailure?: boolean;
10
+ alertOnKill?: boolean;
11
+ }
12
+
13
+ export function executeStart(
14
+ params: StartParams,
15
+ manager: ProcessManager,
16
+ ctx: ExtensionContext,
17
+ ): ExecuteResult {
18
+ if (!params.name) {
19
+ return {
20
+ content: [{ type: "text", text: "Missing required parameter: name" }],
21
+ details: {
22
+ action: "start",
23
+ success: false,
24
+ message: "Missing required parameter: name",
25
+ },
26
+ };
27
+ }
28
+ if (!params.command) {
29
+ return {
30
+ content: [{ type: "text", text: "Missing required parameter: command" }],
31
+ details: {
32
+ action: "start",
33
+ success: false,
34
+ message: "Missing required parameter: command",
35
+ },
36
+ };
37
+ }
38
+
39
+ const proc = manager.start(params.name, params.command, ctx.cwd, {
40
+ alertOnSuccess: params.alertOnSuccess,
41
+ alertOnFailure: params.alertOnFailure,
42
+ alertOnKill: params.alertOnKill,
43
+ });
44
+
45
+ const message = `Started "${proc.name}" (${proc.id}, PID: ${proc.pid})\nLogs: ${proc.stdoutFile}`;
46
+ return {
47
+ content: [{ type: "text", text: message }],
48
+ details: {
49
+ action: "start",
50
+ success: true,
51
+ message,
52
+ process: proc,
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,280 @@
1
+ import { ToolBody, ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
2
+ import { StringEnum } from "@mariozechner/pi-ai";
3
+ import type {
4
+ AgentToolResult,
5
+ ExtensionAPI,
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 { ProcessesDetails } from "../constants";
12
+ import type { ProcessManager } from "../manager";
13
+ import { formatRuntime, hasAnsi, stripAnsi, truncateCmd } from "../utils";
14
+ import { executeAction } from "./actions";
15
+
16
+ const ProcessesParams = Type.Object({
17
+ action: StringEnum(
18
+ ["start", "list", "output", "logs", "kill", "clear"] 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)",
22
+ },
23
+ ),
24
+ command: Type.Optional(
25
+ Type.String({ description: "Command to run (required for start)" }),
26
+ ),
27
+ name: Type.Optional(
28
+ Type.String({
29
+ description:
30
+ "Friendly name for the process (required for start, e.g. 'backend-dev', 'test-runner')",
31
+ }),
32
+ ),
33
+ id: Type.Optional(
34
+ Type.String({
35
+ description:
36
+ "Process ID or name to match (required for output/kill/logs). Can be proc_N or friendly name.",
37
+ }),
38
+ ),
39
+ alertOnSuccess: Type.Optional(
40
+ Type.Boolean({
41
+ description:
42
+ "Get a turn to react when process completes successfully (default: false). Use for builds/tests where you need confirmation.",
43
+ }),
44
+ ),
45
+ alertOnFailure: Type.Optional(
46
+ Type.Boolean({
47
+ description:
48
+ "Get a turn to react when process fails/crashes (default: true). Use to be alerted of unexpected failures.",
49
+ }),
50
+ ),
51
+ alertOnKill: Type.Optional(
52
+ Type.Boolean({
53
+ description:
54
+ "Get a turn to react when process is killed by external signal (default: false). Note: killing via tool never triggers a turn.",
55
+ }),
56
+ ),
57
+ });
58
+
59
+ type ProcessesParamsType = Static<typeof ProcessesParams>;
60
+
61
+ export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
62
+ pi.registerTool<typeof ProcessesParams, ProcessesDetails>({
63
+ name: "process",
64
+ label: "Process",
65
+ description: `Manage background processes. Actions:
66
+ - start: Run command in background (requires 'name' and 'command')
67
+ - alertOnSuccess (default: false): Get a turn to react when process completes successfully
68
+ - alertOnFailure (default: true): Get a turn to react when process crashes/fails
69
+ - alertOnKill (default: false): Get a turn to react if killed by external signal (killing via tool never triggers a turn)
70
+ - list: Show all managed processes with their IDs and names
71
+ - output: Get recent stdout/stderr (requires 'id' - can be proc_N or name match)
72
+ - logs: Get log file paths to inspect with read tool (requires 'id')
73
+ - kill: Terminate a process (requires 'id' - can be proc_N or name match like "backend")
74
+ - clear: Remove all finished processes from the list
75
+
76
+ 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.
77
+
78
+ 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).`,
79
+
80
+ parameters: ProcessesParams,
81
+
82
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
83
+ return executeAction(params, manager, ctx);
84
+ },
85
+
86
+ renderCall(args: ProcessesParamsType, theme: Theme) {
87
+ const longArgs: Array<{ label?: string; value: string }> = [];
88
+ const optionArgs: Array<{ label: string; value: string }> = [];
89
+ let mainArg: string | undefined;
90
+
91
+ if (args.action === "start") {
92
+ if (args.name) {
93
+ mainArg = `"${args.name}"`;
94
+ }
95
+
96
+ if (args.command) {
97
+ if (!mainArg && args.command.length <= 60) {
98
+ mainArg = args.command;
99
+ } else if (args.command.length <= 60) {
100
+ optionArgs.push({ label: "command", value: args.command });
101
+ } else {
102
+ longArgs.push({ label: "command", value: args.command });
103
+ }
104
+ }
105
+ }
106
+
107
+ if (
108
+ (args.action === "output" ||
109
+ args.action === "kill" ||
110
+ args.action === "logs") &&
111
+ args.id
112
+ ) {
113
+ mainArg = args.id;
114
+ }
115
+
116
+ return new ToolCallHeader(
117
+ {
118
+ toolName: "Process",
119
+ action: args.action,
120
+ mainArg,
121
+ optionArgs,
122
+ longArgs,
123
+ },
124
+ theme,
125
+ );
126
+ },
127
+
128
+ renderResult(
129
+ result: AgentToolResult<ProcessesDetails>,
130
+ options: ToolRenderResultOptions,
131
+ theme: Theme,
132
+ ) {
133
+ const { details } = result;
134
+
135
+ if (!details) {
136
+ const text = result.content[0];
137
+ return new Text(
138
+ text?.type === "text" && text.text ? text.text : "No result",
139
+ 0,
140
+ 0,
141
+ );
142
+ }
143
+
144
+ const fields: Array<
145
+ { label: string; value: string; showCollapsed?: boolean } | Text
146
+ > = [];
147
+
148
+ if (!details.success) {
149
+ fields.push({
150
+ label: "Error",
151
+ value: theme.fg("error", details.message),
152
+ showCollapsed: true,
153
+ });
154
+ } else if (details.action === "start" && details.process) {
155
+ const process = details.process;
156
+ fields.push({
157
+ label: "Status",
158
+ value:
159
+ theme.fg("success", "Started") +
160
+ ` ${theme.fg("accent", `"${process.name}"`)} (${process.id}, PID: ${process.pid})`,
161
+ showCollapsed: true,
162
+ });
163
+ } else if (details.action === "output" && details.output) {
164
+ const lines: string[] = [theme.fg("muted", details.message)];
165
+ let hadAnsi = false;
166
+
167
+ if (details.output.stdout.length > 0) {
168
+ lines.push("", theme.fg("accent", "stdout:"));
169
+ for (const line of details.output.stdout.slice(-20)) {
170
+ if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
171
+ lines.push(stripAnsi(line));
172
+ }
173
+ if (details.output.stdout.length > 20) {
174
+ lines.push(
175
+ theme.fg(
176
+ "muted",
177
+ `... (${details.output.stdout.length - 20} more lines)`,
178
+ ),
179
+ );
180
+ }
181
+ }
182
+
183
+ if (details.output.stderr.length > 0) {
184
+ lines.push("", theme.fg("warning", "stderr:"));
185
+ for (const line of details.output.stderr.slice(-10)) {
186
+ if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
187
+ lines.push(theme.fg("warning", stripAnsi(line)));
188
+ }
189
+ if (details.output.stderr.length > 10) {
190
+ lines.push(
191
+ theme.fg(
192
+ "muted",
193
+ `... (${details.output.stderr.length - 10} more lines)`,
194
+ ),
195
+ );
196
+ }
197
+ }
198
+
199
+ if (hadAnsi) {
200
+ lines.push(
201
+ "",
202
+ theme.fg("muted", "ANSI escape codes were stripped from output"),
203
+ );
204
+ }
205
+
206
+ fields.push(new Text(lines.join("\n"), 0, 0));
207
+ } else if (
208
+ details.action === "list" &&
209
+ details.processes &&
210
+ details.processes.length > 0
211
+ ) {
212
+ const lines: string[] = [
213
+ theme.fg("success", `${details.processes.length} process(es):`),
214
+ ];
215
+
216
+ for (const process of details.processes) {
217
+ let status: string;
218
+ switch (process.status) {
219
+ case "running":
220
+ status = theme.fg("accent", "running");
221
+ break;
222
+ case "terminating":
223
+ status = theme.fg("warning", "terminating");
224
+ break;
225
+ case "terminate_timeout":
226
+ status = theme.fg("error", "terminate_timeout");
227
+ break;
228
+ case "killed":
229
+ status = theme.fg("warning", "killed");
230
+ break;
231
+ case "exited":
232
+ status = process.success
233
+ ? theme.fg("success", "exit(0)")
234
+ : theme.fg("error", `exit(${process.exitCode ?? "?"})`);
235
+ break;
236
+ default:
237
+ status = theme.fg("muted", process.status);
238
+ }
239
+
240
+ lines.push(
241
+ ` ${process.id} ${theme.fg("accent", `"${process.name}"`)}: ${truncateCmd(process.command)} [${status}] ${formatRuntime(process.startTime, process.endTime)}`,
242
+ );
243
+ }
244
+
245
+ fields.push(new Text(lines.join("\n"), 0, 0));
246
+ } else if (details.action === "logs" && details.logFiles) {
247
+ fields.push(
248
+ new Text(
249
+ [
250
+ theme.fg("success", "Log files:"),
251
+ ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
252
+ ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
253
+ ].join("\n"),
254
+ 0,
255
+ 0,
256
+ ),
257
+ );
258
+ } else {
259
+ fields.push({
260
+ label: "Result",
261
+ value: details.message,
262
+ showCollapsed: true,
263
+ });
264
+ }
265
+
266
+ const footer = new ToolFooter(theme, {
267
+ items: [
268
+ { label: "action", value: details.action, tone: "accent" },
269
+ {
270
+ label: "status",
271
+ value: details.success ? "ok" : "error",
272
+ tone: details.success ? "success" : "error",
273
+ },
274
+ ],
275
+ });
276
+
277
+ return new ToolBody({ fields, footer }, options, theme);
278
+ },
279
+ });
280
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Strip ANSI escape codes from a string.
3
+ *
4
+ * Removes:
5
+ * - All CSI sequences (\x1b[...X) - SGR, cursor movement, erase, scroll, etc.
6
+ * - OSC 8 hyperlinks (\x1b]8;;URL\x07)
7
+ * - APC sequences (\x1b_...\x07 or \x1b_...\x1b\\)
8
+ */
9
+ /**
10
+ * Check if a string contains ANSI escape codes.
11
+ */
12
+ export function hasAnsi(str: string): boolean {
13
+ return str.includes(String.fromCodePoint(0x001b));
14
+ }
15
+
16
+ export function stripAnsi(str: string): string {
17
+ // ESC = \u001b, BEL = \u0007
18
+ const ESC = String.fromCodePoint(0x001b);
19
+ const BEL = String.fromCodePoint(0x0007);
20
+
21
+ if (!str.includes(ESC)) {
22
+ return str;
23
+ }
24
+
25
+ // Strip all CSI sequences (ESC[...X where X is any letter)
26
+ let clean = str.replace(new RegExp(`${ESC}\\[[0-9;]*[A-Za-z]`, "gu"), "");
27
+ // Strip OSC 8 hyperlinks: ESC]8;;URL<BEL> and ESC]8;;<BEL>
28
+ clean = clean.replace(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, "gu"), "");
29
+ // Strip APC sequences: ESC_...<BEL> or ESC_...<ESC>\\ (used for cursor marker)
30
+ clean = clean.replace(
31
+ new RegExp(`${ESC}_[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, "gu"),
32
+ "",
33
+ );
34
+
35
+ return clean;
36
+ }