@aliou/pi-processes 0.6.3 → 0.7.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.
@@ -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}`));
package/src/config.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Configuration for the processes extension.
3
3
  *
4
- * Global: ~/.pi/agent/extensions/processes.json
5
- * Memory: ephemeral overrides via /process:settings
4
+ * Global: ~/.pi/agent/extensions/process.json
5
+ * Memory: ephemeral overrides via /ps:settings
6
6
  */
7
7
 
8
8
  import { ConfigLoader } from "@aliou/pi-utils-settings";
@@ -11,7 +11,7 @@ import { DEFAULT_KEYBINDINGS } from "./utils/keybindings";
11
11
 
12
12
  export interface ProcessesConfig {
13
13
  processList?: {
14
- /** Max visible processes in the /process:list TUI list. */
14
+ /** Max visible processes in the /ps TUI list. */
15
15
  maxVisibleProcesses?: number;
16
16
  /** Max log preview lines shown below the selected process. */
17
17
  maxPreviewLines?: number;
@@ -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
+ }
@@ -0,0 +1,331 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import type { ManagerEvent } from "./constants";
3
+ import { ProcessManager } from "./manager";
4
+
5
+ function waitForEnd(manager: ProcessManager, id: string): Promise<void> {
6
+ return new Promise((resolve) => {
7
+ const unsub = manager.onEvent((e) => {
8
+ if (e.type === "process_ended" && e.info.id === id) {
9
+ unsub();
10
+ resolve();
11
+ }
12
+ });
13
+ });
14
+ }
15
+
16
+ function collectEvents(manager: ProcessManager): ManagerEvent[] {
17
+ const events: ManagerEvent[] = [];
18
+ // Unsubscribe not stored; manager.cleanup() in afterEach clears all listeners.
19
+ manager.onEvent((e) => events.push(e));
20
+ return events;
21
+ }
22
+
23
+ describe("process_output_changed", () => {
24
+ let manager: ProcessManager;
25
+
26
+ afterEach(() => {
27
+ manager.cleanup();
28
+ });
29
+
30
+ it("emits process_output_changed on stdout", async () => {
31
+ manager = new ProcessManager();
32
+ const events = collectEvents(manager);
33
+ const info = manager.start("test", "echo hello", "/tmp");
34
+ await waitForEnd(manager, info.id);
35
+
36
+ const outputEvents = events.filter(
37
+ (e) => e.type === "process_output_changed",
38
+ );
39
+ expect(outputEvents.length).toBeGreaterThanOrEqual(1);
40
+ expect(outputEvents[0]).toEqual({
41
+ type: "process_output_changed",
42
+ id: info.id,
43
+ });
44
+ });
45
+
46
+ it("emits process_output_changed on stderr", async () => {
47
+ manager = new ProcessManager();
48
+ const events = collectEvents(manager);
49
+ const info = manager.start("test", "echo err >&2", "/tmp");
50
+ await waitForEnd(manager, info.id);
51
+
52
+ const outputEvents = events.filter(
53
+ (e) => e.type === "process_output_changed",
54
+ );
55
+ expect(outputEvents.length).toBeGreaterThanOrEqual(1);
56
+ expect(outputEvents[0]).toEqual({
57
+ type: "process_output_changed",
58
+ id: info.id,
59
+ });
60
+ });
61
+
62
+ it("throttles rapid output", async () => {
63
+ manager = new ProcessManager();
64
+ const events = collectEvents(manager);
65
+ const info = manager.start("test", "seq 1 200", "/tmp");
66
+ await waitForEnd(manager, info.id);
67
+
68
+ const outputEvents = events.filter(
69
+ (e) => e.type === "process_output_changed",
70
+ );
71
+ // Should be significantly fewer than 200 due to throttling
72
+ expect(outputEvents.length).toBeGreaterThanOrEqual(1);
73
+ expect(outputEvents.length).toBeLessThan(50);
74
+ });
75
+
76
+ it("stdout and stderr share one throttle bucket", async () => {
77
+ manager = new ProcessManager();
78
+
79
+ // Dual-stream burst: writes to both stdout and stderr rapidly
80
+ const events2 = collectEvents(manager);
81
+ const info2 = manager.start(
82
+ "dual",
83
+ "bash -c 'for i in $(seq 1 50); do echo out$i; echo err$i >&2; done'",
84
+ "/tmp",
85
+ );
86
+ await waitForEnd(manager, info2.id);
87
+ const dualCount = events2.filter(
88
+ (e) => e.type === "process_output_changed",
89
+ ).length;
90
+
91
+ // Both streams share one throttle bucket, so total events should be low
92
+ expect(dualCount).toBeLessThan(30);
93
+ });
94
+
95
+ it("trailing emit fires after burst ends", async () => {
96
+ manager = new ProcessManager();
97
+ const events = collectEvents(manager);
98
+ const info = manager.start("test", "seq 1 100", "/tmp");
99
+ await waitForEnd(manager, info.id);
100
+
101
+ // There should be at least one output event, and a process_ended event
102
+ const outputEvents = events.filter(
103
+ (e) => e.type === "process_output_changed",
104
+ );
105
+ const endEvents = events.filter((e) => e.type === "process_ended");
106
+ expect(outputEvents.length).toBeGreaterThanOrEqual(1);
107
+ expect(endEvents.length).toBe(1);
108
+ });
109
+
110
+ it("final output event before process_ended", async () => {
111
+ manager = new ProcessManager();
112
+ const events = collectEvents(manager);
113
+ const info = manager.start("test", "echo hello", "/tmp");
114
+ await waitForEnd(manager, info.id);
115
+
116
+ let lastOutputIdx = -1;
117
+ for (let i = events.length - 1; i >= 0; i--) {
118
+ if (events[i].type === "process_output_changed") {
119
+ lastOutputIdx = i;
120
+ break;
121
+ }
122
+ }
123
+ const endIdx = events.findIndex((e) => e.type === "process_ended");
124
+
125
+ expect(lastOutputIdx).toBeGreaterThanOrEqual(0);
126
+ expect(endIdx).toBeGreaterThanOrEqual(0);
127
+ expect(lastOutputIdx).toBeLessThan(endIdx);
128
+ });
129
+
130
+ it("no output events for silent process", async () => {
131
+ manager = new ProcessManager();
132
+ const events = collectEvents(manager);
133
+ const info = manager.start("test", "true", "/tmp");
134
+ await waitForEnd(manager, info.id);
135
+
136
+ // Wait a bit for any stale trailing emits
137
+ await new Promise((r) => setTimeout(r, 200));
138
+
139
+ const outputEvents = events.filter(
140
+ (e) => e.type === "process_output_changed",
141
+ );
142
+ expect(outputEvents.length).toBe(0);
143
+ });
144
+
145
+ it("no stale events after clearFinished", async () => {
146
+ manager = new ProcessManager();
147
+ const info = manager.start("test", "seq 1 50", "/tmp");
148
+ await waitForEnd(manager, info.id);
149
+
150
+ manager.clearFinished();
151
+
152
+ const lateEvents: ManagerEvent[] = [];
153
+ manager.onEvent((e) => lateEvents.push(e));
154
+
155
+ await new Promise((r) => setTimeout(r, 200));
156
+
157
+ const staleOutput = lateEvents.filter(
158
+ (e) => e.type === "process_output_changed",
159
+ );
160
+ expect(staleOutput.length).toBe(0);
161
+ });
162
+
163
+ it("events carry correct process id with multiple processes", async () => {
164
+ manager = new ProcessManager();
165
+ const events = collectEvents(manager);
166
+
167
+ const info1 = manager.start("proc1", "echo one", "/tmp");
168
+ const info2 = manager.start("proc2", "echo two", "/tmp");
169
+
170
+ await Promise.all([
171
+ waitForEnd(manager, info1.id),
172
+ waitForEnd(manager, info2.id),
173
+ ]);
174
+
175
+ const outputEvents = events.filter(
176
+ (e) => e.type === "process_output_changed",
177
+ );
178
+
179
+ for (const e of outputEvents) {
180
+ if (e.type === "process_output_changed") {
181
+ expect([info1.id, info2.id]).toContain(e.id);
182
+ }
183
+ }
184
+
185
+ // Both processes should have at least one output event
186
+ const ids = new Set(
187
+ outputEvents
188
+ .filter(
189
+ (e): e is Extract<ManagerEvent, { type: "process_output_changed" }> =>
190
+ e.type === "process_output_changed",
191
+ )
192
+ .map((e) => e.id),
193
+ );
194
+ expect(ids.has(info1.id)).toBe(true);
195
+ expect(ids.has(info2.id)).toBe(true);
196
+ });
197
+ });
198
+
199
+ describe("process_watch_matched", () => {
200
+ let manager: ProcessManager;
201
+
202
+ afterEach(() => {
203
+ manager.cleanup();
204
+ });
205
+
206
+ it("fires once by default on first matching line", async () => {
207
+ manager = new ProcessManager();
208
+ const events = collectEvents(manager);
209
+
210
+ const info = manager.start(
211
+ "watch-once",
212
+ "bash -c 'echo ready; echo ready; echo ready'",
213
+ "/tmp",
214
+ {
215
+ logWatches: [{ pattern: "ready" }],
216
+ },
217
+ );
218
+
219
+ await waitForEnd(manager, info.id);
220
+
221
+ const matches = events.filter((e) => e.type === "process_watch_matched");
222
+ expect(matches).toHaveLength(1);
223
+
224
+ const first = matches[0];
225
+ if (first.type === "process_watch_matched") {
226
+ expect(first.match.processId).toBe(info.id);
227
+ expect(first.match.source).toBe("stdout");
228
+ expect(first.match.watch.repeat).toBe(false);
229
+ expect(first.match.line).toBe("ready");
230
+ }
231
+ });
232
+
233
+ it("supports repeat watches", async () => {
234
+ manager = new ProcessManager();
235
+ const events = collectEvents(manager);
236
+
237
+ const info = manager.start(
238
+ "watch-repeat",
239
+ "bash -c 'echo done; echo done; echo done'",
240
+ "/tmp",
241
+ {
242
+ logWatches: [{ pattern: "done", repeat: true }],
243
+ },
244
+ );
245
+
246
+ await waitForEnd(manager, info.id);
247
+
248
+ const matches = events.filter((e) => e.type === "process_watch_matched");
249
+ expect(matches).toHaveLength(3);
250
+ });
251
+
252
+ it("respects stream scoping", async () => {
253
+ manager = new ProcessManager();
254
+ const events = collectEvents(manager);
255
+
256
+ const info = manager.start(
257
+ "watch-stream",
258
+ "bash -c 'echo out; echo err >&2'",
259
+ "/tmp",
260
+ {
261
+ logWatches: [{ pattern: "err", stream: "stderr" }],
262
+ },
263
+ );
264
+
265
+ await waitForEnd(manager, info.id);
266
+
267
+ const matches = events.filter((e) => e.type === "process_watch_matched");
268
+ expect(matches).toHaveLength(1);
269
+
270
+ const match = matches[0];
271
+ if (match.type === "process_watch_matched") {
272
+ expect(match.match.source).toBe("stderr");
273
+ expect(match.match.line).toBe("err");
274
+ }
275
+ });
276
+
277
+ it("stream both matches stdout and stderr", async () => {
278
+ manager = new ProcessManager();
279
+ const events = collectEvents(manager);
280
+
281
+ const info = manager.start(
282
+ "watch-both",
283
+ "bash -c 'echo marker; echo marker >&2'",
284
+ "/tmp",
285
+ {
286
+ logWatches: [{ pattern: "marker", stream: "both", repeat: true }],
287
+ },
288
+ );
289
+
290
+ await waitForEnd(manager, info.id);
291
+
292
+ const matches = events.filter((e) => e.type === "process_watch_matched");
293
+ expect(matches).toHaveLength(2);
294
+
295
+ const sources = new Set(
296
+ matches
297
+ .filter(
298
+ (e): e is Extract<ManagerEvent, { type: "process_watch_matched" }> =>
299
+ e.type === "process_watch_matched",
300
+ )
301
+ .map((e) => e.match.source),
302
+ );
303
+
304
+ expect(sources.has("stdout")).toBe(true);
305
+ expect(sources.has("stderr")).toBe(true);
306
+ });
307
+
308
+ it("matches trailing partial line at process end", async () => {
309
+ manager = new ProcessManager();
310
+ const events = collectEvents(manager);
311
+
312
+ const info = manager.start("watch-trailing", "printf ready", "/tmp", {
313
+ logWatches: [{ pattern: "ready" }],
314
+ });
315
+
316
+ await waitForEnd(manager, info.id);
317
+
318
+ const matches = events.filter((e) => e.type === "process_watch_matched");
319
+ expect(matches).toHaveLength(1);
320
+ });
321
+
322
+ it("throws for invalid watch regex", () => {
323
+ manager = new ProcessManager();
324
+
325
+ expect(() =>
326
+ manager.start("bad-watch", "echo ok", "/tmp", {
327
+ logWatches: [{ pattern: "(" }],
328
+ }),
329
+ ).toThrowError(/Invalid log watch pattern/);
330
+ });
331
+ });