@aliou/pi-processes 0.1.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/README.md +63 -0
- package/commands/index.ts +488 -0
- package/constants.ts +2 -0
- package/hooks/cleanup.ts +8 -0
- package/hooks/index.ts +16 -0
- package/hooks/message-renderer.ts +83 -0
- package/hooks/process-end.ts +103 -0
- package/hooks/widget.ts +144 -0
- package/index.ts +13 -0
- package/manager.ts +405 -0
- package/package.json +35 -0
- package/tools/index.ts +532 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants";
|
|
6
|
+
import type { ProcessInfo, ProcessManager } from "../manager";
|
|
7
|
+
|
|
8
|
+
function formatRuntime(startTime: number, endTime: number | null): string {
|
|
9
|
+
const end = endTime ?? Date.now();
|
|
10
|
+
const ms = end - startTime;
|
|
11
|
+
const seconds = Math.floor(ms / 1000);
|
|
12
|
+
const minutes = Math.floor(seconds / 60);
|
|
13
|
+
const hours = Math.floor(minutes / 60);
|
|
14
|
+
|
|
15
|
+
if (hours > 0) {
|
|
16
|
+
return `${hours}h ${minutes % 60}m`;
|
|
17
|
+
}
|
|
18
|
+
if (minutes > 0) {
|
|
19
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
20
|
+
}
|
|
21
|
+
return `${seconds}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ProcessUpdateDetails {
|
|
25
|
+
processId: string;
|
|
26
|
+
processName: string;
|
|
27
|
+
command: string;
|
|
28
|
+
status: "exited" | "killed";
|
|
29
|
+
exitCode: number | null;
|
|
30
|
+
success: boolean;
|
|
31
|
+
runtime: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function setupProcessEndHook(pi: ExtensionAPI, manager: ProcessManager) {
|
|
35
|
+
let latestContext: ExtensionContext | null = null;
|
|
36
|
+
|
|
37
|
+
// Capture context from session events
|
|
38
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
39
|
+
latestContext = ctx;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
43
|
+
latestContext = ctx;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
47
|
+
latestContext = ctx;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Set callback for process end events
|
|
51
|
+
manager.onProcessEnd = (info: ProcessInfo) => {
|
|
52
|
+
// Check notification preferences
|
|
53
|
+
const shouldNotify =
|
|
54
|
+
(info.status === "killed" && info.notifyOnKill) ||
|
|
55
|
+
(info.status === "exited" && info.success && info.notifyOnSuccess) ||
|
|
56
|
+
(info.status === "exited" && !info.success && info.notifyOnFailure);
|
|
57
|
+
|
|
58
|
+
const runtime = formatRuntime(info.startTime, info.endTime);
|
|
59
|
+
|
|
60
|
+
// Build notification message
|
|
61
|
+
let message: string;
|
|
62
|
+
let level: "info" | "error" | "warning";
|
|
63
|
+
|
|
64
|
+
if (info.status === "killed") {
|
|
65
|
+
message = `Process '${info.name}' was terminated (${runtime})`;
|
|
66
|
+
level = "warning";
|
|
67
|
+
} else if (info.success) {
|
|
68
|
+
message = `Process '${info.name}' completed successfully (${runtime})`;
|
|
69
|
+
level = "info";
|
|
70
|
+
} else {
|
|
71
|
+
message = `Process '${info.name}' crashed with exit code ${info.exitCode ?? "?"} (${runtime})`;
|
|
72
|
+
level = "error";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Always notify user via UI
|
|
76
|
+
if (latestContext?.hasUI) {
|
|
77
|
+
latestContext.ui.notify(message, level);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Only send message to agent if notification preferences allow
|
|
81
|
+
if (shouldNotify) {
|
|
82
|
+
const details: ProcessUpdateDetails = {
|
|
83
|
+
processId: info.id,
|
|
84
|
+
processName: info.name,
|
|
85
|
+
command: info.command,
|
|
86
|
+
status: info.status as "exited" | "killed",
|
|
87
|
+
exitCode: info.exitCode,
|
|
88
|
+
success: info.success ?? false,
|
|
89
|
+
runtime,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
pi.sendMessage(
|
|
93
|
+
{
|
|
94
|
+
customType: MESSAGE_TYPE_PROCESS_UPDATE,
|
|
95
|
+
content: message,
|
|
96
|
+
display: true,
|
|
97
|
+
details,
|
|
98
|
+
},
|
|
99
|
+
{ triggerTurn: false },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
package/hooks/widget.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import type { ProcessInfo, ProcessManager } from "../manager";
|
|
6
|
+
|
|
7
|
+
const WIDGET_ID = "processes-status";
|
|
8
|
+
|
|
9
|
+
function formatRuntime(startTime: number, endTime: number | null): string {
|
|
10
|
+
const end = endTime ?? Date.now();
|
|
11
|
+
const ms = end - startTime;
|
|
12
|
+
const seconds = Math.floor(ms / 1000);
|
|
13
|
+
const minutes = Math.floor(seconds / 60);
|
|
14
|
+
const hours = Math.floor(minutes / 60);
|
|
15
|
+
|
|
16
|
+
if (hours > 0) {
|
|
17
|
+
return `${hours}h${minutes % 60}m`;
|
|
18
|
+
}
|
|
19
|
+
if (minutes > 0) {
|
|
20
|
+
return `${minutes}m${seconds % 60}s`;
|
|
21
|
+
}
|
|
22
|
+
return `${seconds}s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatProcessStatus(
|
|
26
|
+
proc: ProcessInfo,
|
|
27
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
28
|
+
): string {
|
|
29
|
+
const runtime = formatRuntime(proc.startTime, proc.endTime);
|
|
30
|
+
const name =
|
|
31
|
+
proc.name.length > 20 ? `${proc.name.slice(0, 17)}...` : proc.name;
|
|
32
|
+
|
|
33
|
+
if (proc.status === "running") {
|
|
34
|
+
return `${theme.fg("accent", name)} ${theme.fg("dim", runtime)}`;
|
|
35
|
+
}
|
|
36
|
+
if (proc.status === "killed") {
|
|
37
|
+
return `${theme.fg("warning", name)} ${theme.fg("dim", "killed")}`;
|
|
38
|
+
}
|
|
39
|
+
if (proc.success) {
|
|
40
|
+
return `${theme.fg("dim", name)} ${theme.fg("success", "done")}`;
|
|
41
|
+
}
|
|
42
|
+
return `${theme.fg("error", name)} ${theme.fg("error", `exit(${proc.exitCode ?? "?"})`)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderWidget(
|
|
46
|
+
processes: ProcessInfo[],
|
|
47
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
48
|
+
): string[] {
|
|
49
|
+
if (processes.length === 0) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const running = processes.filter((p) => p.status === "running");
|
|
54
|
+
const finished = processes.filter((p) => p.status !== "running");
|
|
55
|
+
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
|
|
58
|
+
// Show running processes first
|
|
59
|
+
for (const proc of running) {
|
|
60
|
+
parts.push(formatProcessStatus(proc, theme));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Show finished processes (most recent first, limit to 3)
|
|
64
|
+
const recentFinished = finished
|
|
65
|
+
.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0))
|
|
66
|
+
.slice(0, 3);
|
|
67
|
+
|
|
68
|
+
for (const proc of recentFinished) {
|
|
69
|
+
parts.push(formatProcessStatus(proc, theme));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// If there are more finished processes, show count
|
|
73
|
+
const hiddenCount = finished.length - recentFinished.length;
|
|
74
|
+
if (hiddenCount > 0) {
|
|
75
|
+
parts.push(theme.fg("dim", `+${hiddenCount} more`));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const prefix = theme.fg("dim", "processes: ");
|
|
79
|
+
return [prefix + parts.join(theme.fg("dim", " | "))];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function setupProcessWidget(pi: ExtensionAPI, manager: ProcessManager) {
|
|
83
|
+
let latestContext: ExtensionContext | null = null;
|
|
84
|
+
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
|
85
|
+
|
|
86
|
+
function updateWidget() {
|
|
87
|
+
if (!latestContext?.hasUI) return;
|
|
88
|
+
|
|
89
|
+
const processes = manager.list();
|
|
90
|
+
const lines = renderWidget(processes, latestContext.ui.theme);
|
|
91
|
+
|
|
92
|
+
if (lines.length === 0) {
|
|
93
|
+
latestContext.ui.setWidget(WIDGET_ID, undefined);
|
|
94
|
+
} else {
|
|
95
|
+
latestContext.ui.setWidget(WIDGET_ID, lines, {
|
|
96
|
+
placement: "belowEditor",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function startRefresh() {
|
|
102
|
+
if (refreshInterval) return;
|
|
103
|
+
refreshInterval = setInterval(() => {
|
|
104
|
+
const hasRunning = manager.list().some((p) => p.status === "running");
|
|
105
|
+
if (hasRunning) {
|
|
106
|
+
updateWidget();
|
|
107
|
+
}
|
|
108
|
+
}, 1000);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stopRefresh() {
|
|
112
|
+
if (refreshInterval) {
|
|
113
|
+
clearInterval(refreshInterval);
|
|
114
|
+
refreshInterval = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Capture context and update widget
|
|
119
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
120
|
+
latestContext = ctx;
|
|
121
|
+
updateWidget();
|
|
122
|
+
startRefresh();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
126
|
+
latestContext = ctx;
|
|
127
|
+
updateWidget();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
pi.on("session_shutdown", async () => {
|
|
131
|
+
stopRefresh();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Chain into process end callback
|
|
135
|
+
const originalOnProcessEnd = manager.onProcessEnd;
|
|
136
|
+
manager.onProcessEnd = (info) => {
|
|
137
|
+
originalOnProcessEnd?.call(manager, info);
|
|
138
|
+
updateWidget();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
update: updateWidget,
|
|
143
|
+
};
|
|
144
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { setupProcessesCommands } from "./commands";
|
|
3
|
+
import { setupProcessesHooks } from "./hooks";
|
|
4
|
+
import { ProcessManager } from "./manager";
|
|
5
|
+
import { setupProcessesTools } from "./tools";
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
const manager = new ProcessManager();
|
|
9
|
+
|
|
10
|
+
setupProcessesHooks(pi, manager);
|
|
11
|
+
setupProcessesTools(pi, manager);
|
|
12
|
+
setupProcessesCommands(pi, manager);
|
|
13
|
+
}
|
package/manager.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface ProcessInfo {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string; // Friendly name for display
|
|
15
|
+
pid: number;
|
|
16
|
+
command: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
startTime: number;
|
|
19
|
+
endTime: number | null;
|
|
20
|
+
status: "running" | "exited" | "killed";
|
|
21
|
+
exitCode: number | null;
|
|
22
|
+
success: boolean | null; // null if running, true if exit code 0, false otherwise
|
|
23
|
+
stdoutFile: string;
|
|
24
|
+
stderrFile: string;
|
|
25
|
+
notifyOnSuccess: boolean;
|
|
26
|
+
notifyOnFailure: boolean;
|
|
27
|
+
notifyOnKill: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ManagedProcess extends ProcessInfo {
|
|
31
|
+
process: ChildProcess;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Generate a friendly name from command
|
|
35
|
+
function inferName(command: string): string {
|
|
36
|
+
const cmd = command.toLowerCase();
|
|
37
|
+
|
|
38
|
+
// Dev servers
|
|
39
|
+
if (cmd.includes("dev") && cmd.includes("backend")) return "backend-dev";
|
|
40
|
+
if (cmd.includes("dev") && cmd.includes("frontend")) return "frontend-dev";
|
|
41
|
+
if (cmd.includes("dev") && cmd.includes("api")) return "api-dev";
|
|
42
|
+
if (
|
|
43
|
+
cmd.includes("pnpm dev") ||
|
|
44
|
+
cmd.includes("npm run dev") ||
|
|
45
|
+
cmd.includes("yarn dev")
|
|
46
|
+
)
|
|
47
|
+
return "dev-server";
|
|
48
|
+
if (cmd.includes("vite")) return "vite-dev";
|
|
49
|
+
if (cmd.includes("next dev")) return "next-dev";
|
|
50
|
+
|
|
51
|
+
// Build
|
|
52
|
+
if (cmd.includes("build")) return "build";
|
|
53
|
+
if (cmd.includes("compile")) return "compile";
|
|
54
|
+
|
|
55
|
+
// Tests
|
|
56
|
+
if (cmd.includes("test") || cmd.includes("jest") || cmd.includes("vitest"))
|
|
57
|
+
return "tests";
|
|
58
|
+
|
|
59
|
+
// Watch
|
|
60
|
+
if (cmd.includes("watch")) return "watcher";
|
|
61
|
+
|
|
62
|
+
// Logs
|
|
63
|
+
if (cmd.includes("tail")) return "log-tail";
|
|
64
|
+
|
|
65
|
+
// Docker
|
|
66
|
+
if (cmd.includes("docker-compose") || cmd.includes("docker compose"))
|
|
67
|
+
return "docker";
|
|
68
|
+
|
|
69
|
+
// Database
|
|
70
|
+
if (
|
|
71
|
+
cmd.includes("postgres") ||
|
|
72
|
+
cmd.includes("mysql") ||
|
|
73
|
+
cmd.includes("mongo")
|
|
74
|
+
)
|
|
75
|
+
return "database";
|
|
76
|
+
|
|
77
|
+
// Extract first meaningful word
|
|
78
|
+
const words = command.split(/\s+/);
|
|
79
|
+
const firstWord = (words[0] ?? "process")
|
|
80
|
+
.replace(/^\.\//, "")
|
|
81
|
+
.replace(/\.(sh|js|ts|py)$/, "");
|
|
82
|
+
return firstWord.slice(0, 20);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ProcessManager {
|
|
86
|
+
private processes: Map<string, ManagedProcess> = new Map();
|
|
87
|
+
private counter = 0;
|
|
88
|
+
private logDir: string;
|
|
89
|
+
onProcessEnd?: (info: ProcessInfo) => void;
|
|
90
|
+
|
|
91
|
+
constructor() {
|
|
92
|
+
this.logDir = join(tmpdir(), `pi-processes-${Date.now()}`);
|
|
93
|
+
mkdirSync(this.logDir, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private emitProcessEnd(info: ProcessInfo): void {
|
|
97
|
+
this.onProcessEnd?.(info);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
start(
|
|
101
|
+
command: string,
|
|
102
|
+
cwd: string,
|
|
103
|
+
name?: string,
|
|
104
|
+
options?: {
|
|
105
|
+
notifyOnSuccess?: boolean;
|
|
106
|
+
notifyOnFailure?: boolean;
|
|
107
|
+
notifyOnKill?: boolean;
|
|
108
|
+
},
|
|
109
|
+
): ProcessInfo {
|
|
110
|
+
const id = `proc_${++this.counter}`;
|
|
111
|
+
const friendlyName = name || inferName(command);
|
|
112
|
+
const stdoutFile = join(this.logDir, `${id}-stdout.log`);
|
|
113
|
+
const stderrFile = join(this.logDir, `${id}-stderr.log`);
|
|
114
|
+
|
|
115
|
+
appendFileSync(stdoutFile, "");
|
|
116
|
+
appendFileSync(stderrFile, "");
|
|
117
|
+
|
|
118
|
+
const child = spawn(command, {
|
|
119
|
+
cwd,
|
|
120
|
+
shell: true,
|
|
121
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
122
|
+
detached: false,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const managed: ManagedProcess = {
|
|
126
|
+
id,
|
|
127
|
+
name: friendlyName,
|
|
128
|
+
pid: child.pid ?? -1,
|
|
129
|
+
command,
|
|
130
|
+
cwd,
|
|
131
|
+
startTime: Date.now(),
|
|
132
|
+
endTime: null,
|
|
133
|
+
status: "running",
|
|
134
|
+
exitCode: null,
|
|
135
|
+
success: null,
|
|
136
|
+
stdoutFile,
|
|
137
|
+
stderrFile,
|
|
138
|
+
notifyOnSuccess: options?.notifyOnSuccess ?? false,
|
|
139
|
+
notifyOnFailure: options?.notifyOnFailure ?? true,
|
|
140
|
+
notifyOnKill: options?.notifyOnKill ?? false,
|
|
141
|
+
process: child,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
child.stdout?.on("data", (data: Buffer) => {
|
|
145
|
+
try {
|
|
146
|
+
appendFileSync(stdoutFile, data);
|
|
147
|
+
} catch {
|
|
148
|
+
// Ignore write errors
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
child.stderr?.on("data", (data: Buffer) => {
|
|
153
|
+
try {
|
|
154
|
+
appendFileSync(stderrFile, data);
|
|
155
|
+
} catch {
|
|
156
|
+
// Ignore write errors
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
child.on("close", (code, signal) => {
|
|
161
|
+
// Already handled (e.g., by checkRunningProcesses detecting external kill)
|
|
162
|
+
if (managed.status !== "running") {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
managed.exitCode = code;
|
|
166
|
+
managed.endTime = Date.now();
|
|
167
|
+
managed.success = code === 0;
|
|
168
|
+
managed.status = signal ? "killed" : "exited";
|
|
169
|
+
this.emitProcessEnd(this.toProcessInfo(managed));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
child.on("error", (err) => {
|
|
173
|
+
try {
|
|
174
|
+
appendFileSync(stderrFile, `Process error: ${err.message}\n`);
|
|
175
|
+
} catch {
|
|
176
|
+
// Ignore
|
|
177
|
+
}
|
|
178
|
+
managed.status = "exited";
|
|
179
|
+
managed.exitCode = -1;
|
|
180
|
+
managed.success = false;
|
|
181
|
+
managed.endTime = Date.now();
|
|
182
|
+
this.emitProcessEnd(this.toProcessInfo(managed));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
this.processes.set(id, managed);
|
|
186
|
+
|
|
187
|
+
return this.toProcessInfo(managed);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
list(): ProcessInfo[] {
|
|
191
|
+
// Check if any "running" processes have actually exited
|
|
192
|
+
this.checkRunningProcesses();
|
|
193
|
+
return Array.from(this.processes.values()).map((p) =>
|
|
194
|
+
this.toProcessInfo(p),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get(id: string): ProcessInfo | null {
|
|
199
|
+
this.checkRunningProcesses();
|
|
200
|
+
const managed = this.processes.get(id);
|
|
201
|
+
return managed ? this.toProcessInfo(managed) : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Find by ID or name (partial match)
|
|
205
|
+
find(query: string): ProcessInfo | null {
|
|
206
|
+
this.checkRunningProcesses();
|
|
207
|
+
|
|
208
|
+
// Exact ID match first
|
|
209
|
+
const byId = this.processes.get(query);
|
|
210
|
+
if (byId) return this.toProcessInfo(byId);
|
|
211
|
+
|
|
212
|
+
// Search by name (case insensitive, partial match)
|
|
213
|
+
const queryLower = query.toLowerCase();
|
|
214
|
+
for (const managed of this.processes.values()) {
|
|
215
|
+
if (managed.name.toLowerCase().includes(queryLower)) {
|
|
216
|
+
return this.toProcessInfo(managed);
|
|
217
|
+
}
|
|
218
|
+
if (managed.command.toLowerCase().includes(queryLower)) {
|
|
219
|
+
return this.toProcessInfo(managed);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
getOutput(
|
|
226
|
+
id: string,
|
|
227
|
+
tailLines = 100,
|
|
228
|
+
): { stdout: string[]; stderr: string[]; status: string } | null {
|
|
229
|
+
const managed = this.processes.get(id);
|
|
230
|
+
if (!managed) return null;
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
stdout: this.readTailLines(managed.stdoutFile, tailLines),
|
|
234
|
+
stderr: this.readTailLines(managed.stderrFile, tailLines),
|
|
235
|
+
status: managed.status,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
getFullOutput(id: string): { stdout: string; stderr: string } | null {
|
|
240
|
+
const managed = this.processes.get(id);
|
|
241
|
+
if (!managed) return null;
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
return {
|
|
245
|
+
stdout: readFileSync(managed.stdoutFile, "utf-8"),
|
|
246
|
+
stderr: readFileSync(managed.stderrFile, "utf-8"),
|
|
247
|
+
};
|
|
248
|
+
} catch {
|
|
249
|
+
return { stdout: "", stderr: "" };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
getLogFiles(id: string): { stdoutFile: string; stderrFile: string } | null {
|
|
254
|
+
const managed = this.processes.get(id);
|
|
255
|
+
if (!managed) return null;
|
|
256
|
+
return {
|
|
257
|
+
stdoutFile: managed.stdoutFile,
|
|
258
|
+
stderrFile: managed.stderrFile,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
kill(id: string): boolean {
|
|
263
|
+
const managed = this.processes.get(id);
|
|
264
|
+
if (!managed) return false;
|
|
265
|
+
|
|
266
|
+
if (managed.status !== "running") {
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Disable kill notification since this is intentional
|
|
271
|
+
managed.notifyOnKill = false;
|
|
272
|
+
|
|
273
|
+
managed.status = "killed";
|
|
274
|
+
managed.endTime = Date.now();
|
|
275
|
+
managed.success = false;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
managed.process.kill("SIGTERM");
|
|
279
|
+
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
try {
|
|
282
|
+
if (!managed.process.killed) {
|
|
283
|
+
managed.process.kill("SIGKILL");
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
// Process may already be dead
|
|
287
|
+
}
|
|
288
|
+
}, 3000);
|
|
289
|
+
|
|
290
|
+
return true;
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Clear finished processes (not running)
|
|
297
|
+
clearFinished(): number {
|
|
298
|
+
let cleared = 0;
|
|
299
|
+
for (const [id, managed] of this.processes) {
|
|
300
|
+
if (managed.status !== "running") {
|
|
301
|
+
// Clean up log files
|
|
302
|
+
try {
|
|
303
|
+
rmSync(managed.stdoutFile, { force: true });
|
|
304
|
+
rmSync(managed.stderrFile, { force: true });
|
|
305
|
+
} catch {
|
|
306
|
+
// Ignore
|
|
307
|
+
}
|
|
308
|
+
this.processes.delete(id);
|
|
309
|
+
cleared++;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return cleared;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
killAll(): void {
|
|
316
|
+
for (const [id] of this.processes) {
|
|
317
|
+
this.kill(id);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Check if a PID is still alive (works across platforms)
|
|
322
|
+
private isProcessAlive(pid: number): boolean {
|
|
323
|
+
try {
|
|
324
|
+
// Signal 0 checks if process exists without actually sending a signal
|
|
325
|
+
process.kill(pid, 0);
|
|
326
|
+
return true;
|
|
327
|
+
} catch (error) {
|
|
328
|
+
// If error code is ESRCH, process doesn't exist
|
|
329
|
+
// If error code is EPERM, process exists but we don't have permission (still alive)
|
|
330
|
+
const err = error as NodeJS.ErrnoException;
|
|
331
|
+
return err.code === "EPERM";
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check all running processes and update status if they've exited
|
|
336
|
+
private checkRunningProcesses(): void {
|
|
337
|
+
for (const managed of this.processes.values()) {
|
|
338
|
+
if (managed.status === "running" && !this.isProcessAlive(managed.pid)) {
|
|
339
|
+
// Process is no longer alive but we didn't get the close event yet
|
|
340
|
+
// Mark it as exited with unknown exit code
|
|
341
|
+
managed.status = "exited";
|
|
342
|
+
managed.exitCode = null;
|
|
343
|
+
managed.success = false;
|
|
344
|
+
managed.endTime = Date.now();
|
|
345
|
+
this.emitProcessEnd(this.toProcessInfo(managed));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
cleanup(): void {
|
|
351
|
+
this.killAll();
|
|
352
|
+
try {
|
|
353
|
+
rmSync(this.logDir, { recursive: true, force: true });
|
|
354
|
+
} catch {
|
|
355
|
+
// Ignore cleanup errors
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private readTailLines(filePath: string, lines: number): string[] {
|
|
360
|
+
try {
|
|
361
|
+
const content = readFileSync(filePath, "utf-8");
|
|
362
|
+
const allLines = content.split("\n");
|
|
363
|
+
if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
|
|
364
|
+
allLines.pop();
|
|
365
|
+
}
|
|
366
|
+
return allLines.slice(-lines);
|
|
367
|
+
} catch {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
getFileSize(id: string): { stdout: number; stderr: number } | null {
|
|
373
|
+
const managed = this.processes.get(id);
|
|
374
|
+
if (!managed) return null;
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
return {
|
|
378
|
+
stdout: statSync(managed.stdoutFile).size,
|
|
379
|
+
stderr: statSync(managed.stderrFile).size,
|
|
380
|
+
};
|
|
381
|
+
} catch {
|
|
382
|
+
return { stdout: 0, stderr: 0 };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private toProcessInfo(managed: ManagedProcess): ProcessInfo {
|
|
387
|
+
return {
|
|
388
|
+
id: managed.id,
|
|
389
|
+
name: managed.name,
|
|
390
|
+
pid: managed.pid,
|
|
391
|
+
command: managed.command,
|
|
392
|
+
cwd: managed.cwd,
|
|
393
|
+
startTime: managed.startTime,
|
|
394
|
+
endTime: managed.endTime,
|
|
395
|
+
status: managed.status,
|
|
396
|
+
exitCode: managed.exitCode,
|
|
397
|
+
success: managed.success,
|
|
398
|
+
stdoutFile: managed.stdoutFile,
|
|
399
|
+
stderrFile: managed.stderrFile,
|
|
400
|
+
notifyOnSuccess: managed.notifyOnSuccess,
|
|
401
|
+
notifyOnFailure: managed.notifyOnFailure,
|
|
402
|
+
notifyOnKill: managed.notifyOnKill,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aliou/pi-processes",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi",
|
|
10
|
+
"processes"
|
|
11
|
+
],
|
|
12
|
+
"pi": {
|
|
13
|
+
"extensions": [
|
|
14
|
+
"./index.ts"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"*.ts",
|
|
22
|
+
"commands",
|
|
23
|
+
"hooks",
|
|
24
|
+
"tools",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@sinclair/typebox": "^0.34.41"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@mariozechner/pi-ai": ">=0.49.0",
|
|
32
|
+
"@mariozechner/pi-coding-agent": ">=0.49.0",
|
|
33
|
+
"@mariozechner/pi-tui": ">=0.49.0"
|
|
34
|
+
}
|
|
35
|
+
}
|