@aliou/pi-processes 0.3.4 → 0.4.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/commands/index.ts +293 -16
- package/commands/settings-command.ts +128 -0
- package/components/log-stream-component.ts +147 -0
- package/components/process-picker-component.ts +166 -0
- package/components/processes-component.ts +459 -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 +10 -4
- package/manager.ts +41 -0
- package/package.json +6 -2
- package/tools/actions/kill.ts +1 -1
- package/tools/actions/output.ts +8 -5
- package/tools/index.ts +25 -8
- 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
package/config.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the processes extension.
|
|
3
|
+
*
|
|
4
|
+
* Global: ~/.pi/agent/extensions/processes.json
|
|
5
|
+
* Memory: ephemeral overrides via /process:settings
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ConfigLoader } from "@aliou/pi-utils-settings";
|
|
9
|
+
|
|
10
|
+
export interface ProcessesConfig {
|
|
11
|
+
processList?: {
|
|
12
|
+
/** Max visible processes in the /process:list TUI list. */
|
|
13
|
+
maxVisibleProcesses?: number;
|
|
14
|
+
/** Max log preview lines shown below the selected process. */
|
|
15
|
+
maxPreviewLines?: number;
|
|
16
|
+
};
|
|
17
|
+
output?: {
|
|
18
|
+
/** Default number of tail lines returned to the agent. */
|
|
19
|
+
defaultTailLines?: number;
|
|
20
|
+
/** Hard cap on output lines returned to the agent. */
|
|
21
|
+
maxOutputLines?: number;
|
|
22
|
+
};
|
|
23
|
+
widget?: {
|
|
24
|
+
/** Show the status widget below the editor. */
|
|
25
|
+
showStatusWidget?: boolean;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolvedProcessesConfig {
|
|
30
|
+
processList: {
|
|
31
|
+
maxVisibleProcesses: number;
|
|
32
|
+
maxPreviewLines: number;
|
|
33
|
+
};
|
|
34
|
+
output: {
|
|
35
|
+
defaultTailLines: number;
|
|
36
|
+
maxOutputLines: number;
|
|
37
|
+
};
|
|
38
|
+
widget: {
|
|
39
|
+
showStatusWidget: boolean;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_CONFIG: ResolvedProcessesConfig = {
|
|
44
|
+
processList: {
|
|
45
|
+
maxVisibleProcesses: 8,
|
|
46
|
+
maxPreviewLines: 12,
|
|
47
|
+
},
|
|
48
|
+
output: {
|
|
49
|
+
defaultTailLines: 100,
|
|
50
|
+
maxOutputLines: 200,
|
|
51
|
+
},
|
|
52
|
+
widget: {
|
|
53
|
+
showStatusWidget: true,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const configLoader = new ConfigLoader<
|
|
58
|
+
ProcessesConfig,
|
|
59
|
+
ResolvedProcessesConfig
|
|
60
|
+
>("process", DEFAULT_CONFIG, {
|
|
61
|
+
scopes: ["global", "memory"],
|
|
62
|
+
});
|
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
+
const { update: updateWidget } = setupProcessesHooks(pi, manager);
|
|
22
|
+
const commands = setupProcessesCommands(pi, manager);
|
|
23
|
+
setupProcessesTools(pi, manager, commands);
|
|
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.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": [
|
|
@@ -25,12 +25,16 @@
|
|
|
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.2.1"
|
|
34
38
|
},
|
|
35
39
|
"peerDependencies": {
|
|
36
40
|
"@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
|
@@ -57,10 +57,16 @@ const ProcessesParams = Type.Object({
|
|
|
57
57
|
|
|
58
58
|
type ProcessesParamsType = Static<typeof ProcessesParams>;
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
import type { ProcessCommands } from "../commands";
|
|
61
|
+
|
|
62
|
+
export function setupProcessesTools(
|
|
63
|
+
pi: ExtensionAPI,
|
|
64
|
+
manager: ProcessManager,
|
|
65
|
+
commands: ProcessCommands,
|
|
66
|
+
) {
|
|
61
67
|
pi.registerTool<typeof ProcessesParams, ProcessesDetails>({
|
|
62
|
-
name: "
|
|
63
|
-
label: "
|
|
68
|
+
name: "process",
|
|
69
|
+
label: "Process",
|
|
64
70
|
description: `Manage background processes. Actions:
|
|
65
71
|
- start: Run command in background (requires 'name' and 'command')
|
|
66
72
|
- alertOnSuccess (default: false): Get a turn to react when process completes successfully
|
|
@@ -79,22 +85,33 @@ Note: User always sees process updates in the UI. The notify flags control wheth
|
|
|
79
85
|
parameters: ProcessesParams,
|
|
80
86
|
|
|
81
87
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
82
|
-
|
|
88
|
+
const result = await executeAction(params, manager, ctx);
|
|
89
|
+
// Auto-stream logs when a process is started successfully.
|
|
90
|
+
if (
|
|
91
|
+
params.action === "start" &&
|
|
92
|
+
result.details?.success &&
|
|
93
|
+
result.details.process &&
|
|
94
|
+
ctx.hasUI
|
|
95
|
+
) {
|
|
96
|
+
commands.streamProcess(result.details.process.id, ctx.ui);
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
83
99
|
},
|
|
84
100
|
|
|
85
101
|
renderCall(args: ProcessesParamsType, theme: Theme): Text {
|
|
86
|
-
let text = theme.fg("toolTitle", theme.bold("
|
|
102
|
+
let text = theme.fg("toolTitle", theme.bold("Process "));
|
|
87
103
|
text += theme.fg("accent", args.action);
|
|
88
104
|
|
|
89
105
|
switch (args.action) {
|
|
90
|
-
case "start":
|
|
106
|
+
case "start": {
|
|
91
107
|
if (args.name) {
|
|
92
108
|
text += ` ${theme.fg("accent", `"${args.name}"`)}`;
|
|
93
109
|
}
|
|
94
110
|
if (args.command) {
|
|
95
|
-
text +=
|
|
111
|
+
text += `\n${theme.fg("muted", `$ ${args.command}`)}`;
|
|
96
112
|
}
|
|
97
|
-
|
|
113
|
+
return new Text(text, 0, 0);
|
|
114
|
+
}
|
|
98
115
|
case "output":
|
|
99
116
|
case "kill":
|
|
100
117
|
case "logs":
|
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
|
+
}
|
package/utils/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a process group is still alive.
|
|
3
|
+
* Uses signal 0 to test existence without actually sending a signal.
|
|
4
|
+
*/
|
|
5
|
+
export function isProcessGroupAlive(pgid: number): boolean {
|
|
6
|
+
try {
|
|
7
|
+
process.kill(-pgid, 0);
|
|
8
|
+
return true;
|
|
9
|
+
} catch (error) {
|
|
10
|
+
const err = error as NodeJS.ErrnoException;
|
|
11
|
+
// EPERM: exists, but we can't signal it
|
|
12
|
+
return err.code === "EPERM";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Send a signal to an entire process group.
|
|
18
|
+
* Negative PID targets the process group.
|
|
19
|
+
*/
|
|
20
|
+
export function killProcessGroup(pgid: number, signal: NodeJS.Signals): void {
|
|
21
|
+
process.kill(-pgid, signal);
|
|
22
|
+
}
|