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.
- package/CHANGELOG.md +3 -0
- package/LICENSE +22 -0
- package/README.md +105 -0
- package/RELEASING.md +23 -0
- package/index.ts +1 -0
- package/package.json +67 -0
- package/src/commands.ts +23 -0
- package/src/format.ts +78 -0
- package/src/hint.ts +37 -0
- package/src/index.ts +91 -0
- package/src/input.ts +35 -0
- package/src/lifecycle.ts +450 -0
- package/src/log-search.ts +129 -0
- package/src/monitor-follow.ts +103 -0
- package/src/monitor-session.ts +164 -0
- package/src/monitor-source.ts +62 -0
- package/src/monitor-ws.ts +132 -0
- package/src/monitoring.ts +170 -0
- package/src/notify.ts +140 -0
- package/src/output.ts +132 -0
- package/src/registry.ts +343 -0
- package/src/render.ts +56 -0
- package/src/spawn.ts +141 -0
- package/src/state.ts +30 -0
- package/src/tools/bash-bg.ts +116 -0
- package/src/tools/bash-params.ts +22 -0
- package/src/tools/bash.ts +347 -0
- package/src/tools/job-decide.ts +60 -0
- package/src/tools/jobs.ts +365 -0
- package/src/tools/monitor.ts +165 -0
- package/src/types.ts +148 -0
- package/src/ui.ts +311 -0
package/src/lifecycle.ts
ADDED
|
@@ -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
|
+
}
|