@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,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
+ }
@@ -0,0 +1,10 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { ProcessManager } from "../manager";
3
+
4
+ export function setupCleanupHook(pi: ExtensionAPI, manager: ProcessManager) {
5
+ pi.on("session_shutdown", () => {
6
+ manager.stopWatcher();
7
+ manager.shutdownKillAll();
8
+ manager.cleanup();
9
+ });
10
+ }
@@ -0,0 +1,18 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { ProcessManager } from "../manager";
3
+ import { setupCleanupHook } from "./cleanup";
4
+ import { setupMessageRenderer } from "./message-renderer";
5
+ import { setupProcessEndHook } from "./process-end";
6
+ import { setupProcessWidget } from "./widget";
7
+
8
+ export function setupProcessesHooks(pi: ExtensionAPI, manager: ProcessManager) {
9
+ setupCleanupHook(pi, manager);
10
+ setupProcessEndHook(pi, manager);
11
+
12
+ // Set up widget AFTER process-end so it chains onto the existing callback
13
+ const widget = setupProcessWidget(pi, manager);
14
+
15
+ setupMessageRenderer(pi);
16
+
17
+ return widget;
18
+ }
@@ -0,0 +1,83 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ MessageRenderOptions,
4
+ Theme,
5
+ } from "@mariozechner/pi-coding-agent";
6
+ import { Text } from "@mariozechner/pi-tui";
7
+ import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants";
8
+
9
+ interface ProcessUpdateDetails {
10
+ processId: string;
11
+ processName: string;
12
+ command: string;
13
+ status: "exited" | "killed";
14
+ exitCode: number | null;
15
+ success: boolean;
16
+ runtime: string;
17
+ }
18
+
19
+ interface ProcessUpdateMessage {
20
+ customType: string;
21
+ content: string | Array<{ type: string; text?: string }>;
22
+ details?: ProcessUpdateDetails;
23
+ }
24
+
25
+ function getContentText(
26
+ content: string | Array<{ type: string; text?: string }>,
27
+ ): string {
28
+ if (typeof content === "string") {
29
+ return content;
30
+ }
31
+ return content
32
+ .filter((c) => c.type === "text" && c.text)
33
+ .map((c) => c.text as string)
34
+ .join("");
35
+ }
36
+
37
+ export function setupMessageRenderer(pi: ExtensionAPI) {
38
+ pi.registerMessageRenderer<ProcessUpdateDetails>(
39
+ MESSAGE_TYPE_PROCESS_UPDATE,
40
+ (
41
+ message: ProcessUpdateMessage,
42
+ _options: MessageRenderOptions,
43
+ theme: Theme,
44
+ ) => {
45
+ const details = message.details;
46
+
47
+ if (!details) {
48
+ return new Text(getContentText(message.content), 0, 0);
49
+ }
50
+
51
+ let icon: string;
52
+ let color: "success" | "error" | "warning";
53
+
54
+ if (details.status === "killed") {
55
+ icon = "\u2717"; // x mark
56
+ color = "warning";
57
+ } else if (details.success) {
58
+ icon = "\u2713"; // check mark
59
+ color = "success";
60
+ } else {
61
+ icon = "\u2717"; // x mark
62
+ color = "error";
63
+ }
64
+
65
+ const statusText =
66
+ details.status === "killed"
67
+ ? "terminated"
68
+ : details.success
69
+ ? "completed"
70
+ : `exited(${details.exitCode ?? "?"})`;
71
+
72
+ const text =
73
+ theme.fg(color, `${icon} `) +
74
+ theme.fg("accent", `"${details.processName}"`) +
75
+ theme.fg("muted", ` (${details.processId})`) +
76
+ " " +
77
+ theme.fg(color, statusText) +
78
+ theme.fg("muted", ` ${details.runtime}`);
79
+
80
+ return new Text(text, 0, 0);
81
+ },
82
+ );
83
+ }
@@ -0,0 +1,92 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@mariozechner/pi-coding-agent";
5
+ import { MESSAGE_TYPE_PROCESS_UPDATE, type ProcessInfo } from "../constants";
6
+ import type { ProcessManager } from "../manager";
7
+ import { formatRuntime } from "../utils";
8
+
9
+ interface ProcessUpdateDetails {
10
+ processId: string;
11
+ processName: string;
12
+ command: string;
13
+ status: "exited" | "killed";
14
+ exitCode: number | null;
15
+ success: boolean;
16
+ runtime: string;
17
+ }
18
+
19
+ export function setupProcessEndHook(pi: ExtensionAPI, manager: ProcessManager) {
20
+ let latestContext: ExtensionContext | null = null;
21
+
22
+ // Capture context from session events
23
+ pi.on("session_start", async (_event, ctx) => {
24
+ latestContext = ctx;
25
+ });
26
+
27
+ pi.on("turn_start", async (_event, ctx) => {
28
+ latestContext = ctx;
29
+ });
30
+
31
+ pi.on("turn_end", async (_event, ctx) => {
32
+ latestContext = ctx;
33
+ });
34
+
35
+ manager.onEvent((event) => {
36
+ if (event.type !== "process_ended") return;
37
+
38
+ const info: ProcessInfo = event.info;
39
+
40
+ // Determine if the agent should get a turn to react to this process ending.
41
+ // When true, the agent receives the message in its context and can respond
42
+ // (e.g. check results, fix code, restart the process).
43
+ const triggerAgentTurn =
44
+ (info.status === "killed" && info.alertOnKill) ||
45
+ (info.status === "exited" && info.success && info.alertOnSuccess) ||
46
+ (info.status === "exited" && !info.success && info.alertOnFailure);
47
+
48
+ const runtime = formatRuntime(info.startTime, info.endTime);
49
+
50
+ // Build notification message
51
+ let message: string;
52
+ let level: "info" | "error" | "warning";
53
+
54
+ if (info.status === "killed") {
55
+ message = `Process '${info.name}' was terminated (${runtime})`;
56
+ level = "warning";
57
+ } else if (info.success) {
58
+ message = `Process '${info.name}' completed successfully (${runtime})`;
59
+ level = "info";
60
+ } else {
61
+ message = `Process '${info.name}' crashed with exit code ${info.exitCode ?? "?"} (${runtime})`;
62
+ level = "error";
63
+ }
64
+
65
+ // Always notify user via UI
66
+ if (latestContext?.hasUI) {
67
+ latestContext.ui.notify(message, level);
68
+ }
69
+
70
+ // Always send the message so it appears in the conversation history.
71
+ // Only trigger an agent turn when the notification preferences say so.
72
+ const details: ProcessUpdateDetails = {
73
+ processId: info.id,
74
+ processName: info.name,
75
+ command: info.command,
76
+ status: info.status as "exited" | "killed",
77
+ exitCode: info.exitCode,
78
+ success: info.success ?? false,
79
+ runtime,
80
+ };
81
+
82
+ pi.sendMessage(
83
+ {
84
+ customType: MESSAGE_TYPE_PROCESS_UPDATE,
85
+ content: message,
86
+ display: true,
87
+ details,
88
+ },
89
+ { triggerTurn: triggerAgentTurn },
90
+ );
91
+ });
92
+ }
@@ -0,0 +1,146 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@mariozechner/pi-coding-agent";
5
+ import { visibleWidth } from "@mariozechner/pi-tui";
6
+ import { configLoader } from "../config";
7
+ import type { ProcessInfo } from "../constants";
8
+ import type { ProcessManager } from "../manager";
9
+
10
+ const WIDGET_ID = "processes-status";
11
+
12
+ function formatProcessStatus(
13
+ proc: ProcessInfo,
14
+ theme: ExtensionContext["ui"]["theme"],
15
+ ): string {
16
+ const name =
17
+ proc.name.length > 20 ? `${proc.name.slice(0, 17)}...` : proc.name;
18
+
19
+ switch (proc.status) {
20
+ case "running":
21
+ return `${theme.fg("accent", name)} ${theme.fg("dim", "running")}`;
22
+ case "terminating":
23
+ return `${theme.fg("warning", name)} ${theme.fg("dim", "terminating")}`;
24
+ case "terminate_timeout":
25
+ return `${theme.fg("error", name)} ${theme.fg("error", "terminate_timeout")}`;
26
+ case "killed":
27
+ return `${theme.fg("warning", name)} ${theme.fg("dim", "killed")}`;
28
+ case "exited":
29
+ if (proc.success) {
30
+ return `${theme.fg("dim", name)} ${theme.fg("success", "done")}`;
31
+ }
32
+ return `${theme.fg("error", name)} ${theme.fg("error", `exit(${proc.exitCode ?? "?"})`)}`;
33
+ default:
34
+ return `${theme.fg("dim", name)} ${theme.fg("dim", proc.status)}`;
35
+ }
36
+ }
37
+
38
+ function renderWidget(
39
+ processes: ProcessInfo[],
40
+ theme: ExtensionContext["ui"]["theme"],
41
+ maxWidth?: number,
42
+ ): string[] {
43
+ if (processes.length === 0) {
44
+ return [];
45
+ }
46
+
47
+ const aliveish = processes.filter(
48
+ (p) =>
49
+ p.status === "running" ||
50
+ p.status === "terminating" ||
51
+ p.status === "terminate_timeout",
52
+ );
53
+ const finished = processes.filter(
54
+ (p) =>
55
+ p.status !== "running" &&
56
+ p.status !== "terminating" &&
57
+ p.status !== "terminate_timeout",
58
+ );
59
+
60
+ const allProcs: ProcessInfo[] = [
61
+ ...aliveish,
62
+ ...finished.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0)),
63
+ ];
64
+
65
+ const prefix = theme.fg("dim", "processes: ");
66
+ const prefixLen = visibleWidth(prefix);
67
+ const separator = theme.fg("dim", " | ");
68
+ const separatorLen = visibleWidth(separator);
69
+ const effectiveMax = maxWidth ?? 200;
70
+
71
+ const parts: string[] = [];
72
+ let currentLen = prefixLen;
73
+ let includedCount = 0;
74
+
75
+ for (const proc of allProcs) {
76
+ const formatted = formatProcessStatus(proc, theme);
77
+ const formattedLen = visibleWidth(formatted);
78
+
79
+ // Check if adding this part would exceed the width
80
+ const needed =
81
+ includedCount > 0 ? separatorLen + formattedLen : formattedLen;
82
+
83
+ if (currentLen + needed > effectiveMax && includedCount > 0) {
84
+ // Show how many are hidden
85
+ const remaining = allProcs.length - includedCount;
86
+ if (remaining > 0) {
87
+ parts.push(theme.fg("dim", `+${remaining} more`));
88
+ }
89
+ break;
90
+ }
91
+
92
+ parts.push(formatted);
93
+ currentLen += needed;
94
+ includedCount++;
95
+ }
96
+
97
+ if (parts.length === 0) {
98
+ return [];
99
+ }
100
+
101
+ return [prefix + parts.join(separator)];
102
+ }
103
+
104
+ export function setupProcessWidget(pi: ExtensionAPI, manager: ProcessManager) {
105
+ let latestContext: ExtensionContext | null = null;
106
+
107
+ function updateWidget() {
108
+ if (!latestContext?.hasUI) return;
109
+
110
+ if (!configLoader.getConfig().widget.showStatusWidget) {
111
+ latestContext.ui.setWidget(WIDGET_ID, undefined);
112
+ return;
113
+ }
114
+
115
+ const processes = manager.list();
116
+ const maxWidth = process.stdout.columns || 120;
117
+ const lines = renderWidget(processes, latestContext.ui.theme, maxWidth);
118
+
119
+ if (lines.length === 0) {
120
+ latestContext.ui.setWidget(WIDGET_ID, undefined);
121
+ } else {
122
+ latestContext.ui.setWidget(WIDGET_ID, lines, {
123
+ placement: "belowEditor",
124
+ });
125
+ }
126
+ }
127
+
128
+ pi.on("session_start", async (_event, ctx) => {
129
+ // Startup defer: capture context only. First render happens on process
130
+ // manager events or explicit settings updates.
131
+ latestContext = ctx;
132
+ });
133
+
134
+ pi.on("session_switch", async (_event, ctx) => {
135
+ latestContext = ctx;
136
+ updateWidget();
137
+ });
138
+
139
+ manager.onEvent(() => {
140
+ updateWidget();
141
+ });
142
+
143
+ return {
144
+ update: updateWidget,
145
+ };
146
+ }
package/src/index.ts CHANGED
@@ -1,27 +1,29 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
-
6
- const MARKER_DIR = join(homedir(), ".pi", "agent", "extensions", "migrations");
7
- const MARKER_FILE = join(MARKER_DIR, "processes-moved");
2
+ import { setupProcessesCommands } from "./commands";
3
+ import { registerProcessesSettings } from "./commands/settings-command";
4
+ import { configLoader } from "./config";
5
+ import { setupProcessesHooks } from "./hooks";
6
+ import { ProcessManager } from "./manager";
7
+ import { setupProcessesTools } from "./tools";
8
8
 
