@aliou/pi-processes 0.3.3 → 0.4.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/config.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Configuration for the processes extension.
3
+ *
4
+ * Global: ~/.pi/agent/extensions/processes.json
5
+ * Memory: ephemeral overrides via /process:settings
6
+ */
7
+
8
+ import { ConfigLoader } from "@aliou/pi-utils-settings";
9
+
10
+ export interface ProcessesConfig {
11
+ processList?: {
12
+ /** Max visible processes in the /process:list TUI list. */
13
+ maxVisibleProcesses?: number;
14
+ /** Max log preview lines shown below the selected process. */
15
+ maxPreviewLines?: number;
16
+ };
17
+ output?: {
18
+ /** Default number of tail lines returned to the agent. */
19
+ defaultTailLines?: number;
20
+ /** Hard cap on output lines returned to the agent. */
21
+ maxOutputLines?: number;
22
+ };
23
+ widget?: {
24
+ /** Show the status widget below the editor. */
25
+ showStatusWidget?: boolean;
26
+ };
27
+ }
28
+
29
+ export interface ResolvedProcessesConfig {
30
+ processList: {
31
+ maxVisibleProcesses: number;
32
+ maxPreviewLines: number;
33
+ };
34
+ output: {
35
+ defaultTailLines: number;
36
+ maxOutputLines: number;
37
+ };
38
+ widget: {
39
+ showStatusWidget: boolean;
40
+ };
41
+ }
42
+
43
+ const DEFAULT_CONFIG: ResolvedProcessesConfig = {
44
+ processList: {
45
+ maxVisibleProcesses: 8,
46
+ maxPreviewLines: 12,
47
+ },
48
+ output: {
49
+ defaultTailLines: 100,
50
+ maxOutputLines: 200,
51
+ },
52
+ widget: {
53
+ showStatusWidget: true,
54
+ },
55
+ };
56
+
57
+ export const configLoader = new ConfigLoader<
58
+ ProcessesConfig,
59
+ ResolvedProcessesConfig
60
+ >("process", DEFAULT_CONFIG, {
61
+ scopes: ["global", "memory"],
62
+ });
@@ -0,0 +1,11 @@
1
+ export type {
2
+ ExecuteResult,
3
+ KillResult,
4
+ ManagerEvent,
5
+ ProcessesDetails,
6
+ ProcessInfo,
7
+ ProcessStatus,
8
+ StartOptions,
9
+ } from "./types";
10
+
11
+ export { LIVE_STATUSES, MESSAGE_TYPE_PROCESS_UPDATE } from "./types";
@@ -0,0 +1,65 @@
1
+ // Custom message type for process update notifications
2
+ export const MESSAGE_TYPE_PROCESS_UPDATE = "ad-process:update";
3
+
4
+ export type ProcessStatus =
5
+ | "running"
6
+ | "terminating"
7
+ | "terminate_timeout"
8
+ | "exited"
9
+ | "killed";
10
+
11
+ export const LIVE_STATUSES: ReadonlySet<ProcessStatus> = new Set([
12
+ "running",
13
+ "terminating",
14
+ "terminate_timeout",
15
+ ]);
16
+
17
+ export interface ProcessInfo {
18
+ id: string;
19
+ name: string;
20
+ pid: number; // On Unix, this is also the PGID (process group leader)
21
+ command: string;
22
+ cwd: string;
23
+ startTime: number;
24
+ endTime: number | null;
25
+ status: ProcessStatus;
26
+ exitCode: number | null;
27
+ success: boolean | null; // null if running, true if exit code 0, false otherwise
28
+ stdoutFile: string;
29
+ stderrFile: string;
30
+ alertOnSuccess: boolean;
31
+ alertOnFailure: boolean;
32
+ alertOnKill: boolean;
33
+ }
34
+
35
+ export type ManagerEvent =
36
+ | { type: "process_started"; info: ProcessInfo }
37
+ | { type: "process_status_changed"; info: ProcessInfo; prev: ProcessStatus }
38
+ | { type: "process_ended"; info: ProcessInfo }
39
+ | { type: "processes_changed" };
40
+
41
+ export type KillResult =
42
+ | { ok: true; info: ProcessInfo }
43
+ | { ok: false; info: ProcessInfo; reason: "not_found" | "timeout" | "error" };
44
+
45
+ export interface StartOptions {
46
+ alertOnSuccess?: boolean;
47
+ alertOnFailure?: boolean;
48
+ alertOnKill?: boolean;
49
+ }
50
+
51
+ export interface ProcessesDetails {
52
+ action: string;
53
+ success: boolean;
54
+ message: string;
55
+ process?: ProcessInfo;
56
+ processes?: ProcessInfo[];
57
+ output?: { stdout: string[]; stderr: string[]; status: string };
58
+ logFiles?: { stdoutFile: string; stderrFile: string };
59
+ cleared?: number;
60
+ }
61
+
62
+ export interface ExecuteResult {
63
+ content: Array<{ type: "text"; text: string }>;
64
+ details: ProcessesDetails;
65
+ }
package/hooks/index.ts CHANGED
@@ -10,7 +10,9 @@ export function setupProcessesHooks(pi: ExtensionAPI, manager: ProcessManager) {
10
10
  setupProcessEndHook(pi, manager);
11
11
 
12
12
  // Set up widget AFTER process-end so it chains onto the existing callback
13
- setupProcessWidget(pi, manager);
13
+ const widget = setupProcessWidget(pi, manager);
14
14
 
15
15
  setupMessageRenderer(pi);
16
+
17
+ return widget;
16
18
  }
