@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 ADDED
@@ -0,0 +1,137 @@
1
+ # Processes Extension
2
+
3
+ > **Note**
4
+ > This is a stripped down fork of https://github.com/aliou/pi-processes.
5
+ > Most people should likely use the original project instead.
6
+
7
+ Manage background processes from Pi without blocking the conversation.
8
+
9
+ ## Installation
10
+
11
+ Install from npm:
12
+
13
+ ```bash
14
+ pi install npm:@mjakl/pi-processes
15
+ ```
16
+
17
+ For development builds, install from git:
18
+
19
+ ```bash
20
+ pi install git:https://github.com/mjakl/pi-processes
21
+ ```
22
+
23
+ ## What this fork keeps
24
+
25
+ - the `process` tool for starting, listing, inspecting, killing, and clearing managed processes
26
+ - a single `/ps` overlay for monitoring processes
27
+ - a tiny always-visible process status line while managed processes exist, showing active/finished counts
28
+ - process completion notifications in the conversation
29
+ - file-backed logs so process output is preserved outside agent context
30
+
31
+ ## What this fork removes
32
+
33
+ - all `/ps:*` subcommands
34
+ - the log dock
35
+ - the settings UI
36
+ - extra widget state and dock controls
37
+
38
+ ## Usage
39
+
40
+ ### Agent tool (LLM-facing)
41
+
42
+ The `process` tool is for the agent. Users should ask the agent to start or inspect long-running commands, then monitor them with `/ps`.
43
+
44
+ Tool-call examples:
45
+
46
+ ```text
47
+ process start "pnpm dev" name="backend-dev"
48
+ process start "pnpm test --watch" name="tests"
49
+ process start "pnpm dev" name="backend-dev" continueAfterStart=true
50
+ process list
51
+ process output id="backend-dev"
52
+ process logs id="proc_1"
53
+ process kill id="backend-dev"
54
+ process kill id="proc_1" force=true
55
+ process clear
56
+ ```
57
+
58
+ #### Matching processes
59
+
60
+ For `output`, `logs`, and `kill`, `id` must be either:
61
+
62
+ - the exact process ID (`proc_1`)
63
+ - the exact friendly process name (`backend-dev`)
64
+
65
+ If multiple processes share the same name, use the process ID.
66
+
67
+ #### Notifications for `start`
68
+
69
+ Do not poll after starting a process.
70
+
71
+ This tool is event-driven: the agent is notified automatically when a process exits, fails, or is externally killed. By default, `process start` ends the current agent turn so the agent actually waits for that notification instead of immediately polling. If the next step is waiting, call `process start` by itself rather than batching it with unrelated tools.
72
+
73
+ The intended pattern is:
74
+
75
+ 1. `process start`
76
+ 2. let the turn stop and wait for the automatic notification if the process ends
77
+ 3. resume from that notification
78
+
79
+ Use `continueAfterStart=true` only when the agent has immediate, specific, non-polling work to do after starting the process.
80
+
81
+ Repeated `process list`, `process output`, or `process logs` calls just to see whether the process is still running are an anti-pattern.
82
+
83
+ #### Logs and output
84
+
85
+ - `process output` returns a one-off tailed stdout/stderr snapshot for agent consumption.
86
+ - `process logs` returns file paths for `stdout`, `stderr`, and a combined view for the `/ps` overlay.
87
+ - Use `output`/`logs` only when the user asks, when you need a diagnostic snapshot, or when investigating a problem - not as a polling loop.
88
+
89
+ #### Killing processes
90
+
91
+ - `process kill id="..."` sends `SIGTERM`
92
+ - `process kill id="..." force=true` sends `SIGKILL`
93
+ - tool-triggered kills never notify the agent
94
+
95
+ ### `/ps` overlay
96
+
97
+ `/ps` opens the process overlay.
98
+
99
+ Inside the overlay:
100
+
101
+ - `up/down` - move the highlighted process
102
+ - `left/right` - scroll older/newer log output for the highlighted process
103
+ - `g/G` - jump to the top or back to the live tail
104
+ - `x` - terminate the highlighted process; press `x` again when it shows `needs kill` to force-kill it
105
+ - `c` - clear finished processes
106
+ - `q` or `Esc` - close the overlay
107
+
108
+ The right side always shows logs for the currently highlighted process.
109
+
110
+ ## Configuration
111
+
112
+ Global config lives in `~/.pi/agent/extensions/process.json`.
113
+
114
+ ```json
115
+ {
116
+ "output": {
117
+ "defaultTailLines": 100,
118
+ "maxOutputLines": 200
119
+ },
120
+ "execution": {
121
+ "shellPath": "/absolute/path/to/bash"
122
+ },
123
+ "interception": {
124
+ "blockBackgroundCommands": true
125
+ }
126
+ }
127
+ ```
128
+
129
+ - `output.defaultTailLines` - default number of lines returned by `process output`
130
+ - `output.maxOutputLines` - hard cap for `process output`
131
+ - `execution.shellPath` - absolute shell path override used for process startup
132
+ - `interception.blockBackgroundCommands` - block shell backgrounding (`&`, `nohup`, `disown`, `setsid`) and obvious long-running foreground commands such as `pnpm dev`, `docker compose up`, `tail -f`, or `kubectl port-forward`, and guide the agent to use the `process` tool instead
133
+
134
+ ## Notes
135
+
136
+ - Log files live in a temporary directory managed by the extension.
137
+ - Background processes are cleaned up when the session shuts down.
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@mjakl/pi-processes",
3
+ "version": "0.8.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "private": false,
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "pi",
11
+ "processes"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/mjakl/pi-processes"
16
+ },
17
+ "pi": {
18
+ "extensions": [
19
+ "./src/index.ts"
20
+ ]
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "!src/**/*.test.ts",
28
+ "README.md"
29
+ ],
30
+ "dependencies": {
31
+ "@aliou/sh": "^0.1.0"
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "@earendil-works/pi-tui": "*",
36
+ "typebox": "*"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "^2.3.13",
40
+ "@earendil-works/pi-coding-agent": "0.75.1",
41
+ "@earendil-works/pi-tui": "0.75.1",
42
+ "@types/node": "^25.0.10",
43
+ "husky": "^9.1.7",
44
+ "typebox": "^1.1.24",
45
+ "typescript": "^5.9.3",
46
+ "vitest": "^4.0.18"
47
+ },
48
+ "scripts": {
49
+ "typecheck": "tsc --noEmit",
50
+ "lint": "biome check",
51
+ "format": "biome check --write",
52
+ "test": "vitest run",
53
+ "check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
54
+ "prepare": "[ -d .git ] && husky || true"
55
+ },
56
+ "packageManager": "pnpm@10.26.1",
57
+ "peerDependenciesMeta": {
58
+ "@earendil-works/pi-coding-agent": {
59
+ "optional": true
60
+ },
61
+ "@earendil-works/pi-tui": {
62
+ "optional": true
63
+ },
64
+ "typebox": {
65
+ "optional": true
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,10 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ProcessManager } from "../manager";
3
+ import { registerPsCommand } from "./processes";
4
+
5
+ export function setupProcessesCommands(
6
+ pi: ExtensionAPI,
7
+ manager: ProcessManager,
8
+ ): void {
9
+ registerPsCommand(pi, manager);
10
+ }
@@ -0,0 +1,31 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ProcessesComponent } from "../../components/processes-component";
3
+ import type { ProcessManager } from "../../manager";
4
+
5
+ export function registerPsCommand(
6
+ pi: ExtensionAPI,
7
+ manager: ProcessManager,
8
+ ): void {
9
+ pi.registerCommand("ps", {
10
+ description: "Open the process overlay",
11
+ handler: async (_args, ctx) => {
12
+ if (!ctx.hasUI) {
13
+ return;
14
+ }
15
+
16
+ await ctx.ui.custom<null>(
17
+ (tui, theme, _keybindings, done) => {
18
+ return new ProcessesComponent(tui, theme, () => done(null), manager);
19
+ },
20
+ {
21
+ overlay: true,
22
+ overlayOptions: {
23
+ width: "90%",
24
+ maxHeight: "80%",
25
+ anchor: "center",
26
+ },
27
+ },
28
+ );
29
+ },
30
+ });
31
+ }
@@ -0,0 +1 @@
1
+ export { registerPsCommand } from "./command";
@@ -0,0 +1,317 @@
1
+ /**
2
+ * LogFileViewer - reads a single log file and renders a scrollable,
3
+ * searchable window of lines.
4
+ *
5
+ * A plain helper class (not a Component). Currently consumed by the
6
+ * `/ps` overlay. Callers are responsible for polling / invalidating when
7
+ * file content changes.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { stripAnsi } from "../utils";
14
+
15
+ export type StreamFilter = "combined" | "stdout" | "stderr";
16
+ export type LineFormat = "plain" | "combined";
17
+
18
+ interface ParsedLine {
19
+ type: "stdout" | "stderr";
20
+ text: string;
21
+ }
22
+
23
+ export interface LogFileViewerOptions {
24
+ filePath: string;
25
+ /** "plain" = raw lines (stdout/stderr files), "combined" = manager's 1:/2: tagged format */
26
+ format: LineFormat;
27
+ theme: Theme;
28
+ /** Start in follow mode (auto-scroll to tail). Default: false */
29
+ follow?: boolean;
30
+ }
31
+
32
+ export class LogFileViewer {
33
+ private filePath: string;
34
+ private format: LineFormat;
35
+ private theme: Theme;
36
+
37
+ private follow: boolean;
38
+ /** Absolute index of the last visible line (1-based).
39
+ * null = follow mode; always shows latest lines. */
40
+ private anchorEnd: number | null = null;
41
+ private streamFilter: StreamFilter = "combined";
42
+
43
+ private searchQuery = "";
44
+ private searchMatches: number[] = [];
45
+ private searchCurrentMatch = -1;
46
+
47
+ /** Line index (0-based) to center in the viewport. null = not centering. */
48
+ private centerTarget: number | null = null;
49
+
50
+ constructor(opts: LogFileViewerOptions) {
51
+ this.filePath = opts.filePath;
52
+ this.format = opts.format;
53
+ this.theme = opts.theme;
54
+ this.follow = opts.follow ?? false;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // File reading
59
+ // ---------------------------------------------------------------------------
60
+
61
+ private readAllLines(): ParsedLine[] {
62
+ try {
63
+ const content = readFileSync(this.filePath, "utf-8");
64
+ const rawLines = content.split("\n");
65
+ // Remove trailing empty string produced by a trailing newline.
66
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
67
+ rawLines.pop();
68
+ }
69
+
70
+ if (this.format === "plain") {
71
+ return rawLines.map((line) => ({
72
+ type: "stdout" as const,
73
+ text: line,
74
+ }));
75
+ }
76
+
77
+ // Combined format: "1:text" = stdout, "2:text" = stderr
78
+ return rawLines.map((line) => {
79
+ if (line.startsWith("2:")) {
80
+ return { type: "stderr" as const, text: line.slice(2) };
81
+ }
82
+ return {
83
+ type: "stdout" as const,
84
+ text: line.startsWith("1:") ? line.slice(2) : line,
85
+ };
86
+ });
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ private applyFilter(allLines: ParsedLine[]): ParsedLine[] {
93
+ if (this.streamFilter === "combined") return allLines;
94
+ const keep = this.streamFilter === "stdout" ? "stdout" : "stderr";
95
+ return allLines.filter((l) => l.type === keep);
96
+ }
97
+
98
+ private computeMatches(lines: ParsedLine[]): number[] {
99
+ if (!this.searchQuery) return [];
100
+ const q = this.searchQuery.toLowerCase();
101
+ return lines.reduce<number[]>((acc, line, i) => {
102
+ if (stripAnsi(line.text).toLowerCase().includes(q)) acc.push(i);
103
+ return acc;
104
+ }, []);
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Navigation
109
+ // ---------------------------------------------------------------------------
110
+
111
+ scrollToTop(): void {
112
+ this.anchorEnd = 0;
113
+ this.follow = false;
114
+ }
115
+
116
+ scrollToBottom(): void {
117
+ const lines = this.applyFilter(this.readAllLines());
118
+ this.anchorEnd = lines.length;
119
+ this.follow = false;
120
+ }
121
+
122
+ /** delta > 0 = scroll toward older content, delta < 0 = toward newer. */
123
+ scrollBy(delta: number): void {
124
+ if (this.anchorEnd === null) {
125
+ const lines = this.applyFilter(this.readAllLines());
126
+ this.anchorEnd = lines.length;
127
+ }
128
+ this.anchorEnd = Math.max(0, this.anchorEnd - delta);
129
+ this.follow = false;
130
+ }
131
+
132
+ toggleFollow(): boolean {
133
+ this.follow = !this.follow;
134
+ if (this.follow) {
135
+ this.anchorEnd = null;
136
+ } else {
137
+ const lines = this.applyFilter(this.readAllLines());
138
+ this.anchorEnd = lines.length;
139
+ }
140
+ return this.follow;
141
+ }
142
+
143
+ isFollowing(): boolean {
144
+ return this.follow;
145
+ }
146
+
147
+ cycleStreamFilter(): StreamFilter {
148
+ const order: StreamFilter[] = ["combined", "stdout", "stderr"];
149
+ this.streamFilter =
150
+ order[(order.indexOf(this.streamFilter) + 1) % order.length];
151
+ // Invalidate search since the line set changed.
152
+ this.searchMatches = [];
153
+ this.searchCurrentMatch = -1;
154
+ return this.streamFilter;
155
+ }
156
+
157
+ getStreamFilter(): StreamFilter {
158
+ return this.streamFilter;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Search
163
+ // ---------------------------------------------------------------------------
164
+
165
+ setSearch(query: string): void {
166
+ this.searchQuery = query;
167
+ const lines = this.applyFilter(this.readAllLines());
168
+ this.searchMatches = this.computeMatches(lines);
169
+ if (this.searchMatches.length > 0) {
170
+ // Start at the last match so the user lands near the tail.
171
+ this.searchCurrentMatch = this.searchMatches.length - 1;
172
+ this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]);
173
+ } else {
174
+ this.searchCurrentMatch = -1;
175
+ }
176
+ }
177
+
178
+ clearSearch(): void {
179
+ this.searchQuery = "";
180
+ this.searchMatches = [];
181
+ this.searchCurrentMatch = -1;
182
+ }
183
+
184
+ private jumpToMatchLine(lineIdx: number): void {
185
+ this.centerTarget = lineIdx;
186
+ this.follow = false;
187
+ }
188
+
189
+ nextMatch(): void {
190
+ if (this.searchMatches.length === 0) return;
191
+ this.searchCurrentMatch =
192
+ (this.searchCurrentMatch + 1) % this.searchMatches.length;
193
+ this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]);
194
+ }
195
+
196
+ prevMatch(): void {
197
+ if (this.searchMatches.length === 0) return;
198
+ this.searchCurrentMatch =
199
+ (this.searchCurrentMatch - 1 + this.searchMatches.length) %
200
+ this.searchMatches.length;
201
+ this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]);
202
+ }
203
+
204
+ getSearchInfo(): { query: string; current: number; total: number } | null {
205
+ if (!this.searchQuery) return null;
206
+ return {
207
+ query: this.searchQuery,
208
+ current: this.searchCurrentMatch + 1, // 1-based for display
209
+ total: this.searchMatches.length,
210
+ };
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Rendering
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /** Returns up to `maxLines` rendered content lines. */
218
+ renderLines(width: number, maxLines: number): string[] {
219
+ const theme = this.theme;
220
+ const dim = (s: string) => theme.fg("dim", s);
221
+ const warning = (s: string) => theme.fg("warning", s);
222
+
223
+ const allLines = this.readAllLines();
224
+ const lines = this.applyFilter(allLines);
225
+
226
+ // Refresh matches against current (possibly grown) data.
227
+ if (this.searchQuery) {
228
+ this.searchMatches = this.computeMatches(lines);
229
+ if (this.searchCurrentMatch >= this.searchMatches.length) {
230
+ this.searchCurrentMatch = Math.max(0, this.searchMatches.length - 1);
231
+ }
232
+ }
233
+
234
+ const total = lines.length;
235
+ if (total === 0) return [dim("(no output yet)")];
236
+
237
+ // Resolve centerTarget into anchorEnd now that we know maxLines.
238
+ if (this.centerTarget !== null) {
239
+ const half = Math.floor(maxLines / 2);
240
+ this.anchorEnd = Math.min(total, this.centerTarget + half + 1);
241
+ this.centerTarget = null;
242
+ }
243
+
244
+ // Resolve anchor: null = follow (tail), number = absolute frozen end.
245
+ const rawEnd = this.anchorEnd ?? total;
246
+ // Clamp to valid range. Math.max with min(maxLines, total) ensures anchorEnd = 0
247
+ // (scrollToTop sentinel) still shows a full window from the top.
248
+ const endIdx = Math.min(total, Math.max(rawEnd, Math.min(maxLines, total)));
249
+ const startIdx = Math.max(0, endIdx - maxLines);
250
+
251
+ const currentMatchIdx =
252
+ this.searchCurrentMatch >= 0 &&
253
+ this.searchCurrentMatch < this.searchMatches.length
254
+ ? this.searchMatches[this.searchCurrentMatch]
255
+ : -1;
256
+ const matchSet = new Set(this.searchMatches);
257
+
258
+ return lines.slice(startIdx, endIdx).map((line, i) => {
259
+ const absIdx = startIdx + i;
260
+ const text = truncateToWidth(stripAnsi(line.text), width);
261
+
262
+ if (absIdx === currentMatchIdx) return theme.bold(theme.inverse(text));
263
+ if (matchSet.has(absIdx)) return warning(text);
264
+ if (line.type === "stderr") return warning(text);
265
+ return text;
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Returns a single status-bar string exactly `width` characters wide
271
+ * (visible width). Shows position, stream filter, and search state.
272
+ */
273
+ renderStatusBar(width: number): string {
274
+ const theme = this.theme;
275
+ const dim = (s: string) => theme.fg("dim", s);
276
+ const accent = (s: string) => theme.fg("accent", s);
277
+
278
+ const lines = this.applyFilter(this.readAllLines());
279
+ const total = lines.length;
280
+
281
+ // Right side: position + stream filter
282
+ const rightParts: string[] = [];
283
+ if (this.follow) {
284
+ rightParts.push(accent("following"));
285
+ } else if (total === 0) {
286
+ rightParts.push(dim("empty"));
287
+ } else {
288
+ const rawEnd = this.anchorEnd ?? total;
289
+ const endIdx = Math.min(total, Math.max(0, rawEnd));
290
+ const pct = Math.round((endIdx / total) * 100);
291
+ rightParts.push(dim(`${pct}% L${Math.min(endIdx, total)}/${total}`));
292
+ }
293
+ if (this.streamFilter !== "combined") {
294
+ rightParts.push(dim(`[${this.streamFilter}]`));
295
+ }
296
+
297
+ // Left side: search state
298
+ const searchInfo = this.getSearchInfo();
299
+ let left = "";
300
+ if (searchInfo) {
301
+ left =
302
+ searchInfo.total === 0
303
+ ? theme.fg("error", `no matches: "${searchInfo.query}"`)
304
+ : `${dim("/")}${searchInfo.query} ${dim(`${searchInfo.current}/${searchInfo.total}`)}`;
305
+ }
306
+
307
+ const right = rightParts.join(" ");
308
+ const leftW = visibleWidth(left);
309
+ const rightW = visibleWidth(right);
310
+ const gap = Math.max(1, width - leftW - rightW);
311
+ const bar = left + " ".repeat(gap) + right;
312
+ const barW = visibleWidth(bar);
313
+
314
+ if (barW > width) return truncateToWidth(bar, width);
315
+ return bar + " ".repeat(width - barW);
316
+ }
317
+ }