@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.
@@ -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
+ });
package/src/manager.ts CHANGED
@@ -14,6 +14,8 @@ import type { Writable } from "node:stream";
14
14
  import {
15
15
  type KillResult,
16
16
  LIVE_STATUSES,
17
+ type LogWatch,
18
+ type LogWatchStream,
17
19
  type ManagerEvent,
18
20
  type ProcessInfo,
19
21
  type ProcessStatus,
@@ -23,12 +25,24 @@ import {
23
25
  import { isProcessGroupAlive, killProcessGroup } from "./utils";
24
26
  import { spawnCommand } from "./utils/command-executor";
25
27
 
28
+ interface ResolvedWatch {
29
+ index: number;
30
+ pattern: string;
31
+ regex: RegExp;
32
+ stream: LogWatchStream;
33
+ repeat: boolean;
34
+ fired: boolean;
35
+ }
36
+
26
37
  interface ManagedProcess extends ProcessInfo {
27
38
  process: ChildProcess;
28
39
  stdin: Writable | null;
29
40
  stdinClosed: boolean;
30
41
  lastSignalSent: NodeJS.Signals | null;
31
42
  combinedFile: string;
43
+ stdoutPendingLine: string;
44
+ stderrPendingLine: string;
45
+ watches: ResolvedWatch[];
32
46
  }
33
47
 
34
48
  interface ProcessManagerOptions {
@@ -43,6 +57,9 @@ export class ProcessManager {
43
57
  private watcher: ReturnType<typeof setInterval> | null = null;
44
58
  private getConfiguredShellPath: () => string | undefined;
45
59
 
60
+ private lastOutputEmitAt: Map<string, number> = new Map();
61
+ private pendingOutputEmit: Map<string, NodeJS.Timeout> = new Map();
62
+
46
63
  constructor(options?: ProcessManagerOptions) {
47
64
  this.logDir = join(tmpdir(), `pi-processes-${Date.now()}`);
48
65
  mkdirSync(this.logDir, { recursive: true });
@@ -59,6 +76,48 @@ export class ProcessManager {
59
76
  this.events.emit("event", event);
60
77
  }
61
78
 
79
+ private notifyOutputChanged(id: string): void {
80
+ const now = Date.now();
81
+ const lastEmit = this.lastOutputEmitAt.get(id) ?? 0;
82
+ const elapsed = now - lastEmit;
83
+
84
+ if (elapsed >= 100) {
85
+ this.lastOutputEmitAt.set(id, now);
86
+ this.emit({ type: "process_output_changed", id });
87
+ return;
88
+ }
89
+
90
+ if (!this.pendingOutputEmit.has(id)) {
91
+ const delay = 100 - elapsed;
92
+ const timeout = setTimeout(() => {
93
+ this.pendingOutputEmit.delete(id);
94
+ // Invariant: every path that removes a process from `this.processes`
95
+ // must call `clearOutputChangedState(id)` first, which clears this
96
+ // timeout. This guard is a safety net, not a primary mechanism.
97
+ if (!this.processes.has(id)) return;
98
+ this.lastOutputEmitAt.set(id, Date.now());
99
+ this.emit({ type: "process_output_changed", id });
100
+ }, delay);
101
+ this.pendingOutputEmit.set(id, timeout);
102
+ }
103
+ }
104
+
105
+ private flushPendingOutputChanged(id: string): void {
106
+ const timeout = this.pendingOutputEmit.get(id);
107
+ if (!timeout) return;
108
+ clearTimeout(timeout);
109
+ this.pendingOutputEmit.delete(id);
110
+ this.lastOutputEmitAt.set(id, Date.now());
111
+ this.emit({ type: "process_output_changed", id });
112
+ }
113
+
114
+ private clearOutputChangedState(id: string): void {
115
+ const timeout = this.pendingOutputEmit.get(id);
116
+ if (timeout) clearTimeout(timeout);
117
+ this.pendingOutputEmit.delete(id);
118
+ this.lastOutputEmitAt.delete(id);
119
+ }
120
+
62
121
  private transition(managed: ManagedProcess, next: ProcessStatus): void {
63
122
  if (managed.status === next) return;
64
123
  managed.status = next;
@@ -107,6 +166,9 @@ export class ProcessManager {
107
166
  managed.endTime = Date.now();
108
167
  }
109
168
 
169
+ this.flushPendingOutputChanged(managed.id);
170
+ this.flushPendingLines(managed);
171
+
110
172
  if (managed.lastSignalSent) {
111
173
  managed.success = false;
112
174
  managed.exitCode = null;
@@ -125,6 +187,7 @@ export class ProcessManager {
125
187
  cwd: string,
126
188
  options?: StartOptions,
127
189
  ): ProcessInfo {
190
+ const resolvedWatches = this.resolveLogWatches(options?.logWatches);
128
191
  const id = `proc_${++this.counter}`;
129
192
  const stdoutFile = join(this.logDir, `${id}-stdout.log`);
130
193
  const stderrFile = join(this.logDir, `${id}-stderr.log`);
@@ -159,6 +222,9 @@ export class ProcessManager {
159
222
  stdin: child.stdin,
160
223
  stdinClosed: false,
161
224
  lastSignalSent: null,
225
+ stdoutPendingLine: "",
226
+ stderrPendingLine: "",
227
+ watches: resolvedWatches,
162
228
  };
163
229
 
164
230
  this.processes.set(id, managed);
@@ -179,15 +245,11 @@ export class ProcessManager {
179
245
  child.stdout?.on("data", (data: Buffer) => {
180
246
  try {
181
247
  appendFileSync(stdoutFile, data);
182
- const lines = data.toString().split("\n");
183
- // The last element after split is either empty (if data ended with \n)
184
- // or a partial line. We write all parts with the prefix and newline.
185
- const tagged = lines
186
- .map((line, i) =>
187
- i < lines.length - 1 ? `1:${line}\n` : line ? `1:${line}\n` : "",
188
- )
189
- .join("");
248
+ const lines = this.extractCompleteLines(managed, "stdout", data);
249
+ const tagged = lines.map((line) => `1:${line}\n`).join("");
190
250
  if (tagged) appendFileSync(combinedFile, tagged);
251
+ this.matchWatches(managed, "stdout", lines);
252
+ this.notifyOutputChanged(id);
191
253
  } catch {
192
254
  // Ignore
193
255
  }
@@ -196,13 +258,11 @@ export class ProcessManager {
196
258
  child.stderr?.on("data", (data: Buffer) => {
197
259
  try {
198
260
  appendFileSync(stderrFile, data);
199
- const lines = data.toString().split("\n");
200
- const tagged = lines
201
- .map((line, i) =>
202
- i < lines.length - 1 ? `2:${line}\n` : line ? `2:${line}\n` : "",
203
- )
204
- .join("");
261
+ const lines = this.extractCompleteLines(managed, "stderr", data);
262
+ const tagged = lines.map((line) => `2:${line}\n`).join("");
205
263
  if (tagged) appendFileSync(combinedFile, tagged);
264
+ this.matchWatches(managed, "stderr", lines);
265
+ this.notifyOutputChanged(id);
206
266
  } catch {
207
267
  // Ignore
208
268
  }
@@ -215,6 +275,9 @@ export class ProcessManager {
215
275
  managed.endTime = Date.now();
216
276
  managed.success = code === 0;
217
277
 
278
+ this.flushPendingOutputChanged(id);
279
+ this.flushPendingLines(managed);
280
+
218
281
  if (signal) {
219
282
  this.transition(managed, "killed");
220
283
  } else {
@@ -233,6 +296,8 @@ export class ProcessManager {
233
296
  managed.exitCode = -1;
234
297
  managed.success = false;
235
298
  managed.endTime = Date.now();
299
+ this.flushPendingOutputChanged(id);
300
+ this.flushPendingLines(managed);
236
301
  this.transition(managed, "exited");
237
302
  }
238
303
  });
@@ -254,22 +319,6 @@ export class ProcessManager {
254
319
  return managed ? this.toProcessInfo(managed) : null;
255
320
  }
256
321
 
257
- find(query: string): ProcessInfo | null {
258
- const byId = this.processes.get(query);
259
- if (byId) return this.toProcessInfo(byId);
260
-
261
- const queryLower = query.toLowerCase();
262
- for (const managed of this.processes.values()) {
263
- if (managed.name.toLowerCase().includes(queryLower)) {
264
- return this.toProcessInfo(managed);
265
- }
266
- if (managed.command.toLowerCase().includes(queryLower)) {
267
- return this.toProcessInfo(managed);
268
- }
269
- }
270
- return null;
271
- }
272
-
273
322
  getOutput(
274
323
  id: string,
275
324
  tailLines = 100,
@@ -405,6 +454,8 @@ export class ProcessManager {
405
454
  managed.success = false;
406
455
  }
407
456
 
457
+ this.flushPendingOutputChanged(id);
458
+ this.flushPendingLines(managed);
408
459
  this.transition(managed, "killed");
409
460
  return { ok: true, info: this.toProcessInfo(managed) };
410
461
  }
@@ -468,6 +519,7 @@ export class ProcessManager {
468
519
  // Ignore
469
520
  }
470
521
 
522
+ this.clearOutputChangedState(id);
471
523
  this.processes.delete(id);
472
524
  cleared++;
473
525
  }
@@ -501,6 +553,12 @@ export class ProcessManager {
501
553
  cleanup(): void {
502
554
  this.stopWatcher();
503
555
 
556
+ for (const timeout of this.pendingOutputEmit.values()) {
557
+ clearTimeout(timeout);
558
+ }
559
+ this.pendingOutputEmit.clear();
560
+ this.lastOutputEmitAt.clear();
561
+
504
562
  for (const p of this.processes.values()) {
505
563
  if (!LIVE_STATUSES.has(p.status)) continue;
506
564
  try {
@@ -531,6 +589,132 @@ export class ProcessManager {
531
589
  }
532
590
  }
533
591
 
592
+ private resolveLogWatches(input?: LogWatch[]): ResolvedWatch[] {
593
+ if (!input || input.length === 0) return [];
594
+
595
+ return input.map((watch, index) => {
596
+ const pattern = watch.pattern?.trim();
597
+ if (!pattern) {
598
+ throw new Error(`logWatches[${index}].pattern is required`);
599
+ }
600
+
601
+ let regex: RegExp;
602
+ try {
603
+ regex = new RegExp(pattern);
604
+ } catch (error) {
605
+ const message =
606
+ error instanceof Error ? error.message : "invalid regular expression";
607
+ throw new Error(
608
+ `Invalid log watch pattern at logWatches[${index}]: ${message}`,
609
+ );
610
+ }
611
+
612
+ const stream = watch.stream ?? "both";
613
+ if (stream !== "stdout" && stream !== "stderr" && stream !== "both") {
614
+ throw new Error(
615
+ `Invalid logWatches[${index}].stream: ${stream}. Expected stdout, stderr, or both`,
616
+ );
617
+ }
618
+
619
+ return {
620
+ index,
621
+ pattern,
622
+ regex,
623
+ stream,
624
+ repeat: watch.repeat ?? false,
625
+ fired: false,
626
+ };
627
+ });
628
+ }
629
+
630
+ private extractCompleteLines(
631
+ managed: ManagedProcess,
632
+ source: "stdout" | "stderr",
633
+ data: Buffer,
634
+ ): string[] {
635
+ const chunk = data.toString();
636
+ const pending =
637
+ source === "stdout"
638
+ ? managed.stdoutPendingLine
639
+ : managed.stderrPendingLine;
640
+ const merged = pending + chunk;
641
+ const parts = merged.split(/\r?\n/);
642
+ const completeLines = parts.slice(0, -1);
643
+ const nextPending = parts[parts.length - 1] ?? "";
644
+
645
+ if (source === "stdout") {
646
+ managed.stdoutPendingLine = nextPending;
647
+ } else {
648
+ managed.stderrPendingLine = nextPending;
649
+ }
650
+
651
+ return completeLines;
652
+ }
653
+
654
+ private flushPendingLines(managed: ManagedProcess): void {
655
+ if (managed.stdoutPendingLine) {
656
+ try {
657
+ appendFileSync(
658
+ managed.combinedFile,
659
+ `1:${managed.stdoutPendingLine}\n`,
660
+ );
661
+ } catch {
662
+ // Ignore
663
+ }
664
+ this.matchWatches(managed, "stdout", [managed.stdoutPendingLine]);
665
+ managed.stdoutPendingLine = "";
666
+ }
667
+
668
+ if (managed.stderrPendingLine) {
669
+ try {
670
+ appendFileSync(
671
+ managed.combinedFile,
672
+ `2:${managed.stderrPendingLine}\n`,
673
+ );
674
+ } catch {
675
+ // Ignore
676
+ }
677
+ this.matchWatches(managed, "stderr", [managed.stderrPendingLine]);
678
+ managed.stderrPendingLine = "";
679
+ }
680
+ }
681
+
682
+ private matchWatches(
683
+ managed: ManagedProcess,
684
+ source: "stdout" | "stderr",
685
+ lines: string[],
686
+ ): void {
687
+ if (managed.watches.length === 0 || lines.length === 0) return;
688
+
689
+ for (const line of lines) {
690
+ for (const watch of managed.watches) {
691
+ if (!watch.repeat && watch.fired) continue;
692
+ if (watch.stream !== "both" && watch.stream !== source) continue;
693
+
694
+ if (!watch.regex.test(line)) continue;
695
+
696
+ watch.fired = true;
697
+
698
+ this.emit({
699
+ type: "process_watch_matched",
700
+ match: {
701
+ processId: managed.id,
702
+ processName: managed.name,
703
+ processCommand: managed.command,
704
+ source,
705
+ line,
706
+ watch: {
707
+ index: watch.index,
708
+ pattern: watch.pattern,
709
+ stream: watch.stream,
710
+ repeat: watch.repeat,
711
+ },
712
+ },
713
+ });
714
+ }
715
+ }
716
+ }
717
+
534
718
  private readTailLines(filePath: string, lines: number): string[] {
535
719
  try {
536
720
  const content = readFileSync(filePath, "utf-8");