@aliou/pi-processes 0.6.4 → 0.7.1

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
@@ -114,6 +114,55 @@ Available settings include:
114
114
  - Linux: supported
115
115
  - Windows: not supported
116
116
 
117
+ ## Runtime log watch alerts
118
+
119
+ Use `process` tool `start` with `logWatches` to trigger immediate alerts while the process is still running.
120
+
121
+ - default behavior: each watch fires once (`repeat: false`)
122
+ - set `repeat: true` to trigger on every match
123
+ - scope by stream (`stdout`, `stderr`, `both`) to reduce noise
124
+
125
+ Example: server ready marker (one-time default)
126
+
127
+ ```json
128
+ {
129
+ "action": "start",
130
+ "name": "dev-server",
131
+ "command": "pnpm dev",
132
+ "logWatches": [
133
+ { "pattern": "ready on http://localhost:3000" }
134
+ ]
135
+ }
136
+ ```
137
+
138
+ Example: error marker from stderr
139
+
140
+ ```json
141
+ {
142
+ "action": "start",
143
+ "name": "builder",
144
+ "command": "pnpm build --watch",
145
+ "logWatches": [
146
+ { "pattern": "TypeError|ReferenceError", "stream": "stderr" }
147
+ ]
148
+ }
149
+ ```
150
+
151
+ Example: repeatable watch on stdout only
152
+
153
+ ```json
154
+ {
155
+ "action": "start",
156
+ "name": "worker",
157
+ "command": "pnpm worker",
158
+ "logWatches": [
159
+ { "pattern": "job completed", "stream": "stdout", "repeat": true }
160
+ ]
161
+ }
162
+ ```
163
+
164
+ Invalid regex patterns fail fast at process start with a clear error.
165
+
117
166
  ## Troubleshooting
118
167
 
119
168
  ### Pi started something and I want to see more output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.6.4",
3
+ "version": "0.7.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -39,16 +39,16 @@
39
39
  "@sinclair/typebox": "^0.34.41"
40
40
  },
41
41
  "peerDependencies": {
42
- "@mariozechner/pi-coding-agent": "0.61.0",
43
- "@mariozechner/pi-tui": "0.61.0"
42
+ "@mariozechner/pi-coding-agent": "0.65.0",
43
+ "@mariozechner/pi-tui": "0.65.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@aliou/biome-plugins": "^0.3.2",
47
47
  "@aliou/pi-evals": "^0.3.0",
48
48
  "@biomejs/biome": "^2.3.13",
49
49
  "@changesets/cli": "^2.27.11",
50
- "@mariozechner/pi-ai": "0.61.0",
51
- "@mariozechner/pi-coding-agent": "0.61.0",
50
+ "@mariozechner/pi-ai": "0.65.0",
51
+ "@mariozechner/pi-coding-agent": "0.65.0",
52
52
  "@types/node": "^25.0.10",
53
53
  "husky": "^9.1.7",
54
54
  "typescript": "^5.9.3",
@@ -69,6 +69,7 @@
69
69
  "check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
70
70
  "changeset": "changeset",
71
71
  "version": "changeset version",
72
+ "test": "vitest run",
72
73
  "release": "pnpm changeset publish"
73
74
  }
74
75
  }
