@aliou/pi-processes 0.4.5 → 0.4.7

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,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
  }