@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 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 step. Run `pnpm exec tsc --noEmit` after adding TypeScript sources.
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 exec tsc --noEmit
74
- pnpm check
75
- pnpm publish
81
+ pnpm verify
82
+ pnpm publish # runs pnpm verify again via prepublishOnly
76
83
  ```
77
84
 
78
85
  ## License
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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(STATUS_KEY, count ? `${count} monitor${count === 1 ? "" : "s"}` : undefined);
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", (_event, ctx) => {
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.0",
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
  }