@aliou/pi-processes 0.3.4 → 0.4.2
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/commands/index.ts +282 -16
- package/commands/settings-command.ts +128 -0
- package/components/log-stream-component.ts +149 -0
- package/components/process-picker-component.ts +155 -0
- package/components/processes-component.ts +450 -0
- package/components/status-format.ts +38 -0
- package/config.ts +62 -0
- package/constants/index.ts +11 -0
- package/constants/types.ts +65 -0
- package/hooks/index.ts +3 -1
- package/hooks/widget.ts +6 -0
- package/index.ts +9 -3
- package/manager.ts +41 -0
- package/package.json +7 -2
- package/tools/actions/kill.ts +1 -1
- package/tools/actions/output.ts +8 -5
- package/tools/index.ts +107 -87
- package/utils/ansi.ts +36 -0
- package/utils/format.ts +42 -0
- package/utils/index.ts +3 -0
- package/utils/process-group.ts +22 -0
|
@@ -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);
|
|
21
|
+
const { update: updateWidget } = setupProcessesHooks(pi, manager);
|
|
20
22
|
setupProcessesCommands(pi, manager);
|
|
23
|
+
setupProcessesTools(pi, manager);
|
|
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
|
}
|
|
@@ -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
|
+
"version": "0.4.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": [
|
|
@@ -25,12 +25,17 @@
|
|
|
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.3.0",
|
|
38
|
+
"@aliou/pi-utils-ui": "^0.1.0"
|
|
34
39
|
},
|
|
35
40
|
"peerDependencies": {
|
|
36
41
|
"@mariozechner/pi-coding-agent": ">=0.51.0"
|
package/tools/actions/kill.ts
CHANGED
|
@@ -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 /
|
|
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: {
|
package/tools/actions/output.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 <=
|
|
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 <
|
|
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ToolBody, ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
|
|
1
2
|
import { StringEnum } from "@mariozechner/pi-ai";
|
|
2
3
|
import type {
|
|
3
4
|
AgentToolResult,
|
|
@@ -59,8 +60,8 @@ type ProcessesParamsType = Static<typeof ProcessesParams>;
|
|
|
59
60
|
|
|
60
61
|
export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
|
|
61
62
|
pi.registerTool<typeof ProcessesParams, ProcessesDetails>({
|
|
62
|
-
name: "
|
|
63
|
-
label: "
|
|
63
|
+
name: "process",
|
|
64
|
+
label: "Process",
|
|
64
65
|
description: `Manage background processes. Actions:
|
|
65
66
|
- start: Run command in background (requires 'name' and 'command')
|
|
66
67
|
- alertOnSuccess (default: false): Get a turn to react when process completes successfully
|
|
@@ -82,36 +83,53 @@ Note: User always sees process updates in the UI. The notify flags control wheth
|
|
|
82
83
|
return executeAction(params, manager, ctx);
|
|
83
84
|
},
|
|
84
85
|
|
|
85
|
-
renderCall(args: ProcessesParamsType, theme: Theme)
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
renderCall(args: ProcessesParamsType, theme: Theme) {
|
|
87
|
+
const longArgs: Array<{ label?: string; value: string }> = [];
|
|
88
|
+
const optionArgs: Array<{ label: string; value: string }> = [];
|
|
89
|
+
let mainArg: string | undefined;
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (args.id) {
|
|
102
|
-
text += ` ${theme.fg("muted", args.id)}`;
|
|
91
|
+
if (args.action === "start") {
|
|
92
|
+
if (args.name) {
|
|
93
|
+
mainArg = `"${args.name}"`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (args.command) {
|
|
97
|
+
if (!mainArg && args.command.length <= 60) {
|
|
98
|
+
mainArg = args.command;
|
|
99
|
+
} else if (args.command.length <= 60) {
|
|
100
|
+
optionArgs.push({ label: "command", value: args.command });
|
|
101
|
+
} else {
|
|
102
|
+
longArgs.push({ label: "command", value: args.command });
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
(args.action === "output" ||
|
|
109
|
+
args.action === "kill" ||
|
|
110
|
+
args.action === "logs") &&
|
|
111
|
+
args.id
|
|
112
|
+
) {
|
|
113
|
+
mainArg = args.id;
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
return new
|
|
116
|
+
return new ToolCallHeader(
|
|
117
|
+
{
|
|
118
|
+
toolName: "Process",
|
|
119
|
+
action: args.action,
|
|
120
|
+
mainArg,
|
|
121
|
+
optionArgs,
|
|
122
|
+
longArgs,
|
|
123
|
+
},
|
|
124
|
+
theme,
|
|
125
|
+
);
|
|
108
126
|
},
|
|
109
127
|
|
|
110
128
|
renderResult(
|
|
111
129
|
result: AgentToolResult<ProcessesDetails>,
|
|
112
|
-
|
|
130
|
+
options: ToolRenderResultOptions,
|
|
113
131
|
theme: Theme,
|
|
114
|
-
)
|
|
132
|
+
) {
|
|
115
133
|
const { details } = result;
|
|
116
134
|
|
|
117
135
|
if (!details) {
|
|
@@ -123,34 +141,32 @@ Note: User always sees process updates in the UI. The notify flags control wheth
|
|
|
123
141
|
);
|
|
124
142
|
}
|
|
125
143
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// For start action
|
|
131
|
-
if (details.action === "start" && details.process) {
|
|
132
|
-
const p = details.process;
|
|
133
|
-
return new Text(
|
|
134
|
-
theme.fg("success", "\u2713 Started ") +
|
|
135
|
-
theme.fg("accent", `"${p.name}"`) +
|
|
136
|
-
` (${p.id}, PID: ${p.pid})`,
|
|
137
|
-
0,
|
|
138
|
-
0,
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// For output action
|
|
143
|
-
if (details.action === "output" && details.output) {
|
|
144
|
-
const lines: string[] = [];
|
|
145
|
-
lines.push(theme.fg("muted", details.message));
|
|
144
|
+
const fields: Array<
|
|
145
|
+
{ label: string; value: string; showCollapsed?: boolean } | Text
|
|
146
|
+
> = [];
|
|
146
147
|
|
|
148
|
+
if (!details.success) {
|
|
149
|
+
fields.push({
|
|
150
|
+
label: "Error",
|
|
151
|
+
value: theme.fg("error", details.message),
|
|
152
|
+
showCollapsed: true,
|
|
153
|
+
});
|
|
154
|
+
} else if (details.action === "start" && details.process) {
|
|
155
|
+
const process = details.process;
|
|
156
|
+
fields.push({
|
|
157
|
+
label: "Status",
|
|
158
|
+
value:
|
|
159
|
+
theme.fg("success", "Started") +
|
|
160
|
+
` ${theme.fg("accent", `"${process.name}"`)} (${process.id}, PID: ${process.pid})`,
|
|
161
|
+
showCollapsed: true,
|
|
162
|
+
});
|
|
163
|
+
} else if (details.action === "output" && details.output) {
|
|
164
|
+
const lines: string[] = [theme.fg("muted", details.message)];
|
|
147
165
|
let hadAnsi = false;
|
|
148
166
|
|
|
149
167
|
if (details.output.stdout.length > 0) {
|
|
150
|
-
lines.push("");
|
|
151
|
-
|
|
152
|
-
const stdoutLines = details.output.stdout.slice(-20);
|
|
153
|
-
for (const line of stdoutLines) {
|
|
168
|
+
lines.push("", theme.fg("accent", "stdout:"));
|
|
169
|
+
for (const line of details.output.stdout.slice(-20)) {
|
|
154
170
|
if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
|
|
155
171
|
lines.push(stripAnsi(line));
|
|
156
172
|
}
|
|
@@ -165,10 +181,8 @@ Note: User always sees process updates in the UI. The notify flags control wheth
|
|
|
165
181
|
}
|
|
166
182
|
|
|
167
183
|
if (details.output.stderr.length > 0) {
|
|
168
|
-
lines.push("");
|
|
169
|
-
|
|
170
|
-
const stderrLines = details.output.stderr.slice(-10);
|
|
171
|
-
for (const line of stderrLines) {
|
|
184
|
+
lines.push("", theme.fg("warning", "stderr:"));
|
|
185
|
+
for (const line of details.output.stderr.slice(-10)) {
|
|
172
186
|
if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
|
|
173
187
|
lines.push(theme.fg("warning", stripAnsi(line)));
|
|
174
188
|
}
|
|
@@ -183,28 +197,25 @@ Note: User always sees process updates in the UI. The notify flags control wheth
|
|
|
183
197
|
}
|
|
184
198
|
|
|
185
199
|
if (hadAnsi) {
|
|
186
|
-
lines.push("");
|
|
187
200
|
lines.push(
|
|
201
|
+
"",
|
|
188
202
|
theme.fg("muted", "ANSI escape codes were stripped from output"),
|
|
189
203
|
);
|
|
190
204
|
}
|
|
191
205
|
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// For list action
|
|
196
|
-
if (
|
|
206
|
+
fields.push(new Text(lines.join("\n"), 0, 0));
|
|
207
|
+
} else if (
|
|
197
208
|
details.action === "list" &&
|
|
198
209
|
details.processes &&
|
|
199
210
|
details.processes.length > 0
|
|
200
211
|
) {
|
|
201
|
-
const lines: string[] = [
|
|
202
|
-
lines.push(
|
|
212
|
+
const lines: string[] = [
|
|
203
213
|
theme.fg("success", `${details.processes.length} process(es):`),
|
|
204
|
-
|
|
205
|
-
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
for (const process of details.processes) {
|
|
206
217
|
let status: string;
|
|
207
|
-
switch (
|
|
218
|
+
switch (process.status) {
|
|
208
219
|
case "running":
|
|
209
220
|
status = theme.fg("accent", "running");
|
|
210
221
|
break;
|
|
@@ -218,43 +229,52 @@ Note: User always sees process updates in the UI. The notify flags control wheth
|
|
|
218
229
|
status = theme.fg("warning", "killed");
|
|
219
230
|
break;
|
|
220
231
|
case "exited":
|
|
221
|
-
status =
|
|
232
|
+
status = process.success
|
|
222
233
|
? theme.fg("success", "exit(0)")
|
|
223
|
-
: theme.fg("error", `exit(${
|
|
234
|
+
: theme.fg("error", `exit(${process.exitCode ?? "?"})`);
|
|
224
235
|
break;
|
|
225
236
|
default:
|
|
226
|
-
status = theme.fg("muted",
|
|
237
|
+
status = theme.fg("muted", process.status);
|
|
227
238
|
}
|
|
239
|
+
|
|
228
240
|
lines.push(
|
|
229
|
-
` ${
|
|
241
|
+
` ${process.id} ${theme.fg("accent", `"${process.name}"`)}: ${truncateCmd(process.command)} [${status}] ${formatRuntime(process.startTime, process.endTime)}`,
|
|
230
242
|
);
|
|
231
243
|
}
|
|
232
|
-
return new Text(lines.join("\n"), 0, 0);
|
|
233
|
-
}
|
|
234
244
|
|
|
235
|
-
|
|
236
|
-
if (details.action === "logs" && details.logFiles) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
245
|
+
fields.push(new Text(lines.join("\n"), 0, 0));
|
|
246
|
+
} else if (details.action === "logs" && details.logFiles) {
|
|
247
|
+
fields.push(
|
|
248
|
+
new Text(
|
|
249
|
+
[
|
|
250
|
+
theme.fg("success", "Log files:"),
|
|
251
|
+
` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
|
|
252
|
+
` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
|
|
253
|
+
].join("\n"),
|
|
254
|
+
0,
|
|
255
|
+
0,
|
|
256
|
+
),
|
|
241
257
|
);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
258
|
+
} else {
|
|
259
|
+
fields.push({
|
|
260
|
+
label: "Result",
|
|
261
|
+
value: details.message,
|
|
262
|
+
showCollapsed: true,
|
|
263
|
+
});
|
|
246
264
|
}
|
|
247
265
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
266
|
+
const footer = new ToolFooter(theme, {
|
|
267
|
+
items: [
|
|
268
|
+
{ label: "action", value: details.action, tone: "accent" },
|
|
269
|
+
{
|
|
270
|
+
label: "status",
|
|
271
|
+
value: details.success ? "ok" : "error",
|
|
272
|
+
tone: details.success ? "success" : "error",
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
});
|
|
256
276
|
|
|
257
|
-
return new
|
|
277
|
+
return new ToolBody({ fields, footer }, options, theme);
|
|
258
278
|
},
|
|
259
279
|
});
|
|
260
280
|
}
|
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
|
+
}
|
package/utils/format.ts
ADDED
|
@@ -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
|
+
}
|