@jtsang/pi-extensions 0.1.0 → 0.1.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/README.md +11 -4
- package/extensions/monitor.ts +251 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -18,8 +18,15 @@ keeping the model running between events.
|
|
|
18
18
|
- `pi_background_monitor` starts a command in the session working directory.
|
|
19
19
|
Non-persistent monitors time out after five minutes by default; persistent
|
|
20
20
|
monitors run until stopped or the session ends.
|
|
21
|
+
- `pi_background_monitor_list` lists every active monitor with its ID, command,
|
|
22
|
+
working directory, elapsed time, and timeout state.
|
|
21
23
|
- `pi_background_monitor_stop` stops one monitor by ID, or all monitors when no
|
|
22
24
|
ID is provided.
|
|
25
|
+
- Monitors run concurrently with no extension-level limit; operating-system
|
|
26
|
+
process and resource limits still apply. In the TUI, the active count appears
|
|
27
|
+
below the default editor: press Down when the editor cannot move farther,
|
|
28
|
+
then Enter to open details or Up/Escape to return. `/monitors` shows the same
|
|
29
|
+
details without invoking the model.
|
|
23
30
|
- Output from stdout and stderr is batched for 200 ms, stripped of terminal
|
|
24
31
|
control sequences, limited to 16 KB per event, and delivered to the current
|
|
25
32
|
conversation as untrusted data. An idle agent starts a turn immediately; a
|
|
@@ -61,7 +68,8 @@ pnpm check
|
|
|
61
68
|
```
|
|
62
69
|
|
|
63
70
|
Pi loads TypeScript directly through jiti, so this package intentionally has no
|
|
64
|
-
build
|
|
71
|
+
build artifact. `pnpm verify` runs tests, TypeScript checking, and the publish
|
|
72
|
+
package inspection; `prepublishOnly` runs it automatically before publishing.
|
|
65
73
|
|
|
66
74
|
Small extensions belong in `extensions/<name>.ts`. Multi-file extensions use
|
|
67
75
|
`extensions/<name>/index.ts`. Keep shared code in `lib/` only after real reuse
|
|
@@ -70,9 +78,8 @@ appears.
|
|
|
70
78
|
Before publishing:
|
|
71
79
|
|
|
72
80
|
```sh
|
|
73
|
-
pnpm
|
|
74
|
-
pnpm
|
|
75
|
-
pnpm publish
|
|
81
|
+
pnpm verify
|
|
82
|
+
pnpm publish # runs pnpm verify again via prepublishOnly
|
|
76
83
|
```
|
|
77
84
|
|
|
78
85
|
## License
|
package/extensions/monitor.ts
CHANGED
|
@@ -2,12 +2,25 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { StringDecoder } from "node:string_decoder";
|
|
3
3
|
import { stripVTControlCharacters } from "node:util";
|
|
4
4
|
import {
|
|
5
|
+
CustomEditor,
|
|
5
6
|
createLocalBashOperations,
|
|
6
7
|
formatSize,
|
|
8
|
+
keyHint,
|
|
9
|
+
truncateHead,
|
|
7
10
|
truncateTail,
|
|
8
11
|
type ExtensionAPI,
|
|
9
12
|
type ExtensionContext,
|
|
13
|
+
type KeybindingsManager,
|
|
14
|
+
type Theme,
|
|
10
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
Text,
|
|
18
|
+
truncateToWidth,
|
|
19
|
+
type Component,
|
|
20
|
+
type EditorComponent,
|
|
21
|
+
type Focusable,
|
|
22
|
+
type TUI,
|
|
23
|
+
} from "@earendil-works/pi-tui";
|
|
11
24
|
import { Type } from "typebox";
|
|
12
25
|
|
|
13
26
|
const BATCH_DELAY_MS = 200;
|
|
@@ -17,11 +30,17 @@ const MAX_EVENT_BYTES = 16 * 1024;
|
|
|
17
30
|
const MAX_QUEUED_BYTES = MAX_EVENT_BYTES * 2;
|
|
18
31
|
const MAX_QUEUED_LINES = 200;
|
|
19
32
|
const STATUS_KEY = "jtsang4-background-monitors";
|
|
33
|
+
const WIDGET_KEY = "jtsang4-background-monitor-navigator";
|
|
20
34
|
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u000d\u000e-\u001f\u007f]/g;
|
|
21
35
|
|
|
22
36
|
type RunningMonitor = {
|
|
23
37
|
id: string;
|
|
24
38
|
description: string;
|
|
39
|
+
command: string;
|
|
40
|
+
cwd: string;
|
|
41
|
+
persistent: boolean;
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
startedAt: number;
|
|
25
44
|
abort: AbortController;
|
|
26
45
|
decoder: StringDecoder;
|
|
27
46
|
partialLine: string;
|
|
@@ -35,6 +54,64 @@ type RunningMonitor = {
|
|
|
35
54
|
done: Promise<void>;
|
|
36
55
|
};
|
|
37
56
|
|
|
57
|
+
class MonitorStatusWidget implements Component, Focusable {
|
|
58
|
+
focused = false;
|
|
59
|
+
private editor?: EditorComponent;
|
|
60
|
+
private keybindings?: KeybindingsManager;
|
|
61
|
+
private readonly tui: TUI;
|
|
62
|
+
private readonly theme: Theme;
|
|
63
|
+
private readonly getCount: () => number;
|
|
64
|
+
private readonly openDetails: () => void;
|
|
65
|
+
|
|
66
|
+
constructor(tui: TUI, theme: Theme, getCount: () => number, openDetails: () => void) {
|
|
67
|
+
this.tui = tui;
|
|
68
|
+
this.theme = theme;
|
|
69
|
+
this.getCount = getCount;
|
|
70
|
+
this.openDetails = openDetails;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
connectEditor(editor: EditorComponent, keybindings: KeybindingsManager): void {
|
|
74
|
+
this.editor = editor;
|
|
75
|
+
this.keybindings = keybindings;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
focus(): void {
|
|
79
|
+
this.tui.setFocus(this);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
focusEditor(): void {
|
|
83
|
+
if (this.editor) this.tui.setFocus(this.editor);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
refresh(): void {
|
|
87
|
+
if (this.getCount() === 0 && this.focused) this.focusEditor();
|
|
88
|
+
this.tui.requestRender();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
handleInput(data: string): void {
|
|
92
|
+
if (this.keybindings?.matches(data, "tui.select.confirm")) {
|
|
93
|
+
this.openDetails();
|
|
94
|
+
} else if (
|
|
95
|
+
this.keybindings?.matches(data, "tui.select.up") ||
|
|
96
|
+
this.keybindings?.matches(data, "tui.select.cancel")
|
|
97
|
+
) {
|
|
98
|
+
this.focusEditor();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
invalidate(): void {}
|
|
103
|
+
|
|
104
|
+
render(width: number): string[] {
|
|
105
|
+
const count = this.getCount();
|
|
106
|
+
if (count === 0) return [];
|
|
107
|
+
const label = `${this.focused ? "›" : " "} ${count} monitor${count === 1 ? "" : "s"}`;
|
|
108
|
+
const text = this.focused
|
|
109
|
+
? this.theme.bg("selectedBg", this.theme.fg("accent", label))
|
|
110
|
+
: this.theme.fg("dim", label);
|
|
111
|
+
return [truncateToWidth(text, width)];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
38
115
|
function sanitizeLine(line: string): string {
|
|
39
116
|
return stripVTControlCharacters(line).replace(CONTROL_CHARACTERS, "");
|
|
40
117
|
}
|
|
@@ -43,16 +120,154 @@ function escapeXml(text: string): string {
|
|
|
43
120
|
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
44
121
|
}
|
|
45
122
|
|
|
123
|
+
function getTextContent(content: unknown): string {
|
|
124
|
+
if (typeof content === "string") return content;
|
|
125
|
+
if (!Array.isArray(content)) return "";
|
|
126
|
+
return content
|
|
127
|
+
.map((part) =>
|
|
128
|
+
part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part
|
|
129
|
+
? String(part.text)
|
|
130
|
+
: "",
|
|
131
|
+
)
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
46
136
|
export default function monitorExtension(pi: ExtensionAPI) {
|
|
47
137
|
const shell = createLocalBashOperations();
|
|
48
138
|
const monitors = new Map<string, RunningMonitor>();
|
|
49
139
|
let currentContext: ExtensionContext | undefined;
|
|
140
|
+
let monitorWidget: MonitorStatusWidget | undefined;
|
|
50
141
|
let shuttingDown = false;
|
|
51
142
|
|
|
52
143
|
function updateStatus(): void {
|
|
53
144
|
if (!currentContext?.hasUI) return;
|
|
54
145
|
const count = monitors.size;
|
|
55
|
-
currentContext.ui.setStatus(
|
|
146
|
+
currentContext.ui.setStatus(
|
|
147
|
+
STATUS_KEY,
|
|
148
|
+
currentContext.mode === "tui" || count === 0 ? undefined : `${count} monitor${count === 1 ? "" : "s"}`,
|
|
149
|
+
);
|
|
150
|
+
monitorWidget?.refresh();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getMonitorDetails() {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
return [...monitors.values()].map((monitor) => ({
|
|
156
|
+
id: monitor.id,
|
|
157
|
+
description: monitor.description,
|
|
158
|
+
command: monitor.command,
|
|
159
|
+
cwd: monitor.cwd,
|
|
160
|
+
state: monitor.stopping ? "stopping" : "running",
|
|
161
|
+
persistent: monitor.persistent,
|
|
162
|
+
timeoutMs: monitor.timeoutMs,
|
|
163
|
+
startedAt: monitor.startedAt,
|
|
164
|
+
elapsedMs: now - monitor.startedAt,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatMonitorList(): string {
|
|
169
|
+
const active = getMonitorDetails();
|
|
170
|
+
const lines = [`Active monitors: ${active.length} (no extension limit; system resource limits apply)`];
|
|
171
|
+
for (const monitor of active) {
|
|
172
|
+
lines.push(
|
|
173
|
+
`- ${monitor.id} · ${monitor.state} · elapsed=${(monitor.elapsedMs / 1000).toFixed(1)}s · ${monitor.persistent ? "persistent" : `timeout=${monitor.timeoutMs}ms`}`,
|
|
174
|
+
` description=${JSON.stringify(monitor.description)}`,
|
|
175
|
+
` cwd=${JSON.stringify(monitor.cwd)}`,
|
|
176
|
+
` command=${JSON.stringify(monitor.command)}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const truncated = truncateHead(lines.join("\n"), {
|
|
181
|
+
maxLines: MAX_QUEUED_LINES,
|
|
182
|
+
maxBytes: MAX_EVENT_BYTES - 64,
|
|
183
|
+
});
|
|
184
|
+
return truncated.truncated
|
|
185
|
+
? `${truncated.content}\n[monitor list truncated to ${formatSize(MAX_EVENT_BYTES)}]`
|
|
186
|
+
: truncated.content;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function showMonitorDetails(ctx: ExtensionContext): Promise<void> {
|
|
190
|
+
await ctx.ui.custom<void>(
|
|
191
|
+
(_tui, theme, keybindings, done) => {
|
|
192
|
+
const text = new Text("", 1, 1);
|
|
193
|
+
return {
|
|
194
|
+
handleInput(data: string) {
|
|
195
|
+
if (
|
|
196
|
+
keybindings.matches(data, "tui.select.confirm") ||
|
|
197
|
+
keybindings.matches(data, "tui.select.cancel")
|
|
198
|
+
) {
|
|
199
|
+
done();
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
invalidate: () => text.invalidate(),
|
|
203
|
+
render(width: number) {
|
|
204
|
+
text.setText(
|
|
205
|
+
`${formatMonitorList()}\n\n${theme.fg("dim", `${keyHint("tui.select.confirm", "close")} · ${keyHint("tui.select.cancel", "close")}`)}`,
|
|
206
|
+
);
|
|
207
|
+
return text.render(width);
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
overlay: true,
|
|
213
|
+
overlayOptions: { width: "80%", maxHeight: "80%", anchor: "center", margin: 1 },
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
if (monitors.size === 0) monitorWidget?.focusEditor();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function setupMonitorNavigation(ctx: ExtensionContext, restoreHistory: boolean): void {
|
|
220
|
+
if (ctx.mode !== "tui") return;
|
|
221
|
+
const previousEditorFactory = ctx.ui.getEditorComponent();
|
|
222
|
+
|
|
223
|
+
ctx.ui.setWidget(
|
|
224
|
+
WIDGET_KEY,
|
|
225
|
+
(tui, theme) => {
|
|
226
|
+
monitorWidget = new MonitorStatusWidget(tui, theme, () => monitors.size, () => {
|
|
227
|
+
void showMonitorDetails(ctx);
|
|
228
|
+
});
|
|
229
|
+
return monitorWidget;
|
|
230
|
+
},
|
|
231
|
+
{ placement: "belowEditor" },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
if (previousEditorFactory) return;
|
|
235
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
236
|
+
const editor = new CustomEditor(tui, theme, keybindings);
|
|
237
|
+
monitorWidget?.connectEditor(editor, keybindings);
|
|
238
|
+
if (restoreHistory) {
|
|
239
|
+
for (const entry of ctx.sessionManager.buildContextEntries()) {
|
|
240
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
241
|
+
const text = getTextContent(entry.message.content);
|
|
242
|
+
if (text) editor.addToHistory(text);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const handleInput = editor.handleInput.bind(editor);
|
|
247
|
+
editor.handleInput = (data: string) => {
|
|
248
|
+
if (
|
|
249
|
+
monitors.size === 0 ||
|
|
250
|
+
editor.isShowingAutocomplete() ||
|
|
251
|
+
!keybindings.matches(data, "tui.editor.cursorDown")
|
|
252
|
+
) {
|
|
253
|
+
handleInput(data);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const beforeCursor = editor.getCursor();
|
|
258
|
+
const beforeText = editor.getText();
|
|
259
|
+
handleInput(data);
|
|
260
|
+
const afterCursor = editor.getCursor();
|
|
261
|
+
if (
|
|
262
|
+
beforeText === editor.getText() &&
|
|
263
|
+
beforeCursor.line === afterCursor.line &&
|
|
264
|
+
beforeCursor.col === afterCursor.col
|
|
265
|
+
) {
|
|
266
|
+
monitorWidget?.focus();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
return editor;
|
|
270
|
+
});
|
|
56
271
|
}
|
|
57
272
|
|
|
58
273
|
function discardQueuedOutput(monitor: RunningMonitor): void {
|
|
@@ -179,9 +394,10 @@ export default function monitorExtension(pi: ExtensionAPI) {
|
|
|
179
394
|
await monitor.done;
|
|
180
395
|
}
|
|
181
396
|
|
|
182
|
-
pi.on("session_start", (
|
|
397
|
+
pi.on("session_start", (event, ctx) => {
|
|
183
398
|
currentContext = ctx;
|
|
184
399
|
shuttingDown = false;
|
|
400
|
+
setupMonitorNavigation(ctx, event.reason !== "startup");
|
|
185
401
|
updateStatus();
|
|
186
402
|
});
|
|
187
403
|
|
|
@@ -191,9 +407,35 @@ export default function monitorExtension(pi: ExtensionAPI) {
|
|
|
191
407
|
await Promise.allSettled(active.map(stopMonitor));
|
|
192
408
|
monitors.clear();
|
|
193
409
|
updateStatus();
|
|
410
|
+
monitorWidget = undefined;
|
|
194
411
|
currentContext = undefined;
|
|
195
412
|
});
|
|
196
413
|
|
|
414
|
+
pi.registerCommand("monitors", {
|
|
415
|
+
description: "Show active background monitors",
|
|
416
|
+
handler: async (_args, ctx) => {
|
|
417
|
+
ctx.ui.notify(formatMonitorList(), "info");
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
pi.registerTool({
|
|
422
|
+
name: "pi_background_monitor_list",
|
|
423
|
+
label: "List Monitors",
|
|
424
|
+
description:
|
|
425
|
+
"List active background monitors with their IDs, commands, working directories, elapsed time, and timeout state. The extension has no fixed concurrency limit; system resources are the limit.",
|
|
426
|
+
promptSnippet: "List active background monitors and their current state",
|
|
427
|
+
promptGuidelines: [
|
|
428
|
+
"Use pi_background_monitor_list to inspect active monitor count, commands, and runtime state.",
|
|
429
|
+
],
|
|
430
|
+
parameters: Type.Object({}),
|
|
431
|
+
async execute() {
|
|
432
|
+
return {
|
|
433
|
+
content: [{ type: "text", text: formatMonitorList() }],
|
|
434
|
+
details: { monitors: getMonitorDetails(), concurrencyLimit: null },
|
|
435
|
+
};
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
197
439
|
pi.registerTool({
|
|
198
440
|
name: "pi_background_monitor",
|
|
199
441
|
label: "Monitor",
|
|
@@ -231,6 +473,11 @@ export default function monitorExtension(pi: ExtensionAPI) {
|
|
|
231
473
|
const monitor: RunningMonitor = {
|
|
232
474
|
id,
|
|
233
475
|
description: params.description,
|
|
476
|
+
command: params.command,
|
|
477
|
+
cwd: ctx.cwd,
|
|
478
|
+
persistent,
|
|
479
|
+
timeoutMs,
|
|
480
|
+
startedAt: Date.now(),
|
|
234
481
|
abort,
|
|
235
482
|
decoder: new StringDecoder("utf8"),
|
|
236
483
|
partialLine: "",
|
|
@@ -286,8 +533,10 @@ export default function monitorExtension(pi: ExtensionAPI) {
|
|
|
286
533
|
id,
|
|
287
534
|
description: params.description,
|
|
288
535
|
command: params.command,
|
|
536
|
+
cwd: ctx.cwd,
|
|
289
537
|
persistent,
|
|
290
538
|
timeoutMs,
|
|
539
|
+
startedAt: monitor.startedAt,
|
|
291
540
|
},
|
|
292
541
|
};
|
|
293
542
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jtsang/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "An opinionated collection of extensions for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"check": "pnpm pack --dry-run",
|
|
54
|
-
"test": "node --test --experimental-strip-types tests/*.test.ts"
|
|
54
|
+
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
|
55
|
+
"verify": "pnpm test && pnpm exec tsc --noEmit && pnpm check"
|
|
55
56
|
}
|
|
56
57
|
}
|