@mjakl/pi-processes 0.8.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 +137 -0
- package/package.json +68 -0
- package/src/commands/index.ts +10 -0
- package/src/commands/processes/command.ts +31 -0
- package/src/commands/processes/index.ts +1 -0
- package/src/components/log-file-viewer.ts +317 -0
- package/src/components/processes-component.ts +458 -0
- package/src/config.ts +125 -0
- package/src/constants/index.ts +11 -0
- package/src/constants/types.ts +65 -0
- package/src/hooks/background-blocker.ts +295 -0
- package/src/hooks/cleanup.ts +8 -0
- package/src/hooks/index.ts +24 -0
- package/src/hooks/message-renderer.ts +83 -0
- package/src/hooks/process-end.ts +86 -0
- package/src/hooks/status-widget.ts +65 -0
- package/src/index.ts +25 -0
- package/src/manager.ts +513 -0
- package/src/tools/actions/clear.ts +20 -0
- package/src/tools/actions/index.ts +48 -0
- package/src/tools/actions/kill.ts +107 -0
- package/src/tools/actions/list.ts +43 -0
- package/src/tools/actions/logs.ts +85 -0
- package/src/tools/actions/output.ts +186 -0
- package/src/tools/actions/start.ts +70 -0
- package/src/tools/index.ts +347 -0
- package/src/tools/tool-rendering.ts +164 -0
- package/src/utils/ansi.ts +36 -0
- package/src/utils/command-executor.ts +56 -0
- package/src/utils/format.ts +42 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/process-group.ts +22 -0
- package/src/utils/shell-utils.ts +133 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocks background bash commands (e.g. `cmd &`, `nohup cmd`) and obvious
|
|
3
|
+
* long-running foreground commands (e.g. `pnpm dev`, `tail -f`) and guides
|
|
4
|
+
* the model to use the process tool instead.
|
|
5
|
+
*
|
|
6
|
+
* Controlled via config: `interception.blockBackgroundCommands`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
import { type Program, parse } from "@aliou/sh";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { walkCommands, wordToString } from "../utils/shell-utils";
|
|
13
|
+
|
|
14
|
+
const BACKGROUND_CMD_NAMES = new Set(["nohup", "disown", "setsid"]);
|
|
15
|
+
const BACKGROUND_PATTERN = /&\s*$/;
|
|
16
|
+
const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
|
|
17
|
+
const LONG_RUNNING_SCRIPT_NAMES = new Set([
|
|
18
|
+
"dev",
|
|
19
|
+
"start",
|
|
20
|
+
"serve",
|
|
21
|
+
"preview",
|
|
22
|
+
"watch",
|
|
23
|
+
]);
|
|
24
|
+
const DIRECT_LONG_RUNNING_COMMANDS = new Set([
|
|
25
|
+
"vite",
|
|
26
|
+
"nodemon",
|
|
27
|
+
"webpack-dev-server",
|
|
28
|
+
"uvicorn",
|
|
29
|
+
"foreman",
|
|
30
|
+
"honcho",
|
|
31
|
+
]);
|
|
32
|
+
const SHELL_LAUNCHERS = new Set(["bash", "sh", "zsh", "fish"]);
|
|
33
|
+
const FOLLOW_FLAGS = new Set(["-f", "--follow"]);
|
|
34
|
+
const WATCH_FLAGS = new Set([
|
|
35
|
+
"--watch",
|
|
36
|
+
"--watchall",
|
|
37
|
+
"--watch-all",
|
|
38
|
+
"--watchfiles",
|
|
39
|
+
"--reload",
|
|
40
|
+
]);
|
|
41
|
+
const DETACH_FLAGS = new Set(["-d", "--detach"]);
|
|
42
|
+
const SUSPICIOUS_SCRIPT_NAME =
|
|
43
|
+
/(^|[-_.])(dev|serve|server|start|watch|tail|logs?|port[-_]?forward|preview)([-_.]|$)/i;
|
|
44
|
+
|
|
45
|
+
export interface ManagedCommandDecision {
|
|
46
|
+
kind: "background" | "long_running";
|
|
47
|
+
suggestedName: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function analyzeManagedCommand(
|
|
51
|
+
command: string,
|
|
52
|
+
): ManagedCommandDecision | undefined {
|
|
53
|
+
try {
|
|
54
|
+
const { ast } = parse(command);
|
|
55
|
+
|
|
56
|
+
for (const stmt of ast.body) {
|
|
57
|
+
if (stmt.background) {
|
|
58
|
+
return {
|
|
59
|
+
kind: "background",
|
|
60
|
+
suggestedName:
|
|
61
|
+
findFirstCommandName(command, ast) ?? "background-process",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let decision: ManagedCommandDecision | undefined;
|
|
67
|
+
walkCommands(ast, (cmd) => {
|
|
68
|
+
const words = cmd.words?.map(wordToString).filter(Boolean) ?? [];
|
|
69
|
+
if (words.length === 0) return false;
|
|
70
|
+
|
|
71
|
+
decision = classifySimpleCommand(words);
|
|
72
|
+
return decision !== undefined;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return decision;
|
|
76
|
+
} catch {
|
|
77
|
+
return analyzeManagedCommandFallback(command);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function setupBackgroundBlocker(pi: ExtensionAPI): void {
|
|
82
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
83
|
+
if (event.toolName !== "bash") return;
|
|
84
|
+
|
|
85
|
+
const command = String(event.input.command ?? "");
|
|
86
|
+
const decision = analyzeManagedCommand(command);
|
|
87
|
+
|
|
88
|
+
if (!decision) return;
|
|
89
|
+
|
|
90
|
+
const isBackground = decision.kind === "background";
|
|
91
|
+
ctx.ui?.notify(
|
|
92
|
+
isBackground
|
|
93
|
+
? "Blocked background command. Use process instead."
|
|
94
|
+
: "Blocked long-running command. Use process instead.",
|
|
95
|
+
"warning",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const example = `process({ action: "start", name: "${decision.suggestedName}", command: ${JSON.stringify(command)} })`;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
block: true,
|
|
102
|
+
reason: isBackground
|
|
103
|
+
? `This bash command tries to run in the background. Use the process tool instead. Example: ${example}`
|
|
104
|
+
: `This bash command looks long-running and would block the conversation. Use the process tool instead. Example: ${example}`,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function classifySimpleCommand(
|
|
110
|
+
words: string[],
|
|
111
|
+
): ManagedCommandDecision | undefined {
|
|
112
|
+
const [rawName, ...rawArgs] = words;
|
|
113
|
+
const name = basename(rawName).toLowerCase();
|
|
114
|
+
const args = rawArgs.map((arg) => arg.toLowerCase());
|
|
115
|
+
|
|
116
|
+
if (BACKGROUND_CMD_NAMES.has(name)) {
|
|
117
|
+
return {
|
|
118
|
+
kind: "background",
|
|
119
|
+
suggestedName: suggestProcessName(words),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (isLongRunningCommand(rawName, rawArgs, name, args)) {
|
|
124
|
+
return {
|
|
125
|
+
kind: "long_running",
|
|
126
|
+
suggestedName: suggestProcessName(words),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isLongRunningCommand(
|
|
134
|
+
rawName: string,
|
|
135
|
+
rawArgs: string[],
|
|
136
|
+
name: string,
|
|
137
|
+
args: string[],
|
|
138
|
+
): boolean {
|
|
139
|
+
if (PACKAGE_MANAGERS.has(name)) {
|
|
140
|
+
const scriptName = getPackageManagerScript(args);
|
|
141
|
+
if (
|
|
142
|
+
(scriptName !== undefined && LONG_RUNNING_SCRIPT_NAMES.has(scriptName)) ||
|
|
143
|
+
hasAnyArg(args, WATCH_FLAGS)
|
|
144
|
+
) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if ((args[0] === "exec" || args[0] === "dlx") && rawArgs[1]) {
|
|
149
|
+
const execName = basename(rawArgs[1]).toLowerCase();
|
|
150
|
+
const execArgs = rawArgs.slice(2);
|
|
151
|
+
return isLongRunningCommand(
|
|
152
|
+
rawArgs[1],
|
|
153
|
+
execArgs,
|
|
154
|
+
execName,
|
|
155
|
+
execArgs.map((arg) => arg.toLowerCase()),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (DIRECT_LONG_RUNNING_COMMANDS.has(name)) return true;
|
|
163
|
+
|
|
164
|
+
if (name === "next") return args[0] === "dev" || args[0] === "start";
|
|
165
|
+
if (name === "astro") return args[0] === "dev" || args[0] === "preview";
|
|
166
|
+
if (name === "webpack") return args.includes("serve");
|
|
167
|
+
if (name === "cargo") return args[0] === "watch";
|
|
168
|
+
if (name === "tail" || name === "journalctl") {
|
|
169
|
+
return hasAnyArg(args, FOLLOW_FLAGS);
|
|
170
|
+
}
|
|
171
|
+
if (name === "kubectl") {
|
|
172
|
+
return (
|
|
173
|
+
args[0] === "port-forward" ||
|
|
174
|
+
(args[0] === "logs" && hasAnyArg(args, FOLLOW_FLAGS))
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (name === "docker-compose") {
|
|
178
|
+
return args.includes("up") && !hasAnyArg(args, DETACH_FLAGS);
|
|
179
|
+
}
|
|
180
|
+
if (name === "docker") {
|
|
181
|
+
return (
|
|
182
|
+
args[0] === "compose" &&
|
|
183
|
+
args.includes("up") &&
|
|
184
|
+
!hasAnyArg(args, DETACH_FLAGS)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (name === "ssh") return hasSshNoCommandFlag(rawArgs);
|
|
188
|
+
if (name === "python" || name === "python3") {
|
|
189
|
+
return args[0] === "-m" && args[1] === "http.server";
|
|
190
|
+
}
|
|
191
|
+
if (name === "vitest" || name === "jest") {
|
|
192
|
+
return hasAnyArg(args, WATCH_FLAGS);
|
|
193
|
+
}
|
|
194
|
+
if (name === "rails") return args[0] === "server" || args[0] === "s";
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
looksLikeSuspiciousScript(rawName) ||
|
|
198
|
+
(SHELL_LAUNCHERS.has(name) && looksLikeSuspiciousScript(args[0]))
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getPackageManagerScript(args: string[]): string | undefined {
|
|
203
|
+
if (args.length === 0) return undefined;
|
|
204
|
+
if (args[0] === "run" || args[0] === "exec" || args[0] === "dlx") {
|
|
205
|
+
return args[1];
|
|
206
|
+
}
|
|
207
|
+
return args[0];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function hasSshNoCommandFlag(args: string[]): boolean {
|
|
211
|
+
return args.some((arg) => /^-[^-]*N/.test(arg));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hasAnyArg(args: string[], values: Set<string>): boolean {
|
|
215
|
+
return args.some((arg) => values.has(arg));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function suggestProcessName(words: string[]): string {
|
|
219
|
+
const [rawName, ...rawArgs] = words;
|
|
220
|
+
const name = basename(rawName).toLowerCase();
|
|
221
|
+
const args = rawArgs.map((arg) => arg.toLowerCase());
|
|
222
|
+
|
|
223
|
+
if (PACKAGE_MANAGERS.has(name)) {
|
|
224
|
+
const scriptName = getPackageManagerScript(args);
|
|
225
|
+
if (scriptName) return sanitizeProcessName(scriptName);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (name === "docker" || name === "docker-compose") return "compose";
|
|
229
|
+
if (name === "kubectl" && args[0] === "port-forward") return "port-forward";
|
|
230
|
+
if (name === "tail" || name === "journalctl") return "logs";
|
|
231
|
+
if (SHELL_LAUNCHERS.has(name) && rawArgs[0]) {
|
|
232
|
+
const scriptName = sanitizeProcessName(rawArgs[0]);
|
|
233
|
+
if (scriptName !== "process") return scriptName;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return sanitizeProcessName(rawName);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sanitizeProcessName(value: string): string {
|
|
240
|
+
const withoutExt = basename(value).replace(/\.(sh|bash|zsh|fish)$/i, "");
|
|
241
|
+
const cleaned = withoutExt
|
|
242
|
+
.toLowerCase()
|
|
243
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
244
|
+
.replace(/^-+|-+$/g, "");
|
|
245
|
+
return cleaned || "process";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function looksLikeSuspiciousScript(value: string | undefined): boolean {
|
|
249
|
+
if (!value) return false;
|
|
250
|
+
return SUSPICIOUS_SCRIPT_NAME.test(basename(value));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function analyzeManagedCommandFallback(
|
|
254
|
+
command: string,
|
|
255
|
+
): ManagedCommandDecision | undefined {
|
|
256
|
+
if (BACKGROUND_PATTERN.test(command)) {
|
|
257
|
+
return {
|
|
258
|
+
kind: "background",
|
|
259
|
+
suggestedName: "background-process",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const lower = command.toLowerCase();
|
|
264
|
+
if (
|
|
265
|
+
/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b/.test(
|
|
266
|
+
lower,
|
|
267
|
+
) ||
|
|
268
|
+
/\bdocker(?:-compose|\s+compose)\s+up\b/.test(lower) ||
|
|
269
|
+
/\bkubectl\s+port-forward\b/.test(lower) ||
|
|
270
|
+
/\b(?:tail|journalctl)\b.*(?:\s-f\b|\s-F\b|--follow\b)/.test(lower)
|
|
271
|
+
) {
|
|
272
|
+
return {
|
|
273
|
+
kind: "long_running",
|
|
274
|
+
suggestedName: "process",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function findFirstCommandName(
|
|
282
|
+
command: string,
|
|
283
|
+
ast: Program,
|
|
284
|
+
): string | undefined {
|
|
285
|
+
let suggested: string | undefined;
|
|
286
|
+
|
|
287
|
+
walkCommands(ast, (cmd) => {
|
|
288
|
+
const words = cmd.words?.map(wordToString).filter(Boolean) ?? [];
|
|
289
|
+
if (words.length === 0) return false;
|
|
290
|
+
suggested = suggestProcessName(words);
|
|
291
|
+
return true;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return suggested ?? sanitizeProcessName(command);
|
|
295
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/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.cleanup();
|
|
7
|
+
});
|
|
8
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ResolvedProcessesConfig } from "../config";
|
|
3
|
+
import type { ProcessManager } from "../manager";
|
|
4
|
+
import { setupBackgroundBlocker } from "./background-blocker";
|
|
5
|
+
import { setupCleanupHook } from "./cleanup";
|
|
6
|
+
import { setupMessageRenderer } from "./message-renderer";
|
|
7
|
+
import { setupProcessEndHook } from "./process-end";
|
|
8
|
+
import { setupStatusWidget } from "./status-widget";
|
|
9
|
+
|
|
10
|
+
export function setupProcessesHooks(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
manager: ProcessManager,
|
|
13
|
+
config: ResolvedProcessesConfig,
|
|
14
|
+
): void {
|
|
15
|
+
setupCleanupHook(pi, manager);
|
|
16
|
+
setupProcessEndHook(pi, manager);
|
|
17
|
+
setupStatusWidget(pi, manager);
|
|
18
|
+
|
|
19
|
+
if (config.interception.blockBackgroundCommands) {
|
|
20
|
+
setupBackgroundBlocker(pi);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setupMessageRenderer(pi);
|
|
24
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
MessageRenderOptions,
|
|
4
|
+
Theme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Text } from "@earendil-works/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,86 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { MESSAGE_TYPE_PROCESS_UPDATE, type ProcessInfo } from "../constants";
|
|
3
|
+
import type { ProcessManager } from "../manager";
|
|
4
|
+
import { formatRuntime, stripAnsi, truncateCmd } from "../utils";
|
|
5
|
+
|
|
6
|
+
interface ProcessUpdateDetails {
|
|
7
|
+
processId: string;
|
|
8
|
+
processName: string;
|
|
9
|
+
command: string;
|
|
10
|
+
status: "exited" | "killed";
|
|
11
|
+
exitCode: number | null;
|
|
12
|
+
success: boolean;
|
|
13
|
+
runtime: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function setupProcessEndHook(pi: ExtensionAPI, manager: ProcessManager) {
|
|
17
|
+
manager.onEvent((event) => {
|
|
18
|
+
if (event.type !== "process_ended") return;
|
|
19
|
+
|
|
20
|
+
// Tool-initiated kills already return a tool result. Do not enqueue a
|
|
21
|
+
// custom message while the agent is streaming; even triggerTurn=false would
|
|
22
|
+
// otherwise be delivered as steering by Pi.
|
|
23
|
+
if (!event.triggerAgentTurn) return;
|
|
24
|
+
|
|
25
|
+
const info: ProcessInfo = event.info;
|
|
26
|
+
const runtime = formatRuntime(info.startTime, info.endTime);
|
|
27
|
+
|
|
28
|
+
// Build message
|
|
29
|
+
let summary: string;
|
|
30
|
+
|
|
31
|
+
if (info.status === "killed") {
|
|
32
|
+
summary = `Process "${info.name}" (${info.id}) was terminated after ${runtime}.`;
|
|
33
|
+
} else if (info.success) {
|
|
34
|
+
summary = `Process "${info.name}" (${info.id}) completed successfully after ${runtime}.`;
|
|
35
|
+
} else {
|
|
36
|
+
summary = `Process "${info.name}" (${info.id}) crashed with exit code ${info.exitCode ?? "?"} after ${runtime}.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const message = buildAgentMessage(summary, info, manager);
|
|
40
|
+
|
|
41
|
+
// Send the message to the conversation - displayed via custom renderer in UI.
|
|
42
|
+
const details: ProcessUpdateDetails = {
|
|
43
|
+
processId: info.id,
|
|
44
|
+
processName: info.name,
|
|
45
|
+
command: info.command,
|
|
46
|
+
status: info.status as "exited" | "killed",
|
|
47
|
+
exitCode: info.exitCode,
|
|
48
|
+
success: info.success ?? false,
|
|
49
|
+
runtime,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
pi.sendMessage(
|
|
53
|
+
{
|
|
54
|
+
customType: MESSAGE_TYPE_PROCESS_UPDATE,
|
|
55
|
+
content: message,
|
|
56
|
+
display: true,
|
|
57
|
+
details,
|
|
58
|
+
},
|
|
59
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildAgentMessage(
|
|
65
|
+
summary: string,
|
|
66
|
+
info: ProcessInfo,
|
|
67
|
+
manager: ProcessManager,
|
|
68
|
+
): string {
|
|
69
|
+
const lines = [summary, `Command: ${truncateCmd(info.command, 160)}`];
|
|
70
|
+
|
|
71
|
+
const recentOutput = manager.getCombinedOutput(info.id, 20) ?? [];
|
|
72
|
+
if (recentOutput.length > 0) {
|
|
73
|
+
lines.push("", "Recent output:");
|
|
74
|
+
for (const line of recentOutput) {
|
|
75
|
+
const prefix = line.type === "stderr" ? "stderr" : "stdout";
|
|
76
|
+
lines.push(`${prefix}: ${truncateCmd(stripAnsi(line.text), 500)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
lines.push(
|
|
81
|
+
"",
|
|
82
|
+
"This is the automatic process-end notification. Do not call process list/output/logs just to check whether it finished; use process output once only if you need more logs for debugging.",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ProcessInfo } from "../constants";
|
|
6
|
+
import type { ProcessManager } from "../manager";
|
|
7
|
+
|
|
8
|
+
const STATUS_WIDGET_ID = "processes-status";
|
|
9
|
+
|
|
10
|
+
function renderStatusWidget(
|
|
11
|
+
processes: ProcessInfo[],
|
|
12
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
13
|
+
): string[] {
|
|
14
|
+
if (processes.length === 0) return [];
|
|
15
|
+
|
|
16
|
+
const activeCount = processes.filter(
|
|
17
|
+
(process) =>
|
|
18
|
+
process.status === "running" ||
|
|
19
|
+
process.status === "terminating" ||
|
|
20
|
+
process.status === "terminate_timeout",
|
|
21
|
+
).length;
|
|
22
|
+
const finishedCount = processes.length - activeCount;
|
|
23
|
+
|
|
24
|
+
const line =
|
|
25
|
+
theme.fg("dim", "processes: ") +
|
|
26
|
+
theme.fg("accent", String(activeCount)) +
|
|
27
|
+
theme.fg("dim", " active") +
|
|
28
|
+
theme.fg("dim", " | ") +
|
|
29
|
+
theme.fg("dim", String(finishedCount)) +
|
|
30
|
+
theme.fg("dim", " finished");
|
|
31
|
+
|
|
32
|
+
return [line];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setupStatusWidget(
|
|
36
|
+
pi: ExtensionAPI,
|
|
37
|
+
manager: ProcessManager,
|
|
38
|
+
): void {
|
|
39
|
+
let latestContext: ExtensionContext | null = null;
|
|
40
|
+
|
|
41
|
+
const updateWidget = (): void => {
|
|
42
|
+
if (!latestContext?.hasUI) return;
|
|
43
|
+
|
|
44
|
+
const processes = manager.list();
|
|
45
|
+
if (processes.length === 0) {
|
|
46
|
+
latestContext.ui.setWidget(STATUS_WIDGET_ID, undefined);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
latestContext.ui.setWidget(
|
|
51
|
+
STATUS_WIDGET_ID,
|
|
52
|
+
renderStatusWidget(processes, latestContext.ui.theme),
|
|
53
|
+
{ placement: "belowEditor" },
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
manager.onEvent(() => {
|
|
58
|
+
updateWidget();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
62
|
+
latestContext = ctx;
|
|
63
|
+
updateWidget();
|
|
64
|
+
});
|
|
65
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { setupProcessesCommands } from "./commands";
|
|
3
|
+
import { configLoader } from "./config";
|
|
4
|
+
import { setupProcessesHooks } from "./hooks";
|
|
5
|
+
import { ProcessManager } from "./manager";
|
|
6
|
+
import { setupProcessesTools } from "./tools";
|
|
7
|
+
|
|
8
|
+
export default async function (pi: ExtensionAPI) {
|
|
9
|
+
if (process.platform === "win32") {
|
|
10
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
11
|
+
if (!ctx.hasUI) return;
|
|
12
|
+
ctx.ui.notify("processes extension not available on Windows", "warning");
|
|
13
|
+
});
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await configLoader.load();
|
|
18
|
+
const manager = new ProcessManager({
|
|
19
|
+
getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
setupProcessesHooks(pi, manager, configLoader.getConfig());
|
|
23
|
+
setupProcessesCommands(pi, manager);
|
|
24
|
+
setupProcessesTools(pi, manager);
|
|
25
|
+
}
|