9
9
  export default async function (pi: ExtensionAPI) {
10
- if (existsSync(MARKER_FILE)) return;
11
-
12
- pi.on("session_start", async (_event, ctx) => {
13
- if (existsSync(MARKER_FILE)) return;
10
+ if (process.platform === "win32") {
11
+ pi.on("session_start", async (_event, ctx) => {
12
+ if (!ctx.hasUI) return;
13
+ ctx.ui.notify("processes extension not available on Windows", "warning");
14
+ });
15
+ return;
16
+ }
14
17
 
15
- if (ctx.hasUI) {
16
- ctx.ui.notify(
17
- "@aliou/pi-processes has moved to its own repo. " +
18
- "Run: pi install npm:@aliou/pi-processes -- " +
19
- "then remove it from the pi-extensions package config.",
20
- "warning",
21
- );
22
- }
18
+ await configLoader.load();
19
+ const manager = new ProcessManager({
20
+ getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath,
21
+ });
23
22
 
24
- mkdirSync(MARKER_DIR, { recursive: true });
25
- writeFileSync(MARKER_FILE, new Date().toISOString());
23
+ const { update: updateWidget } = setupProcessesHooks(pi, manager);
24
+ setupProcessesCommands(pi, manager);
25
+ setupProcessesTools(pi, manager);
26
+ registerProcessesSettings(pi, () => {
27
+ updateWidget();
26
28
  });
27
29
  }