@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,458 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
matchesKey,
|
|
5
|
+
type TUI,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import { LIVE_STATUSES, type ProcessInfo } from "../constants";
|
|
10
|
+
import type { ProcessManager } from "../manager";
|
|
11
|
+
import { formatRuntime } from "../utils";
|
|
12
|
+
import { LogFileViewer } from "./log-file-viewer";
|
|
13
|
+
|
|
14
|
+
const REFRESH_INTERVAL_MS = 300;
|
|
15
|
+
const OVERLAY_FRACTION = 0.8;
|
|
16
|
+
const MIN_OVERLAY_ROWS = 14;
|
|
17
|
+
const CHROME_ROWS = 6;
|
|
18
|
+
const MIN_LIST_WIDTH = 24;
|
|
19
|
+
const MAX_LIST_WIDTH = 36;
|
|
20
|
+
|
|
21
|
+
type Tone = "accent" | "success" | "warning" | "error" | "dim";
|
|
22
|
+
|
|
23
|
+
function clamp(value: number, min: number, max: number): number {
|
|
24
|
+
return Math.max(min, Math.min(max, value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function statusLabel(process: ProcessInfo): string {
|
|
28
|
+
switch (process.status) {
|
|
29
|
+
case "running":
|
|
30
|
+
return "running";
|
|
31
|
+
case "terminating":
|
|
32
|
+
return "terminating";
|
|
33
|
+
case "terminate_timeout":
|
|
34
|
+
return "needs kill";
|
|
35
|
+
case "killed":
|
|
36
|
+
return "killed";
|
|
37
|
+
case "exited":
|
|
38
|
+
return process.success ? "done" : `exit(${process.exitCode ?? "?"})`;
|
|
39
|
+
default:
|
|
40
|
+
return process.status;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function statusTone(process: ProcessInfo): Tone {
|
|
45
|
+
switch (process.status) {
|
|
46
|
+
case "running":
|
|
47
|
+
return "success";
|
|
48
|
+
case "terminating":
|
|
49
|
+
return "warning";
|
|
50
|
+
case "terminate_timeout":
|
|
51
|
+
return "error";
|
|
52
|
+
case "killed":
|
|
53
|
+
return "warning";
|
|
54
|
+
case "exited":
|
|
55
|
+
return process.success ? "dim" : "error";
|
|
56
|
+
default:
|
|
57
|
+
return "dim";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function statusIcon(process: ProcessInfo): string {
|
|
62
|
+
switch (process.status) {
|
|
63
|
+
case "running":
|
|
64
|
+
case "terminating":
|
|
65
|
+
return "●";
|
|
66
|
+
case "exited":
|
|
67
|
+
return process.success ? "✓" : "✗";
|
|
68
|
+
case "terminate_timeout":
|
|
69
|
+
case "killed":
|
|
70
|
+
return "✗";
|
|
71
|
+
default:
|
|
72
|
+
return "?";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class ProcessesComponent implements Component {
|
|
77
|
+
private processes: ProcessInfo[] = [];
|
|
78
|
+
private selectedIndex = 0;
|
|
79
|
+
private processScrollOffset = 0;
|
|
80
|
+
private viewers: Map<string, LogFileViewer> = new Map();
|
|
81
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
82
|
+
private unsubscribeManager: (() => void) | null = null;
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
private readonly tui: TUI,
|
|
86
|
+
private readonly theme: Theme,
|
|
87
|
+
private readonly done: () => void,
|
|
88
|
+
private readonly manager: ProcessManager,
|
|
89
|
+
) {
|
|
90
|
+
this.syncProcesses(this.manager.list());
|
|
91
|
+
|
|
92
|
+
this.unsubscribeManager = this.manager.onEvent(() => {
|
|
93
|
+
this.syncProcesses(this.manager.list());
|
|
94
|
+
this.tui.requestRender();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
this.timer = setInterval(() => {
|
|
98
|
+
this.tui.requestRender();
|
|
99
|
+
}, REFRESH_INTERVAL_MS);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
handleInput(data: string): boolean {
|
|
103
|
+
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
|
104
|
+
this.close();
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (matchesKey(data, "down")) {
|
|
109
|
+
if (this.processes.length > 0) {
|
|
110
|
+
this.selectedIndex = Math.min(
|
|
111
|
+
this.selectedIndex + 1,
|
|
112
|
+
this.processes.length - 1,
|
|
113
|
+
);
|
|
114
|
+
this.ensureSelectionVisible(this.getBodyRows());
|
|
115
|
+
this.tui.requestRender();
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (matchesKey(data, "up")) {
|
|
121
|
+
if (this.processes.length > 0) {
|
|
122
|
+
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
|
123
|
+
this.ensureSelectionVisible(this.getBodyRows());
|
|
124
|
+
this.tui.requestRender();
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (data === "x") {
|
|
130
|
+
const process = this.currentProcess();
|
|
131
|
+
if (process && LIVE_STATUSES.has(process.status)) {
|
|
132
|
+
const signal =
|
|
133
|
+
process.status === "terminate_timeout" ? "SIGKILL" : "SIGTERM";
|
|
134
|
+
const timeoutMs = signal === "SIGKILL" ? 200 : 3000;
|
|
135
|
+
void this.manager.kill(process.id, {
|
|
136
|
+
signal,
|
|
137
|
+
timeoutMs,
|
|
138
|
+
notifyOnEnd: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (data === "c" || data === "C") {
|
|
145
|
+
this.manager.clearFinished();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const viewer = this.currentViewer();
|
|
150
|
+
if (!viewer) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (matchesKey(data, "left")) {
|
|
155
|
+
viewer.scrollBy(5);
|
|
156
|
+
this.tui.requestRender();
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (matchesKey(data, "right")) {
|
|
161
|
+
viewer.scrollBy(-5);
|
|
162
|
+
this.tui.requestRender();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (data === "g") {
|
|
167
|
+
viewer.scrollToTop();
|
|
168
|
+
this.tui.requestRender();
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (data === "G") {
|
|
173
|
+
if (!viewer.isFollowing()) {
|
|
174
|
+
viewer.toggleFollow();
|
|
175
|
+
}
|
|
176
|
+
this.tui.requestRender();
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
invalidate(): void {
|
|
184
|
+
// Stateless rendering.
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
render(width: number): string[] {
|
|
188
|
+
const overlayRows = Math.max(
|
|
189
|
+
MIN_OVERLAY_ROWS,
|
|
190
|
+
Math.floor((this.tui.terminal.rows ?? 24) * OVERLAY_FRACTION),
|
|
191
|
+
);
|
|
192
|
+
const bodyRows = Math.max(4, overlayRows - CHROME_ROWS);
|
|
193
|
+
this.ensureSelectionVisible(bodyRows);
|
|
194
|
+
|
|
195
|
+
const border = (text: string) => this.theme.fg("dim", text);
|
|
196
|
+
const accent = (text: string) => this.theme.fg("accent", text);
|
|
197
|
+
|
|
198
|
+
const innerWidth = width - 4;
|
|
199
|
+
const listWidth = clamp(
|
|
200
|
+
Math.floor(innerWidth * 0.32),
|
|
201
|
+
MIN_LIST_WIDTH,
|
|
202
|
+
Math.min(MAX_LIST_WIDTH, innerWidth - 20),
|
|
203
|
+
);
|
|
204
|
+
const separator = border(" │ ");
|
|
205
|
+
const separatorWidth = visibleWidth(separator);
|
|
206
|
+
const logWidth = Math.max(10, innerWidth - listWidth - separatorWidth);
|
|
207
|
+
|
|
208
|
+
const row = (content: string): string => {
|
|
209
|
+
const contentWidth = visibleWidth(content);
|
|
210
|
+
const safe =
|
|
211
|
+
contentWidth > innerWidth
|
|
212
|
+
? truncateToWidth(content, innerWidth)
|
|
213
|
+
: content;
|
|
214
|
+
return `${border("│ ")}${safe}${" ".repeat(Math.max(0, innerWidth - visibleWidth(safe)))}${border(" │")}`;
|
|
215
|
+
};
|
|
216
|
+
const divider = () => border(`├${"─".repeat(width - 2)}┤`);
|
|
217
|
+
const paneRow = (left: string, right: string): string => {
|
|
218
|
+
const paddedLeft = this.padVisible(left, listWidth);
|
|
219
|
+
const paddedRight = this.padVisible(right, logWidth);
|
|
220
|
+
return row(`${paddedLeft}${separator}${paddedRight}`);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const lines: string[] = [];
|
|
224
|
+
const title = " Processes ";
|
|
225
|
+
const titleWidth = visibleWidth(title);
|
|
226
|
+
const sideWidth = Math.max(0, width - 2 - titleWidth);
|
|
227
|
+
const leftDash = Math.floor(sideWidth / 2);
|
|
228
|
+
const rightDash = sideWidth - leftDash;
|
|
229
|
+
lines.push(
|
|
230
|
+
border(`╭${"─".repeat(leftDash)}`) +
|
|
231
|
+
accent(title) +
|
|
232
|
+
border(`${"─".repeat(rightDash)}╮`),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
lines.push(divider());
|
|
236
|
+
|
|
237
|
+
const selected = this.currentProcess();
|
|
238
|
+
const viewer = this.currentViewer();
|
|
239
|
+
const leftRows = this.renderProcessRows(listWidth, bodyRows);
|
|
240
|
+
const rightRows = this.renderLogRows(logWidth, bodyRows, selected, viewer);
|
|
241
|
+
|
|
242
|
+
for (let i = 0; i < bodyRows; i++) {
|
|
243
|
+
lines.push(paneRow(leftRows[i] ?? "", rightRows[i] ?? ""));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
lines.push(divider());
|
|
247
|
+
lines.push(row(this.renderStatusLine(innerWidth, selected, viewer)));
|
|
248
|
+
lines.push(row(this.renderFooter(innerWidth)));
|
|
249
|
+
lines.push(border(`╰${"─".repeat(width - 2)}╯`));
|
|
250
|
+
return lines;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private close(): void {
|
|
254
|
+
if (this.timer) {
|
|
255
|
+
clearInterval(this.timer);
|
|
256
|
+
this.timer = null;
|
|
257
|
+
}
|
|
258
|
+
this.unsubscribeManager?.();
|
|
259
|
+
this.unsubscribeManager = null;
|
|
260
|
+
this.done();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private sortProcesses(processes: ProcessInfo[]): ProcessInfo[] {
|
|
264
|
+
return [...processes].sort((a, b) => {
|
|
265
|
+
const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
|
|
266
|
+
const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
|
|
267
|
+
if (bLive !== aLive) return bLive - aLive;
|
|
268
|
+
return b.startTime - a.startTime;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private syncProcesses(next: ProcessInfo[]): void {
|
|
273
|
+
const currentId = this.currentProcess()?.id ?? null;
|
|
274
|
+
this.processes = this.sortProcesses(next);
|
|
275
|
+
|
|
276
|
+
if (this.processes.length === 0) {
|
|
277
|
+
this.selectedIndex = 0;
|
|
278
|
+
this.processScrollOffset = 0;
|
|
279
|
+
this.viewers.clear();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (currentId) {
|
|
284
|
+
const nextIndex = this.processes.findIndex(
|
|
285
|
+
(process) => process.id === currentId,
|
|
286
|
+
);
|
|
287
|
+
this.selectedIndex = nextIndex >= 0 ? nextIndex : 0;
|
|
288
|
+
} else {
|
|
289
|
+
this.selectedIndex = clamp(
|
|
290
|
+
this.selectedIndex,
|
|
291
|
+
0,
|
|
292
|
+
this.processes.length - 1,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const activeIds = new Set(this.processes.map((process) => process.id));
|
|
297
|
+
for (const id of this.viewers.keys()) {
|
|
298
|
+
if (!activeIds.has(id)) {
|
|
299
|
+
this.viewers.delete(id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private currentProcess(): ProcessInfo | null {
|
|
305
|
+
return this.processes[this.selectedIndex] ?? null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private currentViewer(): LogFileViewer | null {
|
|
309
|
+
const process = this.currentProcess();
|
|
310
|
+
if (!process) return null;
|
|
311
|
+
|
|
312
|
+
let viewer = this.viewers.get(process.id);
|
|
313
|
+
if (!viewer) {
|
|
314
|
+
const logFiles = this.manager.getLogFiles(process.id);
|
|
315
|
+
if (!logFiles) return null;
|
|
316
|
+
viewer = new LogFileViewer({
|
|
317
|
+
filePath: logFiles.combinedFile,
|
|
318
|
+
format: "combined",
|
|
319
|
+
theme: this.theme,
|
|
320
|
+
follow: true,
|
|
321
|
+
});
|
|
322
|
+
this.viewers.set(process.id, viewer);
|
|
323
|
+
}
|
|
324
|
+
return viewer;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private getBodyRows(): number {
|
|
328
|
+
const overlayRows = Math.max(
|
|
329
|
+
MIN_OVERLAY_ROWS,
|
|
330
|
+
Math.floor((this.tui.terminal.rows ?? 24) * OVERLAY_FRACTION),
|
|
331
|
+
);
|
|
332
|
+
return Math.max(4, overlayRows - CHROME_ROWS);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private ensureSelectionVisible(bodyRows: number): void {
|
|
336
|
+
if (this.processes.length === 0) {
|
|
337
|
+
this.processScrollOffset = 0;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (this.selectedIndex < this.processScrollOffset) {
|
|
342
|
+
this.processScrollOffset = this.selectedIndex;
|
|
343
|
+
} else if (this.selectedIndex >= this.processScrollOffset + bodyRows) {
|
|
344
|
+
this.processScrollOffset = this.selectedIndex - bodyRows + 1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
this.processScrollOffset = clamp(
|
|
348
|
+
this.processScrollOffset,
|
|
349
|
+
0,
|
|
350
|
+
Math.max(0, this.processes.length - bodyRows),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private renderProcessRows(width: number, rows: number): string[] {
|
|
355
|
+
const dim = (text: string) => this.theme.fg("dim", text);
|
|
356
|
+
const accent = (text: string) => this.theme.fg("accent", text);
|
|
357
|
+
|
|
358
|
+
if (this.processes.length === 0) {
|
|
359
|
+
return [accent("No processes")];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const rendered: string[] = [];
|
|
363
|
+
const end = Math.min(
|
|
364
|
+
this.processes.length,
|
|
365
|
+
this.processScrollOffset + rows,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
for (let index = this.processScrollOffset; index < end; index++) {
|
|
369
|
+
const process = this.processes[index];
|
|
370
|
+
if (!process) continue;
|
|
371
|
+
const selected = index === this.selectedIndex;
|
|
372
|
+
const color = statusTone(process);
|
|
373
|
+
const icon = this.theme.fg(color, statusIcon(process));
|
|
374
|
+
const prefix = selected ? accent(">") : dim(" ");
|
|
375
|
+
const processId = dim(process.id);
|
|
376
|
+
const status = this.theme.fg(color, statusLabel(process));
|
|
377
|
+
const reservedWidth =
|
|
378
|
+
visibleWidth(prefix) +
|
|
379
|
+
1 +
|
|
380
|
+
visibleWidth(icon) +
|
|
381
|
+
1 +
|
|
382
|
+
1 +
|
|
383
|
+
visibleWidth(processId) +
|
|
384
|
+
1 +
|
|
385
|
+
visibleWidth(status);
|
|
386
|
+
const nameWidth = Math.max(6, width - reservedWidth);
|
|
387
|
+
const name = truncateToWidth(process.name, nameWidth);
|
|
388
|
+
const line = `${prefix} ${icon} ${selected ? accent(name) : name} ${processId} ${status}`;
|
|
389
|
+
rendered.push(this.padVisible(line, width));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
while (rendered.length < rows) {
|
|
393
|
+
rendered.push(" ".repeat(width));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return rendered;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private renderLogRows(
|
|
400
|
+
width: number,
|
|
401
|
+
rows: number,
|
|
402
|
+
selected: ProcessInfo | null,
|
|
403
|
+
viewer: LogFileViewer | null,
|
|
404
|
+
): string[] {
|
|
405
|
+
const dim = (text: string) => this.theme.fg("dim", text);
|
|
406
|
+
|
|
407
|
+
if (!selected) {
|
|
408
|
+
return [dim("Start a process with the process tool to see logs here.")];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!viewer) {
|
|
412
|
+
return [dim("Log file unavailable.")];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const lines = viewer.renderLines(width, rows);
|
|
416
|
+
while (lines.length < rows) {
|
|
417
|
+
lines.push("");
|
|
418
|
+
}
|
|
419
|
+
return lines;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private renderStatusLine(
|
|
423
|
+
width: number,
|
|
424
|
+
selected: ProcessInfo | null,
|
|
425
|
+
viewer: LogFileViewer | null,
|
|
426
|
+
): string {
|
|
427
|
+
const dim = (text: string) => this.theme.fg("dim", text);
|
|
428
|
+
const accent = (text: string) => this.theme.fg("accent", text);
|
|
429
|
+
|
|
430
|
+
if (!selected || !viewer) {
|
|
431
|
+
return this.padVisible(dim("No processes"), width);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const meta = `${accent(selected.name)}${dim(" • ")}${dim(statusLabel(selected))}${dim(" • ")}${dim(formatRuntime(selected.startTime, selected.endTime))}`;
|
|
435
|
+
const viewerStatus = viewer.renderStatusBar(
|
|
436
|
+
Math.max(16, width - visibleWidth(meta) - 3),
|
|
437
|
+
);
|
|
438
|
+
const combined = `${meta}${dim(" | ")}${viewerStatus}`;
|
|
439
|
+
return this.padVisible(combined, width);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private renderFooter(width: number): string {
|
|
443
|
+
const dim = (text: string) => this.theme.fg("dim", text);
|
|
444
|
+
const footer =
|
|
445
|
+
`${dim("up/down")} select ` +
|
|
446
|
+
`${dim("left/right")} scroll ` +
|
|
447
|
+
`${dim("g/G")} top/live ` +
|
|
448
|
+
`${dim("x")} kill ` +
|
|
449
|
+
`${dim("c")} clear ` +
|
|
450
|
+
`${dim("q")} close`;
|
|
451
|
+
return this.padVisible(footer, width);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private padVisible(text: string, width: number): string {
|
|
455
|
+
const safe = truncateToWidth(text, width, "");
|
|
456
|
+
return safe + " ".repeat(Math.max(0, width - visibleWidth(safe)));
|
|
457
|
+
}
|
|
458
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime configuration for the processes extension.
|
|
3
|
+
*
|
|
4
|
+
* Global: ~/.pi/agent/extensions/process.json
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
export interface ProcessesConfig {
|
|
12
|
+
output?: {
|
|
13
|
+
/** Default number of tail lines returned to the agent. */
|
|
14
|
+
defaultTailLines?: number;
|
|
15
|
+
/** Hard cap on output lines returned to the agent. */
|
|
16
|
+
maxOutputLines?: number;
|
|
17
|
+
};
|
|
18
|
+
execution?: {
|
|
19
|
+
/** Absolute shell path override. Leave unset to auto-resolve. */
|
|
20
|
+
shellPath?: string;
|
|
21
|
+
};
|
|
22
|
+
interception?: {
|
|
23
|
+
/** Block shell backgrounding and obvious long-running bash commands, and guide the model to use the process tool. */
|
|
24
|
+
blockBackgroundCommands?: boolean;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ResolvedProcessesConfig {
|
|
29
|
+
output: {
|
|
30
|
+
defaultTailLines: number;
|
|
31
|
+
maxOutputLines: number;
|
|
32
|
+
};
|
|
33
|
+
execution: {
|
|
34
|
+
shellPath?: string;
|
|
35
|
+
};
|
|
36
|
+
interception: {
|
|
37
|
+
blockBackgroundCommands: boolean;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_CONFIG: ResolvedProcessesConfig = {
|
|
42
|
+
output: {
|
|
43
|
+
defaultTailLines: 100,
|
|
44
|
+
maxOutputLines: 200,
|
|
45
|
+
},
|
|
46
|
+
execution: {},
|
|
47
|
+
interception: {
|
|
48
|
+
blockBackgroundCommands: true,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
class ProcessesConfigLoader {
|
|
53
|
+
private resolved: ResolvedProcessesConfig | null = null;
|
|
54
|
+
|
|
55
|
+
async load(): Promise<void> {
|
|
56
|
+
const rawConfig = await readGlobalConfig();
|
|
57
|
+
this.resolved = resolveConfig(rawConfig);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getConfig(): ResolvedProcessesConfig {
|
|
61
|
+
if (!this.resolved) {
|
|
62
|
+
throw new Error("Config not loaded. Call load() first.");
|
|
63
|
+
}
|
|
64
|
+
return this.resolved;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readGlobalConfig(): Promise<ProcessesConfig | null> {
|
|
69
|
+
const path = resolve(getAgentDir(), "extensions/process.json");
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const content = await readFile(path, "utf-8");
|
|
73
|
+
const parsed: unknown = JSON.parse(content);
|
|
74
|
+
if (!isRecord(parsed)) return null;
|
|
75
|
+
|
|
76
|
+
const { $schema: _schema, ...config } = parsed;
|
|
77
|
+
return config as ProcessesConfig;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveConfig(
|
|
84
|
+
config: ProcessesConfig | null,
|
|
85
|
+
): ResolvedProcessesConfig {
|
|
86
|
+
return {
|
|
87
|
+
output: {
|
|
88
|
+
defaultTailLines: numberOrDefault(
|
|
89
|
+
config?.output?.defaultTailLines,
|
|
90
|
+
DEFAULT_CONFIG.output.defaultTailLines,
|
|
91
|
+
),
|
|
92
|
+
maxOutputLines: numberOrDefault(
|
|
93
|
+
config?.output?.maxOutputLines,
|
|
94
|
+
DEFAULT_CONFIG.output.maxOutputLines,
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
execution: {
|
|
98
|
+
shellPath: stringOrUndefined(config?.execution?.shellPath),
|
|
99
|
+
},
|
|
100
|
+
interception: {
|
|
101
|
+
blockBackgroundCommands: booleanOrDefault(
|
|
102
|
+
config?.interception?.blockBackgroundCommands,
|
|
103
|
+
DEFAULT_CONFIG.interception.blockBackgroundCommands,
|
|
104
|
+
),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function numberOrDefault(value: unknown, fallback: number): number {
|
|
110
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stringOrUndefined(value: unknown): string | undefined {
|
|
114
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function booleanOrDefault(value: unknown, fallback: boolean): boolean {
|
|
118
|
+
return typeof value === "boolean" ? value : fallback;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const configLoader = new ProcessesConfigLoader();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Custom message type for process update notifications
|
|
2
|
+
export const MESSAGE_TYPE_PROCESS_UPDATE = "pi-processes: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
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ManagerEvent =
|
|
33
|
+
| { type: "process_started"; info: ProcessInfo }
|
|
34
|
+
| { type: "process_ended"; info: ProcessInfo; triggerAgentTurn: boolean }
|
|
35
|
+
| { type: "processes_changed" };
|
|
36
|
+
|
|
37
|
+
export type KillResult =
|
|
38
|
+
| { ok: true; info: ProcessInfo }
|
|
39
|
+
| { ok: false; info: ProcessInfo; reason: "not_found" | "timeout" | "error" };
|
|
40
|
+
|
|
41
|
+
export type ResolveProcessResult =
|
|
42
|
+
| { ok: true; info: ProcessInfo }
|
|
43
|
+
| { ok: false; reason: "not_found" | "ambiguous"; matches?: ProcessInfo[] };
|
|
44
|
+
|
|
45
|
+
export interface ProcessesDetails {
|
|
46
|
+
action: string;
|
|
47
|
+
success: boolean;
|
|
48
|
+
message: string;
|
|
49
|
+
process?: ProcessInfo;
|
|
50
|
+
processes?: ProcessInfo[];
|
|
51
|
+
output?: { stdout: string[]; stderr: string[]; status: string };
|
|
52
|
+
logFiles?: { stdoutFile: string; stderrFile: string; combinedFile: string };
|
|
53
|
+
cleared?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ExecuteResult {
|
|
57
|
+
content: Array<{ type: "text"; text: string }>;
|
|
58
|
+
details: ProcessesDetails;
|
|
59
|
+
/**
|
|
60
|
+
* Hint to Pi's agent loop to stop after this tool batch. Used by process
|
|
61
|
+
* start so the model actually waits for the lifecycle notification instead
|
|
62
|
+
* of immediately continuing into list/output polling.
|
|
63
|
+
*/
|
|
64
|
+
terminate?: boolean;
|
|
65
|
+
}
|