pi-async-bash 0.1.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.
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Lifecycle helpers for background jobs.
3
+ *
4
+ * Collects the cross-cutting concerns — completion notification, timeout
5
+ * scheduling, terminal-state marking, and cleanup (kill) — in one place.
6
+ * Monitoring (progress polling, stall detection) lives in monitoring.ts.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { statSync as fsStatSync } from "node:fs";
11
+ import { readdir, stat, unlink } from "node:fs/promises";
12
+ import { join as pathJoin } from "node:path";
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ isTerminalStatus,
16
+ MAX_CONCURRENT_JOBS,
17
+ type Job,
18
+ type JobStatus,
19
+ type UiContext,
20
+ } from "./types.ts";
21
+ import type { BackgroundRegistry } from "./state.ts";
22
+ import { killProcessTree, type SpawnExit } from "./spawn.ts";
23
+ import { atConcurrencyLimit, forget, LOG_DIR, renderSidebar } from "./registry.ts";
24
+ import { watchStalls } from "./monitoring.ts";
25
+ import { markNotified, sendTaskNotification } from "./notify.ts";
26
+
27
+ // --- Background-job orchestration ----------------------------------------
28
+
29
+ /** Throw a standard error when no concurrency slot is free. */
30
+ export function assertJobSlot(reg: BackgroundRegistry): void {
31
+ if (atConcurrencyLimit(reg)) {
32
+ throw new Error(
33
+ `Max concurrent background jobs (${MAX_CONCURRENT_JOBS}) reached. ` +
34
+ `Kill or wait for existing jobs before starting new ones.`
35
+ );
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Wire a background job's lifecycle: completion promise, abort controller,
41
+ * stall watcher, and the exit→completeJob hand-off. The job must already be in
42
+ * the registry. Returns the job's AbortController so callers can attach extra
43
+ * monitoring or timeout cleanup.
44
+ */
45
+ export function startBackgroundJob(args: {
46
+ reg: BackgroundRegistry;
47
+ pi: ExtensionAPI;
48
+ ctx: UiContext;
49
+ job: Job;
50
+ exit: Promise<SpawnExit>;
51
+ shouldNotify?: boolean;
52
+ /** Suppress the interactive-prompt stall heuristic (monitors stream their
53
+ * own output, so a quiet tail is normal, not a stuck prompt). */
54
+ disablePromptStall?: boolean;
55
+ /** Suppress the oversize auto-kill (persistent log tails are expected to
56
+ * grow without bound). */
57
+ disableOversizeKill?: boolean;
58
+ onExit?: (result: SpawnExit) => void;
59
+ }): AbortController {
60
+ ensureCompletionPromise(args.job);
61
+ const jobAc = createJobAbort(args.reg, args.job.id);
62
+ const cancelStall = watchStalls({
63
+ jobId: args.job.id,
64
+ command: args.job.command,
65
+ name: args.job.name,
66
+ logPath: args.job.logPath,
67
+ pi: args.pi,
68
+ disablePromptStall: args.disablePromptStall,
69
+ disableOversizeKill: args.disableOversizeKill,
70
+ onOversize: () => terminateJobSilently(args.reg, args.job),
71
+ });
72
+ jobAc.signal.addEventListener("abort", cancelStall, { once: true });
73
+ void args.exit.then((result) => {
74
+ args.onExit?.(result);
75
+ completeJob({
76
+ job: args.job,
77
+ code: result.code,
78
+ signal: result.signal,
79
+ reg: args.reg,
80
+ pi: args.pi,
81
+ ctx: args.ctx,
82
+ shouldNotify: args.shouldNotify,
83
+ });
84
+ });
85
+ renderSidebar(args.reg, args.ctx);
86
+ return jobAc;
87
+ }
88
+
89
+ // --- Terminal-state marking ----------------------------------------------
90
+
91
+ /**
92
+ * Standard completion flow after a job exits — abortJob → markTerminal →
93
+ * notify → renderSidebar. Shared by every tool's exit callback (bash,
94
+ * bash_async, and bash_async_watch as the canonical termination protocol.
95
+ *
96
+ * The notification is Claude Code's per-job <task-notification>, sent the
97
+ * moment the job exits (see notify.ts). A successful send evicts the job
98
+ * from the live registry (terminal + notified). Jobs whose outcome is
99
+ * already known (killed silently, or read via bash_async_list output/attach) skip the
100
+ * notification and linger until the lazy sweep in `bash_async_list list`. Monitors own
101
+ * their terminal notification (monitor-session, shouldNotify: false) and are
102
+ * evicted here once it has fired. A `shouldNotify: false` job (bash_async
103
+ * `notify: false`) is latched notified WITHOUT sending — "don't notify" IS
104
+ * notified — so it evicts too and never lingers as a permanent entry.
105
+ */
106
+ export function completeJob(args: {
107
+ job: Job;
108
+ code: number | null | undefined;
109
+ /** The signal that killed the job, when it died by signal. */
110
+ signal?: NodeJS.Signals | null;
111
+ reg: BackgroundRegistry;
112
+ pi: ExtensionAPI;
113
+ ctx: UiContext;
114
+ shouldNotify?: boolean;
115
+ }): void {
116
+ if (isTerminalStatus(args.job.status)) return;
117
+ // The caller passes the authoritative Job (the object held in the registry),
118
+ // so no lookup is needed.
119
+ const finished = args.job;
120
+ abortJob(args.reg, finished.id);
121
+ markTerminal(finished, statusFromExit(args.code, args.signal), args.code ?? undefined);
122
+ if (args.shouldNotify !== false) {
123
+ sendTaskNotification({ reg: args.reg, pi: args.pi, job: finished });
124
+ } else {
125
+ markNotified(finished);
126
+ forget(args.reg, finished);
127
+ }
128
+ renderSidebar(args.reg, args.ctx);
129
+ }
130
+
131
+ /**
132
+ * Mark a job terminal and resolve its donePromise. Idempotent — already-
133
+ * terminal jobs are ignored. The proc reference is dropped explicitly for GC.
134
+ */
135
+ export function markTerminal(
136
+ job: Job,
137
+ status: JobStatus,
138
+ exitCode?: number
139
+ ): void {
140
+ if (isTerminalStatus(job.status)) {
141
+ return;
142
+ }
143
+ job.status = status;
144
+ job.exitCode = exitCode;
145
+ delete job.proc;
146
+ if (job.resolveDone) {
147
+ job.resolveDone();
148
+ delete job.resolveDone;
149
+ }
150
+ delete job.donePromise;
151
+ }
152
+
153
+ /** Map an exit result to a JobStatus: a signal death (external kill, OOM) is
154
+ * "killed" (CC marks these killed), exit code 0 is "completed", anything else
155
+ * is "failed". */
156
+ export function statusFromExit(
157
+ code: number | null | undefined,
158
+ signal?: NodeJS.Signals | null
159
+ ): JobStatus {
160
+ if (signal) return "killed";
161
+ return code === 0 ? "completed" : "failed";
162
+ }
163
+
164
+ /**
165
+ * Create a job's donePromise. This is the entry point that attach/log-wait
166
+ * flows await for a result. Idempotent — does not recreate an existing promise.
167
+ */
168
+ export function ensureCompletionPromise(job: Job): void {
169
+ if (job.donePromise) return;
170
+ let resolveDone: (() => void) | undefined;
171
+ job.donePromise = new Promise<void>((resolve) => {
172
+ resolveDone = resolve;
173
+ });
174
+ job.resolveDone = resolveDone;
175
+ }
176
+
177
+ /**
178
+ * Mark a job "killed" and latch the notified flag, so the exit callback does
179
+ * not emit a spurious completion notification on any termination path.
180
+ * `markTerminal` flips status to "killed" first; `markNotified` then records
181
+ * that the outcome needs no <task-notification> (Claude Code parity — a
182
+ * deliberate kill is intentional cleanup the agent already knows about).
183
+ */
184
+ export function markKilledSilently(job: Job): void {
185
+ markTerminal(job, "killed");
186
+ markNotified(job);
187
+ }
188
+
189
+ /** Kill a job quietly and abort its registered monitors/timers. The notified
190
+ * latch is set BEFORE the kill so the exit handler's notification is
191
+ * suppressed (Ctrl+Shift+X, jobs kill, session quit). */
192
+ export function terminateJobSilently(reg: BackgroundRegistry, job: Job): void {
193
+ markNotified(job);
194
+ terminateJob(job);
195
+ markKilledSilently(job);
196
+ abortJob(reg, job.id);
197
+ }
198
+
199
+ // --- Per-job abort (cleanup) ---------------------------------------------
200
+
201
+ /** Create an AbortController for a job. Aborting it cancels all monitors. */
202
+ export function createJobAbort(
203
+ reg: BackgroundRegistry,
204
+ jobId: string
205
+ ): AbortController {
206
+ const existing = reg.jobAborts.get(jobId);
207
+ if (existing) return existing;
208
+ const ac = new AbortController();
209
+ reg.jobAborts.set(jobId, ac);
210
+ return ac;
211
+ }
212
+
213
+ /** Abort all monitors for a job and remove the controller. */
214
+ export function abortJob(reg: BackgroundRegistry, jobId: string): void {
215
+ const ac = reg.jobAborts.get(jobId);
216
+ if (ac) {
217
+ ac.abort();
218
+ reg.jobAborts.delete(jobId);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Kill a job — SIGTERM the live process group if the proc handle is present,
224
+ * otherwise signal the recorded PID directly (covers jobs whose proc handle
225
+ * was already dropped).
226
+ */
227
+ export function terminateJob(job: Job): void {
228
+ // Monitors carry a transient teardown hook (follower + ws socket). A ws
229
+ // monitor has pid 0, so the process-tree kill below is a no-op for it and
230
+ // job.stop does the real work; a command monitor needs both.
231
+ job.stop?.();
232
+ // No liveness probe: killProcessTree already swallows ESRCH, and probing
233
+ // first would be a TOCTOU race. killProcessTree itself guards pid <= 0.
234
+ killProcessTree(job.proc?.pid ?? job.pid, "SIGTERM");
235
+ }
236
+
237
+ // --- Foreground backgrounding --------------------------------------------
238
+
239
+ /** Richer context for `/bash-async`: the UI plus the turn-control surface
240
+ * (idle check and whether a user message is already queued). */
241
+ export type ControlContext = UiContext & {
242
+ isIdle(): boolean;
243
+ hasPendingMessages(): boolean;
244
+ };
245
+
246
+ /**
247
+ * Flip every running foreground command into the background — Claude Code's
248
+ * Ctrl+B `backgroundAll`. Pure mechanic — no toast, no agent message. Returns
249
+ * false when there is nothing in the foreground to pause. Callers compose the
250
+ * messaging.
251
+ */
252
+ export function pauseAllForeground(reg: BackgroundRegistry, ctx: UiContext): boolean {
253
+ if (reg.foreground.size === 0) return false;
254
+ for (const slot of reg.foreground.values()) {
255
+ slot.requestPause("manual");
256
+ }
257
+ reg.foreground.clear();
258
+ renderSidebar(reg, ctx);
259
+ return true;
260
+ }
261
+
262
+ /** Move the current foreground command(s) to the background. The tool result
263
+ * already tells the model what happened (CC's exact `Command was manually
264
+ * backgrounded by user with ID: ...` string), so no synthetic agent message
265
+ * is sent — only the UI toast. */
266
+ export function backgroundActiveForeground(
267
+ reg: BackgroundRegistry,
268
+ ctx: UiContext
269
+ ): boolean {
270
+ if (!pauseAllForeground(reg, ctx)) return false;
271
+ ctx.ui.notify("▶ Backgrounded — continuing.", "info");
272
+ return true;
273
+ }
274
+
275
+ /** Outcome of `/bash-async` control handover. */
276
+ export type ControlOutcome = "backgrounded" | "queued" | "nothing";
277
+
278
+ /**
279
+ * `/bash-async` backgrounds all running foreground commands.
280
+ *
281
+ * It deliberately does NOT call ctx.abort(): in pi, aborting restores any queued
282
+ * message to the editor (unsent), renders a scary "Operation aborted", AND kills
283
+ * the running process — exactly the data-loss we must avoid. Instead, like
284
+ * Claude Code, backgrounding makes the bash tool return; the turn ends and any
285
+ * queued message drains at the natural turn boundary.
286
+ */
287
+ export function takeControl(
288
+ reg: BackgroundRegistry,
289
+ ctx: ControlContext
290
+ ): ControlOutcome {
291
+ if (pauseAllForeground(reg, ctx)) {
292
+ ctx.ui.notify("▶ Backgrounded — continuing.", "info");
293
+ return "backgrounded";
294
+ }
295
+
296
+ // Nothing in the foreground to background. If a message is queued behind the
297
+ // current turn, set expectations rather than abort (abort would lose it).
298
+ if (!ctx.isIdle() && ctx.hasPendingMessages()) {
299
+ ctx.ui.notify("Message queued — it'll send when the current step finishes.", "info");
300
+ return "queued";
301
+ }
302
+
303
+ ctx.ui.notify("No running process to background.", "warning");
304
+ return "nothing";
305
+ }
306
+
307
+ // --- Helpers -------------------------------------------------------------
308
+
309
+ /** Verify the cwd actually exists. Throws a clear error if not. */
310
+ export function requireExistingCwd(cwd: string): void {
311
+ try {
312
+ fsStatSync(cwd);
313
+ } catch {
314
+ throw new Error(`Working directory does not exist: ${cwd}`);
315
+ }
316
+ }
317
+
318
+ /** True for whitespace-only commands. bash silently passes empty commands, so reject them explicitly. */
319
+ export function isBlankCommand(command: string): boolean {
320
+ return command.trim().length === 0;
321
+ }
322
+
323
+ /**
324
+ * True when the command is eligible for auto-backgrounding. Rejects commands
325
+ * like `sleep` where backgrounding is pointless.
326
+ */
327
+ const DISALLOWED_AUTO_BACKGROUND = new Set(["sleep"]);
328
+ export function isAutoBackgroundAllowed(command: string): boolean {
329
+ const base = command.trim().split(/\s+/)[0] ?? "";
330
+ return !DISALLOWED_AUTO_BACKGROUND.has(base);
331
+ }
332
+
333
+ /**
334
+ * Actionable guidance shown when a naive `sleep N` wait is blocked. A fixed
335
+ * sleep both wastes time and leaves a lingering background job; every bullet
336
+ * points at a tool that ends as soon as the real work does.
337
+ */
338
+ export const SLEEP_WAIT_GUIDANCE =
339
+ "A fixed `sleep N` to wait wastes time and leaves a job lingering for the " +
340
+ "full duration. Instead:\n" +
341
+ "• Waiting on a background job you started? Use bash_async_list action='attach' — it " +
342
+ "returns as soon as that job finishes.\n" +
343
+ "• Waiting for a condition? Use the bash_async_watch tool, or a poll loop that EXITS " +
344
+ "when ready (e.g. `until grep -q READY log; do sleep 0.5; done`).\n" +
345
+ "• Just pacing/rate-limiting? Keep it under 2 seconds.";
346
+
347
+ /** A bare `sleep N[unit]` that counts as a wait (>= 2s). Float durations
348
+ * (`sleep 0.5`) and sub-2s integer sleeps are deliberate pacing — allowed. */
349
+ function detectSleepClause(segment: string): string | null {
350
+ // Allow a trailing `&` — a backgrounded `sleep 600 &` is itself a lingering job.
351
+ const m = /^sleep\s+(\d+)([smhd]?)\s*&?\s*$/.exec(segment);
352
+ if (!m) return null;
353
+ const unit = m[2] || "s";
354
+ if (unit === "s" && parseInt(m[1], 10) < 2) return null;
355
+ return `sleep ${m[1]}${m[2]}`;
356
+ }
357
+
358
+ /**
359
+ * Detect a `sleep N` used as a naive wait — `sleep 600`, `cd x; sleep 600;
360
+ * check`, `build && sleep 5 && test`, `sleep 5m`. Catches it as a top-level
361
+ * step in a flat command sequence (split on top-level `;`, `&&`, `||`).
362
+ *
363
+ * Deliberately conservative around control flow: a `sleep` inside a while/until/
364
+ * for loop is the *correct* polling pattern, and subshells / command
365
+ * substitution make flat splitting unsafe — there we check only the leading
366
+ * command, so a legitimate `until ready; do sleep 1; done` is never flagged.
367
+ *
368
+ * Returns the offending `sleep` clause, or null.
369
+ */
370
+ export function detectBlockedSleep(command: string): string | null {
371
+ const trimmed = command.trim();
372
+ // Only an actual loop body can leave a bare `sleep N` segment after a flat
373
+ // split (`do work; sleep 5; done`); an if/case block keeps its `then`/`)`
374
+ // prefix on the sleep, so those don't need special handling. We detect the
375
+ // structural loop pairing (not loose keywords, so `echo done` is fine) and
376
+ // grouping/command-substitution, and fall back to the leading command there.
377
+ const unsafeToSplit =
378
+ /\b(while|until|for)\b[\s\S]*?\bdo\b/.test(trimmed) ||
379
+ /\bdo\b[\s\S]*?\bdone\b/.test(trimmed) ||
380
+ /[(){}`]|\$\(/.test(trimmed);
381
+ // Newline is bash's primary command separator, alongside ; && || — split on
382
+ // all of them so `start-server\nsleep 5\ncurl` is caught like `…; sleep 5; …`.
383
+ const SEPARATORS = /&&|\|\||;|\n/;
384
+ const segments = unsafeToSplit
385
+ ? [trimmed.split(SEPARATORS)[0] ?? ""]
386
+ : trimmed.split(SEPARATORS);
387
+ for (const segment of segments) {
388
+ const clause = detectSleepClause(segment.trim());
389
+ if (clause) return clause;
390
+ }
391
+ return null;
392
+ }
393
+
394
+ // --- Reload continuity ---------------------------------------------------
395
+
396
+ /**
397
+ * Restore only jobs started by this Pi process. A reloaded extension can safely
398
+ * manage those process groups; a new Pi process must never signal a reused PID.
399
+ */
400
+ export function reviveAndValidate(job: Job): "alive" | "terminal" {
401
+ if (isTerminalStatus(job.status)) return "terminal";
402
+ const spawningPid = Number.parseInt(job.id.slice(1, job.id.indexOf("-")), 10);
403
+ if (spawningPid !== process.pid) {
404
+ markTerminal(job, "failed");
405
+ return "terminal";
406
+ }
407
+ try {
408
+ process.kill(job.pid, 0);
409
+ return "alive";
410
+ } catch {
411
+ markTerminal(job, "failed");
412
+ return "terminal";
413
+ }
414
+ }
415
+
416
+ /** Remove stale log files older than 24 hours without delaying startup. */
417
+ export async function cleanupStaleRuntimeArtifacts(): Promise<void> {
418
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
419
+ let entries: string[];
420
+ try {
421
+ entries = await readdir(LOG_DIR);
422
+ } catch {
423
+ return;
424
+ }
425
+ await Promise.all(entries.map(async (entry) => {
426
+ const path = pathJoin(LOG_DIR, entry);
427
+ try {
428
+ if ((await stat(path)).mtimeMs < cutoff) await unlink(path);
429
+ } catch {
430
+ // Files may disappear while the asynchronous cleanup runs.
431
+ }
432
+ }));
433
+ }
434
+
435
+ /** Serialize only data that can be safely restored after a same-process reload. */
436
+ export function serializeJobs(
437
+ jobs: Iterable<Job>,
438
+ ): Array<Omit<Job, "proc" | "donePromise" | "resolveDone" | "stop">> {
439
+ return Array.from(jobs, ({ proc: _proc, donePromise: _done, resolveDone: _resolve, stop: _stop, ...job }) => job);
440
+ }
441
+
442
+
443
+ /** Detect whether pi is running non-interactively (print / non-TTY). */
444
+ export function detectNonInteractive(
445
+ argv: readonly string[],
446
+ stdinIsTTY: boolean
447
+ ): boolean {
448
+ if (!stdinIsTTY) return true;
449
+ return argv.includes("-p") || argv.includes("--print");
450
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Streaming log search service.
3
+ *
4
+ * The bash_async_list tool owns parameter parsing and result formatting; this module owns
5
+ * full-log scanning, match counting, bounded display-hit retention, and the
6
+ * tail fallback used when the log file cannot be streamed line-by-line.
7
+ */
8
+
9
+ import { createReadStream } from "node:fs";
10
+ import { createInterface } from "node:readline";
11
+ import { OUTPUT_PREVIEW_CHARS, PREVIEW_CHARS, type Job } from "./types.ts";
12
+ import { readLogTail } from "./registry.ts";
13
+
14
+ export interface LogSearchHit {
15
+ path: string;
16
+ line: number;
17
+ text: string;
18
+ }
19
+
20
+ export interface LogSearchGroup {
21
+ jobId: string;
22
+ name?: string;
23
+ count: number;
24
+ hits: LogSearchHit[];
25
+ }
26
+
27
+ export interface LogSearchResult {
28
+ totalHits: number;
29
+ groups: LogSearchGroup[];
30
+ }
31
+
32
+ interface ScanOptions {
33
+ maxHitsPerJob: number;
34
+ maxLineChars: number;
35
+ }
36
+
37
+ export async function searchLogs(args: {
38
+ jobs: Iterable<Job>;
39
+ pattern: RegExp;
40
+ maxHitsPerJob: number;
41
+ maxLineChars?: number;
42
+ }): Promise<LogSearchResult> {
43
+ const options: ScanOptions = {
44
+ maxHitsPerJob: args.maxHitsPerJob,
45
+ maxLineChars: args.maxLineChars ?? PREVIEW_CHARS.line,
46
+ };
47
+ const jobs = [...args.jobs];
48
+
49
+ // Each job's log is an independent stream — scan them concurrently. Results
50
+ // come back in jobs[] order, so group ordering stays stable.
51
+ const groups = (
52
+ await Promise.all(jobs.map((job) => scanOneJob(job, args.pattern, options)))
53
+ ).filter((g) => g.count > 0);
54
+
55
+ let totalHits = 0;
56
+ for (const group of groups) totalHits += group.count;
57
+ return { totalHits, groups };
58
+ }
59
+
60
+ /** Scan one job's log (streamed line-by-line, falling back to the tail when the
61
+ * file cannot be streamed), returning its hit group. */
62
+ async function scanOneJob(
63
+ job: Job,
64
+ re: RegExp,
65
+ options: ScanOptions
66
+ ): Promise<LogSearchGroup> {
67
+ const group: LogSearchGroup = { jobId: job.id, name: job.name, count: 0, hits: [] };
68
+ const scanned = await streamLogFile(job, re, group, options);
69
+ if (!scanned) {
70
+ scanTailText(job, re, readLogTail(job, OUTPUT_PREVIEW_CHARS), group, options);
71
+ }
72
+ return group;
73
+ }
74
+
75
+ function record(group: LogSearchGroup, hit: LogSearchHit, maxHitsPerJob: number): void {
76
+ group.count++;
77
+ if (group.hits.length < maxHitsPerJob) group.hits.push(hit);
78
+ }
79
+
80
+ async function streamLogFile(
81
+ job: Job,
82
+ re: RegExp,
83
+ group: LogSearchGroup,
84
+ options: ScanOptions
85
+ ): Promise<boolean> {
86
+ return await new Promise<boolean>((resolve) => {
87
+ let lineNo = 0;
88
+ let sawFile = false;
89
+ const stream = createReadStream(job.logPath, { encoding: "utf-8" });
90
+ stream.on("error", () => resolve(false));
91
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
92
+ rl.on("line", (line) => {
93
+ sawFile = true;
94
+ lineNo++;
95
+ if (re.test(line)) {
96
+ record(group, {
97
+ path: job.logPath,
98
+ line: lineNo,
99
+ text: truncateLine(line, options.maxLineChars),
100
+ }, options.maxHitsPerJob);
101
+ }
102
+ });
103
+ rl.on("close", () => resolve(sawFile));
104
+ });
105
+ }
106
+
107
+ function scanTailText(
108
+ job: Job,
109
+ re: RegExp,
110
+ text: string,
111
+ group: LogSearchGroup,
112
+ options: ScanOptions
113
+ ): void {
114
+ const lines = text.split("\n");
115
+ for (let i = 0; i < lines.length; i++) {
116
+ if (re.test(lines[i])) {
117
+ record(group, {
118
+ path: `${job.logPath} (log tail)`,
119
+ line: i + 1,
120
+ text: truncateLine(lines[i], options.maxLineChars),
121
+ }, options.maxHitsPerJob);
122
+ }
123
+ }
124
+ }
125
+
126
+ function truncateLine(line: string, maxChars: number): string {
127
+ if (line.length <= maxChars) return line;
128
+ return `${line.slice(0, maxChars)}...[truncated]`;
129
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Line-accurate tail follower for the bash_async_watch tool.
3
+ *
4
+ * Unlike output.ts/pollFileTail (a bounded 4 KB tail deduped by content, fine
5
+ * for progress display but lossy under bursts), this follower tracks a byte
6
+ * offset forward from 0 and emits only *complete* newly-appended lines. A
7
+ * partial trailing line is held until its newline arrives. Lines read within a
8
+ * single poll tick are delivered together, so the poll cadence doubles as the
9
+ * batch window.
10
+ *
11
+ * This is the single emitter path for both monitor sources: command monitors
12
+ * append to the log via the child's stdout fd; ws monitors append each frame as
13
+ * a line. The follower does not care which.
14
+ */
15
+
16
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
17
+ import { MONITOR_POLL_MS } from "./types.ts";
18
+
19
+ export interface MonitorFollower {
20
+ /** Stop polling. When flush is true, do a final synchronous read and emit
21
+ * any remaining complete lines plus a trailing partial line (the process
22
+ * ended, so the last unterminated line is final). */
23
+ stop(flush?: boolean): void;
24
+ }
25
+
26
+ /**
27
+ * Follow `logPath`, invoking `onLines` with each batch of newly-completed
28
+ * lines. Starts at offset 0. Reads are bounded by available bytes, not file
29
+ * size history, so growth is O(new bytes) per tick.
30
+ */
31
+ export function followLines(
32
+ logPath: string,
33
+ onLines: (lines: string[]) => void,
34
+ intervalMs: number = MONITOR_POLL_MS
35
+ ): MonitorFollower {
36
+ let offset = 0;
37
+ let remainder = "";
38
+ let stopped = false;
39
+
40
+ /** Read everything appended since `offset`, split into complete lines.
41
+ * Returns complete lines; updates offset and remainder. */
42
+ function readNew(): string[] {
43
+ let size: number;
44
+ try {
45
+ size = statSync(logPath).size;
46
+ } catch {
47
+ return []; // file not created yet
48
+ }
49
+ // Truncation/rotation: the file shrank below our offset. Reset so we
50
+ // don't go permanently silent reading past the new end.
51
+ if (size < offset) {
52
+ offset = 0;
53
+ remainder = "";
54
+ }
55
+ if (size <= offset) return [];
56
+
57
+ const toRead = size - offset;
58
+ // allocUnsafe is safe here: only the [0, n) slice that readSync fills is
59
+ // ever consumed below.
60
+ const buf = Buffer.allocUnsafe(toRead);
61
+ let fd: number;
62
+ try {
63
+ fd = openSync(logPath, "r");
64
+ } catch {
65
+ return [];
66
+ }
67
+ try {
68
+ const n = readSync(fd, buf, 0, toRead, offset);
69
+ offset += n;
70
+ const text = remainder + buf.toString("utf-8", 0, n);
71
+ const parts = text.split("\n");
72
+ remainder = parts.pop() ?? ""; // trailing partial (no newline yet)
73
+ return parts;
74
+ } finally {
75
+ closeSync(fd);
76
+ }
77
+ }
78
+
79
+ const timer = setTimeout(function tick() {
80
+ if (stopped) return;
81
+ const lines = readNew();
82
+ if (lines.length > 0) onLines(lines);
83
+ if (!stopped) timer.refresh();
84
+ }, intervalMs);
85
+ (timer as NodeJS.Timeout).unref();
86
+
87
+ return {
88
+ stop(flush = false) {
89
+ if (stopped) return;
90
+ stopped = true;
91
+ clearTimeout(timer);
92
+ if (flush) {
93
+ const lines = readNew();
94
+ // A non-empty remainder is a final, newline-less last line.
95
+ if (remainder.length > 0) {
96
+ lines.push(remainder);
97
+ remainder = "";
98
+ }
99
+ if (lines.length > 0) onLines(lines);
100
+ }
101
+ },
102
+ };
103
+ }