@@ -19,7 +19,7 @@ export function registerPsKillCommand(
19
19
  let processId: string | undefined;
20
20
 
21
21
  if (arg) {
22
- const proc = manager.find(arg);
22
+ const proc = manager.get(arg);
23
23
  if (!proc) {
24
24
  return;
25
25
  }
@@ -49,18 +49,18 @@ export function registerPsKillCommand(
49
49
  }
50
50
  }
51
51
 
52
+ if (!processId) return;
53
+
52
54
  const proc = manager.get(processId);
53
- if (!proc) {
54
- return;
55
- }
55
+ if (!proc) return;
56
56
 
57
57
  const signal =
58
58
  proc.status === "terminate_timeout" ? "SIGKILL" : "SIGTERM";
59
59
  const timeoutMs = signal === "SIGKILL" ? 200 : 3000;
60
- const result = await manager.kill(processId, { signal, timeoutMs });
60
+ const result = await manager.kill(proc.id, { signal, timeoutMs });
61
61
 
62
62
  if (result.ok) {
63
- if (dockActions.getFocusedProcessId() === processId) {
63
+ if (dockActions.getFocusedProcessId() === proc.id) {
64
64
  dockActions.setFocus(null);
65
65
  }
66
66
  }
@@ -18,7 +18,7 @@ export function registerPsLogsCommand(
18
18
  let processId: string | undefined;
19
19
 
20
20
  if (arg) {
21
- const proc = manager.find(arg);
21
+ const proc = manager.get(arg);
22
22
  if (!proc) return;
23
23
  processId = proc.id;
24
24
  }
@@ -17,7 +17,7 @@ export function registerPsPinCommand(
17
17
  let processId: string | undefined;
18
18
 
19
19
  if (arg) {
20
- const proc = manager.find(arg);
20
+ const proc = manager.get(arg);
21
21
  if (!proc) {
22
22
  return;
23
23
  }
@@ -27,6 +27,7 @@ export function registerPsPinCommand(
27
27
  if (!processId) return;
28
28
  }
29
29
 
30
+ if (!processId) return;
30
31
  dockActions.setFocus(processId);
31
32
  },
32
33
  });
@@ -17,8 +17,6 @@ import { LIVE_STATUSES } from "../constants";
17
17
  import type { ProcessManager } from "../manager";
18
18
  import { LogFileViewer } from "./log-file-viewer";
19
19
 
20
- const POLL_INTERVAL_MS = 500;
21
-
22
20
  const PROCESS_COLORS: ThemeColor[] = [
23
21
  "accent",
24
22
  "warning",
@@ -47,7 +45,6 @@ export class LogDockComponent implements Component {
47
45
  private mode: "collapsed" | "open";
48
46
  private focusedProcessId: string | null;
49
47
 
50
- private timer: ReturnType<typeof setInterval> | null = null;
51
48
  private unsubscribeManager: (() => void) | null = null;
52
49
 
53
50
  /** One viewer per process, lazily created, follow:true. */
@@ -64,10 +61,6 @@ export class LogDockComponent implements Component {
64
61
  this.mode = options.mode;
65
62
  this.focusedProcessId = options.focusedProcessId;
66
63
 
67
- this.timer = setInterval(() => {
68
- this.tui.requestRender();
69
- }, POLL_INTERVAL_MS);
70
-
71
64
  this.unsubscribeManager = this.manager.onEvent(() => {
72
65
  this.tui.requestRender();
73
66
  });
@@ -227,10 +220,6 @@ export class LogDockComponent implements Component {
227
220
  }
228
221
 
229
222
  dispose(): void {
230
- if (this.timer) {
231
- clearInterval(this.timer);
232
- this.timer = null;
233
- }
234
223
  this.unsubscribeManager?.();
235
224
  this.viewers.clear();
236
225
  this.processColors.clear();
@@ -61,7 +61,6 @@ export class LogOverlayComponent implements Component {
61
61
  private mode: OverlayMode = "normal";
62
62
  private searchInput: Input = new Input();
63
63
 
64
- private timer: ReturnType<typeof setInterval> | null = null;
65
64
  private unsubscribeManager: (() => void) | null = null;
66
65
 
67
66
  constructor(opts: LogOverlayOptions) {
@@ -91,10 +90,6 @@ export class LogOverlayComponent implements Component {
91
90
  this.tui.requestRender();
92
91
  });
93
92
 
94
- this.timer = setInterval(() => {
95
- this.tui.requestRender();
96
- }, 300);
97
-
98
93
  this.searchInput.onSubmit = (query) => {
99
94
  const trimmed = query.trim();
100
95
  if (trimmed) {
@@ -163,10 +158,6 @@ export class LogOverlayComponent implements Component {
163
158
  // ---------------------------------------------------------------------------
164
159
 
165
160
  private close(): void {
166
- if (this.timer) {
167
- clearInterval(this.timer);
168
- this.timer = null;
169
- }
170
161
  this.unsubscribeManager?.();
171
162
  this.unsubscribeManager = null;
172
163
  this.done();
@@ -48,6 +48,19 @@ function truncate(str: string, maxLen: number): string {
48
48
  return `${str.slice(0, maxLen - 3)}...`;
49
49
  }
50
50
 
51
+ function fitCell(
52
+ value: string,
53
+ width: number,
54
+ align: "left" | "right" = "left",
55
+ ): string {
56
+ const truncated = truncateToWidth(value, Math.max(0, width));
57
+ const pad = Math.max(0, width - visibleWidth(truncated));
58
+ if (align === "right") {
59
+ return " ".repeat(pad) + truncated;
60
+ }
61
+ return truncated + " ".repeat(pad);
62
+ }
63
+
51
64
  export class ProcessesComponent implements Component {
52
65
  private tui: { requestRender: () => void };
53
66
  private theme: Theme;
@@ -243,8 +256,7 @@ export class ProcessesComponent implements Component {
243
256
  const scaleFactor =
244
257
  innerWidth < minTotalWidth ? innerWidth / minTotalWidth : 1;
245
258
 
246
- const idWidth = Math.max(6, Math.floor(9 * scaleFactor));
247
- const nameWidth = Math.max(8, Math.floor(15 * scaleFactor));
259
+ const processWidth = Math.max(14, Math.floor(24 * scaleFactor));
248
260
  const statusWidth = Math.max(10, Math.floor(18 * scaleFactor));
249
261
  const timeWidth = Math.max(4, Math.floor(8 * scaleFactor));
250
262
  const sizeWidth = Math.max(4, Math.floor(8 * scaleFactor));
@@ -258,8 +270,7 @@ export class ProcessesComponent implements Component {
258
270
  // Calculate command column width based on remaining space
259
271
  const fixedWidth =
260
272
  prefixWidth +
261
- idWidth +
262
- nameWidth +
273
+ processWidth +
263
274
  statusWidth +
264
275
  timeWidth +
265
276
  sizeWidth +
@@ -269,8 +280,7 @@ export class ProcessesComponent implements Component {
269
280
  lines.push(padLine(""));
270
281
  const header =
271
282
  " " +
272
- dim("ID".padEnd(idWidth)) +
273
- dim("Name".padEnd(nameWidth)) +
283
+ dim("Process".padEnd(processWidth)) +
274
284
  dim("Command".padEnd(cmdWidth)) +
275
285
  dim("Status".padEnd(statusWidth)) +
276
286
  dim("Time".padEnd(timeWidth)) +
@@ -294,18 +304,24 @@ export class ProcessesComponent implements Component {
294
304
  const totalSize = sizes ? sizes.stdout + sizes.stderr : 0;
295
305
 
296
306
  const statusText = this.formatStatus(proc);
297
- const statusPadding =
298
- statusWidth + (statusText.length - visibleWidth(statusText));
307
+
308
+ // Keep process cell bounded even with large IDs.
309
+ const idPlain = `(${proc.id})`;
310
+ const maxNameLen = Math.max(
311
+ 1,
312
+ processWidth - visibleWidth(idPlain) - 1,
313
+ );
314
+ const tName = truncate(proc.name, maxNameLen);
315
+ const processCell = isSelected
316
+ ? `${accent(tName)} ${dim(` ${idPlain}`)}`
317
+ : `${tName}${dim(` ${idPlain}`)}`;
299
318
 
300
319
  const row =
301
- (isSelected
302
- ? accent(proc.id.padEnd(idWidth))
303
- : proc.id.padEnd(idWidth)) +
304
- truncate(proc.name, nameWidth - 1).padEnd(nameWidth) +
305
- truncate(proc.command, cmdWidth - 1).padEnd(cmdWidth) +
306
- statusText.padEnd(statusPadding) +
307
- formatRuntime(proc.startTime, proc.endTime).padEnd(timeWidth) +
308
- formatBytes(totalSize).padStart(sizeWidth);
320
+ fitCell(processCell, processWidth) +
321
+ fitCell(truncate(proc.command, cmdWidth - 1), cmdWidth) +
322
+ fitCell(statusText, statusWidth) +
323
+ fitCell(formatRuntime(proc.startTime, proc.endTime), timeWidth) +
324
+ fitCell(formatBytes(totalSize), sizeWidth, "right");
309
325
 
310
326
  if (isSelected) {
311
327
  lines.push(padLine(`${accent(">")} ${row}`));
@@ -1,7 +1,11 @@
1
1
  export type {
2
2
  ExecuteResult,
3
3
  KillResult,
4
+ LogWatch,
5
+ LogWatchMatchEvent,
6
+ LogWatchStream,
4
7
  ManagerEvent,
8
+ ProcessAction,
5
9
  ProcessesDetails,
6
10
  ProcessInfo,
7
11
  ProcessStatus,
@@ -1,6 +1,16 @@
1
1
  // Custom message type for process update notifications
2
2
  export const MESSAGE_TYPE_PROCESS_UPDATE = "ad-process:update";
3
3
 
4
+ export type ProcessAction =
5
+ | "start"
6
+ | "list"
7
+ | "output"
8
+ | "logs"
9
+ | "kill"
10
+ | "clear"
11
+ | "write"
12
+ | "debug_preview";
13
+
4
14
  export type ProcessStatus =
5
15
  | "running"
6
16
  | "terminating"
@@ -14,6 +24,14 @@ export const LIVE_STATUSES: ReadonlySet<ProcessStatus> = new Set([
14
24
  "terminate_timeout",
15
25
  ]);
16
26
 
27
+ export type LogWatchStream = "stdout" | "stderr" | "both";
28
+
29
+ export interface LogWatch {
30
+ pattern: string;
31
+ stream?: LogWatchStream;
32
+ repeat?: boolean;
33
+ }
34
+
17
35
  export interface ProcessInfo {
18
36
  id: string;
19
37
  name: string;
@@ -32,9 +50,25 @@ export interface ProcessInfo {
32
50
  alertOnKill: boolean;
33
51
  }
34
52
 
53
+ export interface LogWatchMatchEvent {
54
+ processId: string;
55
+ processName: string;
56
+ processCommand: string;
57
+ source: "stdout" | "stderr";
58
+ line: string;
59
+ watch: {
60
+ index: number;
61
+ pattern: string;
62
+ stream: LogWatchStream;
63
+ repeat: boolean;
64
+ };
65
+ }
66
+
35
67
  export type ManagerEvent =
36
68
  | { type: "process_started"; info: ProcessInfo }
37
69
  | { type: "process_ended"; info: ProcessInfo }
70
+ | { type: "process_output_changed"; id: string }
71
+ | { type: "process_watch_matched"; match: LogWatchMatchEvent }
38
72
  | { type: "processes_changed" };
39
73
 
40
74
  export type KillResult =
@@ -52,10 +86,11 @@ export interface StartOptions {
52
86
  alertOnSuccess?: boolean;
53
87
  alertOnFailure?: boolean;
54
88
  alertOnKill?: boolean;
89
+ logWatches?: LogWatch[];
55
90
  }
56
91
 
57
92
  export interface ProcessesDetails {
58
- action: string;
93
+ action: ProcessAction;
59
94
  success: boolean;
60
95
  message: string;
61
96
  process?: ProcessInfo;
@@ -5,6 +5,7 @@ import { setupBackgroundBlocker } from "./background-blocker";
5
5
  import { setupCleanupHook } from "./cleanup";
6
6
  import { setupMessageRenderer } from "./message-renderer";
7
7
  import { setupProcessEndHook } from "./process-end";
8
+ import { setupProcessWatchHook } from "./process-watch";
8
9
  import { type DockActions, setupProcessWidget } from "./widget";
9
10
 
10
11
  export type { DockActions };
@@ -16,6 +17,7 @@ export function setupProcessesHooks(
16
17
  ): { update: () => void; dockActions: DockActions } {
17
18
  setupCleanupHook(pi, manager);
18
19
  setupProcessEndHook(pi, manager);
20
+ setupProcessWatchHook(pi, manager);
19
21
 
20
22
  if (config.interception.blockBackgroundCommands) {
21
23
  setupBackgroundBlocker(pi);
@@ -6,7 +6,8 @@ import type {
6
6
  import { Text } from "@mariozechner/pi-tui";
7
7
  import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants";
8
8
 
9
- interface ProcessUpdateDetails {
9
+ interface ProcessLifecycleDetails {
10
+ kind?: "lifecycle";
10
11
  processId: string;
11
12
  processName: string;
12
13
  command: string;
@@ -16,10 +17,25 @@ interface ProcessUpdateDetails {
16
17
  runtime: string;
17
18
  }
18
19
 
20
+ interface ProcessWatchMatchDetails {
21
+ kind: "watch_matched";
22
+ processId: string;
23
+ processName: string;
24
+ command: string;
25
+ source: "stdout" | "stderr";
26
+ line: string;
27
+ watch: {
28
+ index: number;
29
+ pattern: string;
30
+ stream: "stdout" | "stderr" | "both";
31
+ repeat: boolean;
32
+ };
33
+ }
34
+
19
35
  interface ProcessUpdateMessage {
20
36
  customType: string;
21
37
  content: string | Array<{ type: string; text?: string }>;
22
- details?: ProcessUpdateDetails;
38
+ details?: ProcessLifecycleDetails | ProcessWatchMatchDetails;
23
39
  }
24
40
 
25
41
  function getContentText(
@@ -35,7 +51,9 @@ function getContentText(
35
51
  }
36
52
 
37
53
  export function setupMessageRenderer(pi: ExtensionAPI) {
38
- pi.registerMessageRenderer<ProcessUpdateDetails>(
54
+ pi.registerMessageRenderer<
55
+ ProcessLifecycleDetails | ProcessWatchMatchDetails
56
+ >(
39
57
  MESSAGE_TYPE_PROCESS_UPDATE,
40
58
  (
41
59
  message: ProcessUpdateMessage,
@@ -48,6 +66,20 @@ export function setupMessageRenderer(pi: ExtensionAPI) {
48
66
  return new Text(getContentText(message.content), 0, 0);
49
67
  }
50
68
 
69
+ if (details.kind === "watch_matched") {
70
+ const streamColor = details.source === "stderr" ? "warning" : "accent";
71
+ const text =
72
+ theme.fg("success", "* ") +
73
+ theme.fg("accent", `"${details.processName}"`) +
74
+ theme.fg("muted", ` (${details.processId}) `) +
75
+ theme.fg("success", "watch matched ") +
76
+ theme.fg("muted", `/${details.watch.pattern}/ `) +
77
+ theme.fg(streamColor, `[${details.source}]`) +
78
+ theme.fg("muted", ` ${details.line}`);
79
+
80
+ return new Text(text, 0, 0);
81
+ }
82
+
51
83
  let icon: string;
52
84
  let color: "success" | "error" | "warning";
53
85
 
@@ -4,6 +4,7 @@ import type { ProcessManager } from "../manager";
4
4
  import { formatRuntime } from "../utils";
5
5
 
6
6
  interface ProcessUpdateDetails {
7
+ kind: "lifecycle";
7
8
  processId: string;
8
9
  processName: string;
9
10
  command: string;
@@ -43,6 +44,7 @@ export function setupProcessEndHook(pi: ExtensionAPI, manager: ProcessManager) {
43
44
  // Send the message to the conversation - displayed via custom renderer in UI
44
45
  // Only trigger an agent turn when the notification preferences say so.
45
46
  const details: ProcessUpdateDetails = {
47
+ kind: "lifecycle",
46
48
  processId: info.id,
47
49
  processName: info.name,
48
50
  command: info.command,
@@ -0,0 +1,83 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants";
3
+ import type { ProcessManager } from "../manager";
4
+
5
+ interface ProcessWatchUpdateDetails {
6
+ kind: "watch_matched";
7
+ processId: string;
8
+ processName: string;
9
+ command: string;
10
+ source: "stdout" | "stderr";
11
+ line: string;
12
+ watch: {
13
+ index: number;
14
+ pattern: string;
15
+ stream: "stdout" | "stderr" | "both";
16
+ repeat: boolean;
17
+ };
18
+ }
19
+
20
+ const REPEAT_WATCH_TURN_COOLDOWN_MS = 5000;
21
+
22
+ export function setupProcessWatchHook(
23
+ pi: ExtensionAPI,
24
+ manager: ProcessManager,
25
+ ) {
26
+ const lastRepeatTurnAt = new Map<string, number>();
27
+
28
+ manager.onEvent((event) => {
29
+ if (event.type === "process_ended") {
30
+ // Cleanup cooldown state for this process.
31
+ const prefix = `${event.info.id}:`;
32
+ for (const key of lastRepeatTurnAt.keys()) {
33
+ if (key.startsWith(prefix)) {
34
+ lastRepeatTurnAt.delete(key);
35
+ }
36
+ }
37
+ return;
38
+ }
39
+
40
+ if (event.type !== "process_watch_matched") return;
41
+
42
+ const match = event.match;
43
+ const message =
44
+ `Watch matched for '${match.processName}' (${match.processId}) ` +
45
+ `[${match.source}] /${match.watch.pattern}/`;
46
+
47
+ const details: ProcessWatchUpdateDetails = {
48
+ kind: "watch_matched",
49
+ processId: match.processId,
50
+ processName: match.processName,
51
+ command: match.processCommand,
52
+ source: match.source,
53
+ line: match.line,
54
+ watch: {
55
+ index: match.watch.index,
56
+ pattern: match.watch.pattern,
57
+ stream: match.watch.stream,
58
+ repeat: match.watch.repeat,
59
+ },
60
+ };
61
+
62
+ let triggerTurn = true;
63
+ if (match.watch.repeat) {
64
+ const watchKey = `${match.processId}:${match.watch.index}`;
65
+ const now = Date.now();
66
+ const last = lastRepeatTurnAt.get(watchKey) ?? 0;
67
+ triggerTurn = now - last >= REPEAT_WATCH_TURN_COOLDOWN_MS;
68
+ if (triggerTurn) {
69
+ lastRepeatTurnAt.set(watchKey, now);
70
+ }
71
+ }
72
+
73
+ pi.sendMessage(
74
+ {
75
+ customType: MESSAGE_TYPE_PROCESS_UPDATE,
76
+ content: message,
77
+ display: true,
78
+ details,
79
+ },
80
+ { triggerTurn },
81
+ );
82
+ });
83
+ }
@@ -147,10 +147,6 @@ export function setupProcessWidget(
147
147
  });
148
148
 
149
149
  pi.on("session_start", async (_event, ctx) => {
150
- latestContext = ctx;
151
- });
152
-
153
- pi.on("session_switch", async (_event, ctx) => {
154
150
  if (logDockComponent) {
155
151
  logDockComponent.dispose();
156
152
  logDockComponent = null;