package/hooks/widget.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ExtensionContext,
4
4
  } from "@mariozechner/pi-coding-agent";
5
5
  import { visibleWidth } from "@mariozechner/pi-tui";
6
+ import { configLoader } from "../config";
6
7
  import type { ProcessInfo } from "../constants";
7
8
  import type { ProcessManager } from "../manager";
8
9
 
@@ -106,6 +107,11 @@ export function setupProcessWidget(pi: ExtensionAPI, manager: ProcessManager) {
106
107
  function updateWidget() {
107
108
  if (!latestContext?.hasUI) return;
108
109
 
110
+ if (!configLoader.getConfig().widget.showStatusWidget) {
111
+ latestContext.ui.setWidget(WIDGET_ID, undefined);
112
+ return;
113
+ }
114
+
109
115
  const processes = manager.list();
110
116
  const maxWidth = process.stdout.columns || 120;
111
117
  const lines = renderWidget(processes, latestContext.ui.theme, maxWidth);
package/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { setupProcessesCommands } from "./commands";
3
+ import { registerProcessesSettings } from "./commands/settings-command";
4
+ import { configLoader } from "./config";
3
5
  import { setupProcessesHooks } from "./hooks";
4
6
  import { ProcessManager } from "./manager";
5
7
  import { setupProcessesTools } from "./tools";
6
8
 
7
- export default function (pi: ExtensionAPI) {
9
+ export default async function (pi: ExtensionAPI) {
8
10
  if (process.platform === "win32") {
9
11
  pi.on("session_start", async (_event, ctx) => {
10
12
  if (!ctx.hasUI) return;
@@ -13,9 +15,13 @@ export default function (pi: ExtensionAPI) {
13
15
  return;
14
16
  }
15
17
 
18
+ await configLoader.load();
16
19
  const manager = new ProcessManager();
17
20
 
18
- setupProcessesHooks(pi, manager);
19
- setupProcessesTools(pi, manager);
20
- setupProcessesCommands(pi, manager);
21
+ const { update: updateWidget } = setupProcessesHooks(pi, manager);
22
+ const commands = setupProcessesCommands(pi, manager);
23
+ setupProcessesTools(pi, manager, commands);
24
+ registerProcessesSettings(pi, () => {
25
+ updateWidget();
26
+ });
21
27
  }
package/manager.ts CHANGED
@@ -23,6 +23,7 @@ import { isProcessGroupAlive, killProcessGroup } from "./utils";
23
23
  interface ManagedProcess extends ProcessInfo {
24
24
  process: ChildProcess;
25
25
  lastSignalSent: NodeJS.Signals | null;
26
+ combinedFile: string;
26
27
  }
27
28
 
28
29
  export class ProcessManager {
@@ -122,9 +123,11 @@ export class ProcessManager {
122
123
  const id = `proc_${++this.counter}`;
123
124
  const stdoutFile = join(this.logDir, `${id}-stdout.log`);
124
125
  const stderrFile = join(this.logDir, `${id}-stderr.log`);
126
+ const combinedFile = join(this.logDir, `${id}-combined.log`);
125
127
 
126
128
  appendFileSync(stdoutFile, "");
127
129
  appendFileSync(stderrFile, "");
130
+ appendFileSync(combinedFile, "");
128
131
 
129
132
  const child = spawn("/bin/bash", ["-lc", command], {
130
133
  cwd,
@@ -148,6 +151,7 @@ export class ProcessManager {
148
151
  success: null,
149
152
  stdoutFile,
150
153
  stderrFile,
154
+ combinedFile,
151
155
  alertOnSuccess: options?.alertOnSuccess ?? false,
152
156
  alertOnFailure: options?.alertOnFailure ?? true,
153
157
  alertOnKill: options?.alertOnKill ?? false,
@@ -173,6 +177,15 @@ export class ProcessManager {
173
177
  child.stdout?.on("data", (data: Buffer) => {
174
178
  try {
175
179
  appendFileSync(stdoutFile, data);
180
+ const lines = data.toString().split("\n");
181
+ // The last element after split is either empty (if data ended with \n)
182
+ // or a partial line. We write all parts with the prefix and newline.
183
+ const tagged = lines
184
+ .map((line, i) =>
185
+ i < lines.length - 1 ? `1:${line}\n` : line ? `1:${line}\n` : "",
186
+ )
187
+ .join("");
188
+ if (tagged) appendFileSync(combinedFile, tagged);
176
189
  } catch {
177
190
  // Ignore
178
191
  }
@@ -181,6 +194,13 @@ export class ProcessManager {
181
194
  child.stderr?.on("data", (data: Buffer) => {
182
195
  try {
183
196
  appendFileSync(stderrFile, data);
197
+ const lines = data.toString().split("\n");
198
+ const tagged = lines
199
+ .map((line, i) =>
200
+ i < lines.length - 1 ? `2:${line}\n` : line ? `2:${line}\n` : "",
201
+ )
202
+ .join("");
203
+ if (tagged) appendFileSync(combinedFile, tagged);
184
204
  } catch {
185
205
  // Ignore
186
206
  }
@@ -222,9 +242,9 @@ export class ProcessManager {
222
242
  }
223
243
 
224
244
  list(): ProcessInfo[] {
225
- return Array.from(this.processes.values()).map((p) =>
226
- this.toProcessInfo(p),
227
- );
245
+ return Array.from(this.processes.values())
246
+ .map((p) => this.toProcessInfo(p))
247
+ .reverse();
228
248
  }
229
249
 
230
250
  get(id: string): ProcessInfo | null {
@@ -262,6 +282,26 @@ export class ProcessManager {
262
282
  };
263
283
  }
264
284
 
285
+ getCombinedOutput(
286
+ id: string,
287
+ tailLines = 100,
288
+ ): { type: "stdout" | "stderr"; text: string }[] | null {
289
+ const managed = this.processes.get(id);
290
+ if (!managed) return null;
291
+
292
+ const rawLines = this.readTailLines(managed.combinedFile, tailLines);
293
+ return rawLines.map((line) => {
294
+ if (line.startsWith("2:")) {
295
+ return { type: "stderr", text: line.slice(2) };
296
+ }
297
+ // Default to stdout (handles "1:" prefix and any malformed lines).
298
+ return {
299
+ type: "stdout",
300
+ text: line.startsWith("1:") ? line.slice(2) : line,
301
+ };
302
+ });
303
+ }
304
+
265
305
  getFullOutput(id: string): { stdout: string; stderr: string } | null {
266
306
  const managed = this.processes.get(id);
267
307
  if (!managed) return null;
@@ -374,6 +414,7 @@ export class ProcessManager {
374
414
  try {
375
415
  rmSync(managed.stdoutFile, { force: true });
376
416
  rmSync(managed.stderrFile, { force: true });
417
+ rmSync(managed.combinedFile, { force: true });
377
418
  } catch {
378
419
  // Ignore
379
420
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": [
@@ -25,16 +25,18 @@
25
25
  "files": [
26
26
  "*.ts",
27
27
  "commands",
28
+ "components",
29
+ "constants",
28
30
  "hooks",
29
31
  "tools",
32
+ "utils",
30
33
  "README.md"
31
34
  ],
32
35
  "dependencies": {
33
- "@sinclair/typebox": "^0.34.41"
36
+ "@sinclair/typebox": "^0.34.41",
37
+ "@aliou/pi-utils-settings": "0.2.1"
34
38
  },
35
39
  "peerDependencies": {
36
- "@mariozechner/pi-ai": "0.51.0",
37
- "@mariozechner/pi-coding-agent": "0.51.0",
38
- "@mariozechner/pi-tui": "0.51.0"
40
+ "@mariozechner/pi-coding-agent": ">=0.51.0"
39
41
  }
40
42
  }
@@ -53,7 +53,7 @@ export async function executeKill(
53
53
  if (result.reason === "timeout") {
54
54
  const message =
55
55
  `SIGTERM timed out for "${proc.name}" (${proc.id}). ` +
56
- "Run /processes and press x on terminate_timeout to force kill (SIGKILL).";
56
+ "Run /process:list and press x on terminate_timeout to force kill (SIGKILL).";
57
57
  return {
58
58
  content: [{ type: "text", text: message }],
59
59
  details: {
@@ -1,8 +1,8 @@
1
+ import { configLoader } from "../../config";
1
2
  import type { ExecuteResult } from "../../constants";
2
3
  import type { ProcessManager } from "../../manager";
3
4
  import { formatStatus, stripAnsi } from "../../utils";
4
5
 
5
- const MAX_LINES = 200;
6
6
  const MAX_BYTES = 50 * 1024; // 50KB
7
7
 
8
8
  interface OutputParams {
@@ -37,7 +37,8 @@ export function executeOutput(
37
37
  };
38
38
  }
39
39
 
40
- const output = manager.getOutput(proc.id);
40
+ const { defaultTailLines } = configLoader.getConfig().output;
41
+ const output = manager.getOutput(proc.id, defaultTailLines);
41
42
  if (!output) {
42
43
  const message = `Could not read output for: ${proc.id}`;
43
44
  return {
@@ -68,7 +69,8 @@ export function executeOutput(
68
69
  }
69
70
 
70
71
  const fullText = outputParts.join("\n");
71
- const contentText = truncateTail(fullText, logFiles);
72
+ const { maxOutputLines } = configLoader.getConfig().output;
73
+ const contentText = truncateTail(fullText, logFiles, maxOutputLines);
72
74
 
73
75
  return {
74
76
  content: [{ type: "text", text: contentText }],
@@ -89,12 +91,13 @@ export function executeOutput(
89
91
  function truncateTail(
90
92
  text: string,
91
93
  logFiles: { stdoutFile: string; stderrFile: string } | null,
94
+ maxLines: number,
92
95
  ): string {
93
96
  const totalBytes = Buffer.byteLength(text, "utf-8");
94
97
  const lines = text.split("\n");
95
98
  const totalLines = lines.length;
96
99
 
97
- if (totalLines <= MAX_LINES && totalBytes <= MAX_BYTES) {
100
+ if (totalLines <= maxLines && totalBytes <= MAX_BYTES) {
98
101
  return text;
99
102
  }
100
103
 
@@ -103,7 +106,7 @@ function truncateTail(
103
106
  let keptBytes = 0;
104
107
  let hitBytes = false;
105
108
 
106
- for (let i = lines.length - 1; i >= 0 && kept.length < MAX_LINES; i--) {
109
+ for (let i = lines.length - 1; i >= 0 && kept.length < maxLines; i--) {
107
110
  const line = lines[i] ?? "";
108
111
  const lineBytes =
109
112
  Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0);
package/tools/index.ts CHANGED
@@ -57,10 +57,16 @@ const ProcessesParams = Type.Object({
57
57
 
58
58
  type ProcessesParamsType = Static<typeof ProcessesParams>;
59
59
 
60
- export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
60
+ import type { ProcessCommands } from "../commands";
61
+
62
+ export function setupProcessesTools(
63
+ pi: ExtensionAPI,
64
+ manager: ProcessManager,
65
+ commands: ProcessCommands,
66
+ ) {
61
67
  pi.registerTool<typeof ProcessesParams, ProcessesDetails>({
62
- name: "processes",
63
- label: "Processes",
68
+ name: "process",
69
+ label: "Process",
64
70
  description: `Manage background processes. Actions:
65
71
  - start: Run command in background (requires 'name' and 'command')
66
72
  - alertOnSuccess (default: false): Get a turn to react when process completes successfully
@@ -79,22 +85,33 @@ Note: User always sees process updates in the UI. The notify flags control wheth
79
85
  parameters: ProcessesParams,
80
86
 
81
87
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
82
- return executeAction(params, manager, ctx);
88
+ const result = await executeAction(params, manager, ctx);
89
+ // Auto-stream logs when a process is started successfully.
90
+ if (
91
+ params.action === "start" &&
92
+ result.details?.success &&
93
+ result.details.process &&
94
+ ctx.hasUI
95
+ ) {
96
+ commands.streamProcess(result.details.process.id, ctx.ui);
97
+ }
98
+ return result;
83
99
  },
84
100
 
85
101
  renderCall(args: ProcessesParamsType, theme: Theme): Text {
86
- let text = theme.fg("toolTitle", theme.bold("processes "));
102
+ let text = theme.fg("toolTitle", theme.bold("Process "));
87
103
  text += theme.fg("accent", args.action);
88
104
 
89
105
  switch (args.action) {
90
- case "start":
106
+ case "start": {
91
107
  if (args.name) {
92
108
  text += ` ${theme.fg("accent", `"${args.name}"`)}`;
93
109
  }
94
110
  if (args.command) {
95
- text += ` ${theme.fg("muted", args.command.slice(0, 40))}`;
111
+ text += `\n${theme.fg("muted", `$ ${args.command}`)}`;
96
112
  }
97
- break;
113
+ return new Text(text, 0, 0);
114
+ }
98
115
  case "output":
99
116
  case "kill":
100
117
  case "logs":
package/utils/ansi.ts ADDED
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ import type { ProcessInfo } from "../constants";
2
+
3
+ export function formatRuntime(
4
+ startTime: number,
5
+ endTime: number | null,
6
+ ): string {
7
+ const end = endTime ?? Date.now();
8
+ const ms = end - startTime;
9
+ const seconds = Math.floor(ms / 1000);
10
+ const minutes = Math.floor(seconds / 60);
11
+ const hours = Math.floor(minutes / 60);
12
+
13
+ if (hours > 0) {
14
+ return `${hours}h ${minutes % 60}m`;
15
+ }
16
+ if (minutes > 0) {
17
+ return `${minutes}m ${seconds % 60}s`;
18
+ }
19
+ return `${seconds}s`;
20
+ }
21
+
22
+ export function formatStatus(proc: ProcessInfo): string {
23
+ switch (proc.status) {
24
+ case "running":
25
+ return "running";
26
+ case "terminating":
27
+ return "terminating";
28
+ case "terminate_timeout":
29
+ return "terminate_timeout";
30
+ case "killed":
31
+ return "killed";
32
+ case "exited":
33
+ return proc.success ? "exit(0)" : `exit(${proc.exitCode ?? "?"})`;
34
+ default:
35
+ return proc.status;
36
+ }
37
+ }
38
+
39
+ export function truncateCmd(cmd: string, max = 40): string {
40
+ if (cmd.length <= max) return cmd;
41
+ return `${cmd.slice(0, max - 3)}...`;
42
+ }
package/utils/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { hasAnsi, stripAnsi } from "./ansi";
2
+ export { formatRuntime, formatStatus, truncateCmd } from "./format";
3
+ export { isProcessGroupAlive, killProcessGroup } from "./process-group";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Check if a process group is still alive.
3
+ * Uses signal 0 to test existence without actually sending a signal.
4
+ */
5
+ export function isProcessGroupAlive(pgid: number): boolean {
6
+ try {
7
+ process.kill(-pgid, 0);
8
+ return true;
9
+ } catch (error) {
10
+ const err = error as NodeJS.ErrnoException;
11
+ // EPERM: exists, but we can't signal it
12
+ return err.code === "EPERM";
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Send a signal to an entire process group.
18
+ * Negative PID targets the process group.
19
+ */
20
+ export function killProcessGroup(pgid: number, signal: NodeJS.Signals): void {
21
+ process.kill(-pgid, signal);
22
+ }