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/output.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// src/output.ts
|
|
2
|
+
import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
|
|
3
|
+
import { FOREGROUND_TAIL_BYTES } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read the tail of a log file, bounded by maxChars. Only the last maxChars
|
|
7
|
+
* bytes are read (O(maxChars), not O(fileSize)). Opens once and fstats the
|
|
8
|
+
* descriptor — no separate path-stat, so no stat-then-read race.
|
|
9
|
+
*/
|
|
10
|
+
export function readBoundedTail(logPath: string, maxChars: number): string {
|
|
11
|
+
let fd: number;
|
|
12
|
+
try {
|
|
13
|
+
fd = openSync(logPath, "r");
|
|
14
|
+
} catch {
|
|
15
|
+
return "(no output yet)";
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const { size } = fstatSync(fd);
|
|
19
|
+
if (size === 0) return "(no output yet)";
|
|
20
|
+
const toRead = Math.min(size, maxChars);
|
|
21
|
+
const buf = Buffer.alloc(toRead);
|
|
22
|
+
readSync(fd, buf, 0, toRead, Math.max(0, size - toRead));
|
|
23
|
+
const body = buf.toString("utf-8");
|
|
24
|
+
return size > maxChars
|
|
25
|
+
? `...[truncated, showing last ${maxChars} chars]\n${body}`
|
|
26
|
+
: body;
|
|
27
|
+
} catch {
|
|
28
|
+
return "(no output yet)";
|
|
29
|
+
} finally {
|
|
30
|
+
closeSync(fd);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Terminal escape/control sequences stripped from a progress line so the
|
|
35
|
+
// sidebar shows clean text and crafted job output cannot inject escapes. The
|
|
36
|
+
// leading \u001b (ESC) is essential — without it these would eat literal
|
|
37
|
+
// `[...]`/`]...` like JSON.
|
|
38
|
+
const ANSI_CSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
39
|
+
const ANSI_OSC = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g;
|
|
40
|
+
// Remaining C0/C1 control chars and DEL (newlines handled by the split).
|
|
41
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The last non-empty line of a log's tail, ANSI-stripped — used to show live
|
|
45
|
+
* progress in the sidebar. Reads only the trailing bytes (cheap per tick), and
|
|
46
|
+
* collapses `\r` progress-bar redraws to their final segment. Returns "" when
|
|
47
|
+
* there's no output yet.
|
|
48
|
+
*/
|
|
49
|
+
export function readLastLine(logPath: string, scanBytes = 2_048): string {
|
|
50
|
+
const tail = readBoundedTail(logPath, scanBytes);
|
|
51
|
+
if (tail === "(no output yet)") return "";
|
|
52
|
+
const lines = tail.replace(ANSI_CSI, "").replace(ANSI_OSC, "").split(/[\r\n]+/);
|
|
53
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
54
|
+
const line = lines[i].replace(CONTROL_CHARS, "").replace(/\t/g, " ").trim();
|
|
55
|
+
if (line.length > 0) return line;
|
|
56
|
+
}
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Poll a log file tail at `intervalMs` (default 1000ms). Calls `onUpdate`
|
|
62
|
+
* only when content changes. Returns a handle with `stop()`.
|
|
63
|
+
*
|
|
64
|
+
* This is the Claude Code pattern: the file is written to by the child
|
|
65
|
+
* process via file descriptor. We poll the tail for progress display.
|
|
66
|
+
*/
|
|
67
|
+
export function pollFileTail(
|
|
68
|
+
logPath: string,
|
|
69
|
+
onUpdate: (text: string) => void,
|
|
70
|
+
intervalMs = 1_000
|
|
71
|
+
): { stop: () => void } {
|
|
72
|
+
let lastSize = 0;
|
|
73
|
+
let lastContent = "";
|
|
74
|
+
let stopped = false;
|
|
75
|
+
|
|
76
|
+
const timer = setTimeout(function tick() {
|
|
77
|
+
if (stopped) return;
|
|
78
|
+
try {
|
|
79
|
+
const { size } = statSync(logPath);
|
|
80
|
+
if (size === lastSize) {
|
|
81
|
+
timer.refresh();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
lastSize = size;
|
|
85
|
+
const fd = openSync(logPath, "r");
|
|
86
|
+
try {
|
|
87
|
+
const readStart = Math.max(0, size - FOREGROUND_TAIL_BYTES);
|
|
88
|
+
const toRead = Math.min(size, FOREGROUND_TAIL_BYTES);
|
|
89
|
+
const buf = Buffer.alloc(toRead);
|
|
90
|
+
readSync(fd, buf, 0, toRead, readStart);
|
|
91
|
+
const content = buf.toString("utf-8", 0, toRead);
|
|
92
|
+
if (content && content !== lastContent) {
|
|
93
|
+
lastContent = content;
|
|
94
|
+
onUpdate(content);
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
closeSync(fd);
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// File not yet created or locked — retry next tick.
|
|
101
|
+
}
|
|
102
|
+
if (!stopped) timer.refresh();
|
|
103
|
+
}, intervalMs);
|
|
104
|
+
(timer as NodeJS.Timeout).unref();
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
stop() {
|
|
108
|
+
stopped = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A tool's streaming-update callback (text-only partial results). */
|
|
115
|
+
export type ToolTextUpdate = (update: {
|
|
116
|
+
content: { type: "text"; text: string }[];
|
|
117
|
+
details: undefined;
|
|
118
|
+
}) => void;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Stream a log file's live tail into a tool's onUpdate callback — the shared
|
|
122
|
+
* "show live output while a job runs" mechanic shared by `bash` and
|
|
123
|
+
* `bash_async_list attach`. Returns the poller's stop handle.
|
|
124
|
+
*/
|
|
125
|
+
export function streamLog(
|
|
126
|
+
logPath: string,
|
|
127
|
+
onUpdate: ToolTextUpdate | undefined
|
|
128
|
+
): { stop: () => void } {
|
|
129
|
+
return pollFileTail(logPath, (text) => {
|
|
130
|
+
onUpdate?.({ content: [{ type: "text", text }], details: undefined });
|
|
131
|
+
});
|
|
132
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The job registry — the single point of truth for every running or
|
|
3
|
+
* recently-terminal background job. All CRUD operations live here.
|
|
4
|
+
*
|
|
5
|
+
* On top of the data store, this module renders the in-session sidebar
|
|
6
|
+
* pill bar (`renderSidebar`) and aggregates stats (`getStats`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomInt } from "node:crypto";
|
|
10
|
+
import { statSync, unlinkSync } from "node:fs";
|
|
11
|
+
import { formatDuration, jobLabel } from "./format.ts";
|
|
12
|
+
import {
|
|
13
|
+
isTerminalStatus,
|
|
14
|
+
JOB_ID_PREFIX,
|
|
15
|
+
MAX_CONCURRENT_JOBS,
|
|
16
|
+
PREVIEW_CHARS,
|
|
17
|
+
RECENT_TERMINAL_KEEP,
|
|
18
|
+
type Job,
|
|
19
|
+
type JobKind,
|
|
20
|
+
type UiContext,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
import type { BackgroundRegistry } from "./state.ts";
|
|
23
|
+
import { readBoundedTail, readLastLine } from "./output.ts";
|
|
24
|
+
|
|
25
|
+
// --- ID generation -------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Typed task ids: a one-letter kind prefix, spawning process identifier, and
|
|
31
|
+
* eight random base36 characters (for example, `b1234-7f3k9a2x`).
|
|
32
|
+
*/
|
|
33
|
+
export function newJobId(kind: JobKind, reg?: BackgroundRegistry): string {
|
|
34
|
+
let id: string;
|
|
35
|
+
do {
|
|
36
|
+
let suffix = "";
|
|
37
|
+
for (let i = 0; i < 8; i++) {
|
|
38
|
+
suffix += ID_ALPHABET[randomInt(0, ID_ALPHABET.length)];
|
|
39
|
+
}
|
|
40
|
+
id = `${JOB_ID_PREFIX[kind]}${process.pid}-${suffix}`;
|
|
41
|
+
} while (reg?.jobs.has(id));
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Dedicated log directory. Keeping logs in their own dir (not loose in /tmp)
|
|
46
|
+
* keeps the stale-log sweep bounded — it lists only our files. */
|
|
47
|
+
export const LOG_DIR = "/tmp/pi-bg";
|
|
48
|
+
|
|
49
|
+
export function logPathFor(jobId: string): string {
|
|
50
|
+
return `${LOG_DIR}/${jobId}.log`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Sibling stderr-capture path for a monitor's split output. Keeps the
|
|
54
|
+
* `.log`/`.err` naming convention in one place. */
|
|
55
|
+
export function errPathFor(jobId: string): string {
|
|
56
|
+
return `${LOG_DIR}/${jobId}.err`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build a fresh running Job. Centralizes the Job shape so the new `kind`/`stop`
|
|
61
|
+
* fields (and any future additions) don't drift across the bash/bash_async/
|
|
62
|
+
* bash_async_watch construction sites.
|
|
63
|
+
*/
|
|
64
|
+
export function createRunningJob(args: {
|
|
65
|
+
id: string;
|
|
66
|
+
command: string;
|
|
67
|
+
pid: number;
|
|
68
|
+
logPath: string;
|
|
69
|
+
toolCallId: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
kind?: JobKind;
|
|
72
|
+
isBackgrounded?: boolean;
|
|
73
|
+
}): Job {
|
|
74
|
+
return {
|
|
75
|
+
id: args.id,
|
|
76
|
+
name: args.name,
|
|
77
|
+
command: args.command,
|
|
78
|
+
pid: args.pid,
|
|
79
|
+
startTime: Date.now(),
|
|
80
|
+
status: "running",
|
|
81
|
+
logPath: args.logPath,
|
|
82
|
+
toolCallId: args.toolCallId,
|
|
83
|
+
isBackgrounded: args.isBackgrounded ?? true,
|
|
84
|
+
kind: args.kind,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Registry mutations --------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/** Record that a job has started (lifetime counter). */
|
|
91
|
+
export function markStarted(reg: BackgroundRegistry): void {
|
|
92
|
+
reg.totalStarted++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Add a brand-new running job and count it as started. */
|
|
96
|
+
export function add(reg: BackgroundRegistry, job: Job): Job {
|
|
97
|
+
reg.jobs.set(job.id, job);
|
|
98
|
+
markStarted(reg);
|
|
99
|
+
return job;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True once the running-job count has reached the concurrency cap. Counts with
|
|
103
|
+
* a short-circuit so it stops at the cap instead of scanning the whole map. */
|
|
104
|
+
export function atConcurrencyLimit(reg: BackgroundRegistry): boolean {
|
|
105
|
+
let n = 0;
|
|
106
|
+
for (const job of reg.jobs.values()) {
|
|
107
|
+
if (job.status === "running" && ++n >= MAX_CONCURRENT_JOBS) return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Remove a terminal job from the live map and update lifetime counters.
|
|
114
|
+
* Returns the removed job (or undefined if it wasn't in the map).
|
|
115
|
+
*/
|
|
116
|
+
export function forget(reg: BackgroundRegistry, job: Job): Job | undefined {
|
|
117
|
+
if (!reg.jobs.delete(job.id)) return undefined;
|
|
118
|
+
if (job.status === "completed") {
|
|
119
|
+
reg.completedCount++;
|
|
120
|
+
reg.totalDurationMs += terminalDurationMs(job);
|
|
121
|
+
} else if (job.status === "failed") {
|
|
122
|
+
reg.failedCount++;
|
|
123
|
+
reg.totalDurationMs += terminalDurationMs(job);
|
|
124
|
+
} else if (job.status === "killed") {
|
|
125
|
+
reg.killedCount++;
|
|
126
|
+
}
|
|
127
|
+
reg.recentTerminal.push(job);
|
|
128
|
+
if (reg.recentTerminal.length > RECENT_TERMINAL_KEEP) {
|
|
129
|
+
reg.recentTerminal.shift();
|
|
130
|
+
}
|
|
131
|
+
return job;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Look up a job by ID — first the live registry, then the recent-terminal ring
|
|
135
|
+
* for jobs that already finished and were evicted. */
|
|
136
|
+
export function findJob(reg: BackgroundRegistry, jobId: string): Job | undefined {
|
|
137
|
+
return (
|
|
138
|
+
reg.jobs.get(jobId) ??
|
|
139
|
+
reg.recentTerminal.find((j) => j.id === jobId)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Purge all terminal jobs from in-memory state and delete their log files. */
|
|
144
|
+
export function cleanupTerminal(reg: BackgroundRegistry): {
|
|
145
|
+
purged: number;
|
|
146
|
+
bytesReclaimed: number;
|
|
147
|
+
} {
|
|
148
|
+
let purged = 0;
|
|
149
|
+
let bytes = 0;
|
|
150
|
+
const deletedLogs = new Set<string>();
|
|
151
|
+
const deleteOnce = (logPath: string): number => {
|
|
152
|
+
if (deletedLogs.has(logPath)) return 0;
|
|
153
|
+
deletedLogs.add(logPath);
|
|
154
|
+
return deleteLogFile(logPath);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const idsToRemove: string[] = [];
|
|
158
|
+
for (const [id, job] of reg.jobs.entries()) {
|
|
159
|
+
if (isTerminalStatus(job.status)) {
|
|
160
|
+
idsToRemove.push(id);
|
|
161
|
+
bytes += deleteOnce(job.logPath);
|
|
162
|
+
purged++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const id of idsToRemove) {
|
|
166
|
+
reg.jobs.delete(id);
|
|
167
|
+
}
|
|
168
|
+
// The recent-terminal ring is all terminal jobs too — sweep their logs.
|
|
169
|
+
for (const job of reg.recentTerminal) {
|
|
170
|
+
bytes += deleteOnce(job.logPath);
|
|
171
|
+
purged++;
|
|
172
|
+
}
|
|
173
|
+
reg.recentTerminal.length = 0;
|
|
174
|
+
return { purged, bytesReclaimed: bytes };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function deleteLogFile(logPath: string): number {
|
|
178
|
+
try {
|
|
179
|
+
const { size } = statSync(logPath);
|
|
180
|
+
unlinkSync(logPath);
|
|
181
|
+
return size;
|
|
182
|
+
} catch {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Sidebar rendering ───────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Render the pill-bar status widget and aggregate status-bar text, and keep a
|
|
191
|
+
* 1 Hz ticker running while any job is alive so the durations stay live (the
|
|
192
|
+
* widget isn't redrawn on a timer otherwise). Re-renders only when the content
|
|
193
|
+
* actually changes. Call after any state change that affects running jobs.
|
|
194
|
+
*/
|
|
195
|
+
export function renderSidebar(reg: BackgroundRegistry, ctx: UiContext): void {
|
|
196
|
+
const pills: string[] = [];
|
|
197
|
+
let runningCount = 0;
|
|
198
|
+
const runningLogs = new Set<string>();
|
|
199
|
+
|
|
200
|
+
for (const job of reg.jobs.values()) {
|
|
201
|
+
// Terminal jobs render no pill: their outcome is always surfaced by a
|
|
202
|
+
// <task-notification>, a kill, or a read — there is no unread state.
|
|
203
|
+
if (isTerminalStatus(job.status)) continue;
|
|
204
|
+
runningCount++;
|
|
205
|
+
runningLogs.add(job.logPath);
|
|
206
|
+
const duration = formatDuration(Date.now() - job.startTime);
|
|
207
|
+
const glyph = job.kind === "monitor" ? "◉" : "▶";
|
|
208
|
+
// Show the job's latest output line as live progress; fall back to the
|
|
209
|
+
// command until there's any output. Re-read each tick by the ticker.
|
|
210
|
+
const progress = sidebarLastLine(job.logPath) || job.command;
|
|
211
|
+
pills.push(
|
|
212
|
+
`${glyph} ${jobLabel(job)}: ${progress.slice(0, PREVIEW_CHARS.progress)} (${duration})`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Drop progress-cache entries for logs no longer tracked.
|
|
217
|
+
for (const key of sidebarLineCache.keys()) {
|
|
218
|
+
if (!runningLogs.has(key)) sidebarLineCache.delete(key);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (pills.length === 0) {
|
|
222
|
+
stopSidebarTicker(reg);
|
|
223
|
+
if (reg.lastSidebarContent !== undefined) {
|
|
224
|
+
reg.lastSidebarContent = undefined;
|
|
225
|
+
ctx.ui.setWidget("background-jobs", undefined);
|
|
226
|
+
ctx.ui.setStatus("background-jobs", undefined);
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const parts = [`${runningCount} running`];
|
|
232
|
+
if (reg.completedCount > 0) parts.push(`${reg.completedCount} done`);
|
|
233
|
+
if (reg.failedCount > 0) parts.push(`${reg.failedCount} failed`);
|
|
234
|
+
const statusText = `▶ ${parts.join(", ")}`;
|
|
235
|
+
const key = `${pills.join("\n")}|${statusText}`;
|
|
236
|
+
|
|
237
|
+
if (key !== reg.lastSidebarContent) {
|
|
238
|
+
reg.lastSidebarContent = key;
|
|
239
|
+
ctx.ui.setWidget("background-jobs", pills);
|
|
240
|
+
ctx.ui.setStatus("background-jobs", ctx.ui.theme.fg("accent", statusText));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The 1 Hz ticker exists to keep running-job durations live; with no
|
|
244
|
+
// running jobs there is nothing to tick.
|
|
245
|
+
if (runningCount > 0) ensureSidebarTicker(reg, ctx);
|
|
246
|
+
else stopSidebarTicker(reg);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Per-log cache for the sidebar's live progress line: the 1 Hz ticker would
|
|
250
|
+
* otherwise re-read every running job's tail every tick even when output is
|
|
251
|
+
* static. statSync first; skip the read when the size is unchanged. */
|
|
252
|
+
const sidebarLineCache = new Map<string, { size: number; lastLine: string }>();
|
|
253
|
+
|
|
254
|
+
function sidebarLastLine(logPath: string): string {
|
|
255
|
+
let size: number;
|
|
256
|
+
try {
|
|
257
|
+
size = statSync(logPath).size;
|
|
258
|
+
} catch {
|
|
259
|
+
sidebarLineCache.delete(logPath);
|
|
260
|
+
return "";
|
|
261
|
+
}
|
|
262
|
+
const cached = sidebarLineCache.get(logPath);
|
|
263
|
+
if (cached && cached.size === size) return cached.lastLine;
|
|
264
|
+
const lastLine = readLastLine(logPath);
|
|
265
|
+
sidebarLineCache.set(logPath, { size, lastLine });
|
|
266
|
+
return lastLine;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Start the live-duration ticker if not already running. */
|
|
270
|
+
function ensureSidebarTicker(reg: BackgroundRegistry, ctx: UiContext): void {
|
|
271
|
+
if (reg.sidebarTimer) return;
|
|
272
|
+
const t = setInterval(() => {
|
|
273
|
+
try {
|
|
274
|
+
renderSidebar(reg, ctx);
|
|
275
|
+
} catch {
|
|
276
|
+
// The captured ctx went stale (session reload/fork/switch) — stop
|
|
277
|
+
// ticking rather than throw an uncaught exception in the interval.
|
|
278
|
+
stopSidebarTicker(reg);
|
|
279
|
+
}
|
|
280
|
+
}, 1000);
|
|
281
|
+
t.unref();
|
|
282
|
+
reg.sidebarTimer = t;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Stop the live-duration ticker (no running jobs, or on shutdown). */
|
|
286
|
+
export function stopSidebarTicker(reg: BackgroundRegistry): void {
|
|
287
|
+
if (reg.sidebarTimer) {
|
|
288
|
+
clearInterval(reg.sidebarTimer);
|
|
289
|
+
reg.sidebarTimer = undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─── Stats ───────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
export interface JobStats {
|
|
296
|
+
totalStarted: number;
|
|
297
|
+
running: number;
|
|
298
|
+
completed: number;
|
|
299
|
+
failed: number;
|
|
300
|
+
killed: number;
|
|
301
|
+
recentTerminal: number;
|
|
302
|
+
averageDurationMs: number;
|
|
303
|
+
totalDurationMs: number;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function getStats(reg: BackgroundRegistry): JobStats {
|
|
307
|
+
let running = 0;
|
|
308
|
+
for (const job of reg.jobs.values()) {
|
|
309
|
+
if (job.status === "running") running++;
|
|
310
|
+
}
|
|
311
|
+
const terminalCount = reg.completedCount + reg.failedCount;
|
|
312
|
+
return {
|
|
313
|
+
totalStarted: reg.totalStarted,
|
|
314
|
+
running,
|
|
315
|
+
completed: reg.completedCount,
|
|
316
|
+
failed: reg.failedCount,
|
|
317
|
+
killed: reg.killedCount,
|
|
318
|
+
recentTerminal: reg.recentTerminal.length,
|
|
319
|
+
averageDurationMs:
|
|
320
|
+
terminalCount > 0
|
|
321
|
+
? Math.round(reg.totalDurationMs / terminalCount)
|
|
322
|
+
: 0,
|
|
323
|
+
totalDurationMs: reg.totalDurationMs,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── Internal helpers ──────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
function terminalDurationMs(job: Job): number {
|
|
330
|
+
return Date.now() - job.startTime;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Status helpers (used by tools and shortcuts) ───────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/** True when the job is currently in the running state. */
|
|
336
|
+
export function isRunning(job: Job): boolean {
|
|
337
|
+
return job.status === "running";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Read only the tail of a job's log file — O(maxChars) even for large files. */
|
|
341
|
+
export function readLogTail(job: Job, maxChars: number): string {
|
|
342
|
+
return readBoundedTail(job.logPath, maxChars);
|
|
343
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
type CallArgs = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
type RenderTheme = {
|
|
6
|
+
fg(color: "muted" | "toolTitle", text: string): string;
|
|
7
|
+
bold(text: string): string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type RenderContext = {
|
|
11
|
+
lastComponent?: Component;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function renderBashAsyncCall(
|
|
15
|
+
args: unknown,
|
|
16
|
+
theme: RenderTheme,
|
|
17
|
+
context: RenderContext,
|
|
18
|
+
): Text {
|
|
19
|
+
const values = asCallArgs(args);
|
|
20
|
+
const command = nonEmptyString(values.command);
|
|
21
|
+
const commandDisplay = command === undefined
|
|
22
|
+
? styledTitle("$ ", theme) + theme.fg("muted", "...")
|
|
23
|
+
: styledTitle(`$ ${command}`, theme);
|
|
24
|
+
const timeoutSuffix = renderTimeout(values.timeout, theme);
|
|
25
|
+
|
|
26
|
+
return renderCall(commandDisplay + timeoutSuffix, context);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderCall(content: string, context: RenderContext): Text {
|
|
30
|
+
const previousComponent = context.lastComponent;
|
|
31
|
+
const text = previousComponent instanceof Text
|
|
32
|
+
? previousComponent
|
|
33
|
+
: new Text("", 0, 0);
|
|
34
|
+
text.setText(content);
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderTimeout(value: unknown, theme: RenderTheme): string {
|
|
39
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
40
|
+
? theme.fg("muted", ` (timeout ${value}s)`)
|
|
41
|
+
: "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function styledTitle(title: string, theme: RenderTheme): string {
|
|
45
|
+
return theme.fg("toolTitle", theme.bold(title));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
49
|
+
return typeof value === "string" && value.trim().length > 0
|
|
50
|
+
? value
|
|
51
|
+
: undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asCallArgs(args: unknown): CallArgs {
|
|
55
|
+
return typeof args === "object" && args !== null ? args as CallArgs : {};
|
|
56
|
+
}
|
package/src/spawn.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/spawn.ts
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { closeSync, mkdirSync, openSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** How the child ended: an exit code, or the signal that killed it. Node
|
|
7
|
+
* reports `code === null` when the child died by signal (external kill, OOM),
|
|
8
|
+
* so the signal half is what tells a crash apart from a clean exit. */
|
|
9
|
+
export interface SpawnExit {
|
|
10
|
+
code: number | null;
|
|
11
|
+
signal: NodeJS.Signals | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SpawnResult {
|
|
15
|
+
pid: number;
|
|
16
|
+
logPath: string;
|
|
17
|
+
exit: Promise<SpawnExit>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Spawn a child with stdout+stderr written directly to a file descriptor — the
|
|
22
|
+
* Claude Code pattern: the kernel writes output to disk with zero JS in the
|
|
23
|
+
* data path. Progress is read back by polling the file tail separately.
|
|
24
|
+
*
|
|
25
|
+
* Pass `command` to run `bash -c <command>`, or `file` and `fileArgs` to run a
|
|
26
|
+
* binary directly. The child is detached so the whole process group can be
|
|
27
|
+
* signalled.
|
|
28
|
+
*/
|
|
29
|
+
export function spawnWithFileOutput(args: {
|
|
30
|
+
command?: string;
|
|
31
|
+
file?: string;
|
|
32
|
+
fileArgs?: string[];
|
|
33
|
+
cwd: string;
|
|
34
|
+
logPath: string;
|
|
35
|
+
/** When set, stderr is written here instead of merged into logPath. Used by
|
|
36
|
+
* the bash_async_watch tool so stdout is a clean event stream and stderr is captured
|
|
37
|
+
* separately (readable, but never emitted as an event). */
|
|
38
|
+
errPath?: string;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}): SpawnResult {
|
|
41
|
+
ensureLogDir(args.logPath);
|
|
42
|
+
const outFd = openSync(args.logPath, "w");
|
|
43
|
+
let errFd: number;
|
|
44
|
+
try {
|
|
45
|
+
errFd = args.errPath ? openSync(args.errPath, "w") : outFd;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
closeSync(outFd);
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [bin, binArgs]: [string, string[]] = args.file
|
|
52
|
+
? [args.file, args.fileArgs ?? []]
|
|
53
|
+
: ["bash", ["-c", args.command ?? ""]];
|
|
54
|
+
|
|
55
|
+
let proc;
|
|
56
|
+
try {
|
|
57
|
+
proc = spawn(bin, binArgs, {
|
|
58
|
+
stdio: ["ignore", outFd, errFd],
|
|
59
|
+
cwd: args.cwd,
|
|
60
|
+
detached: true,
|
|
61
|
+
env: { ...process.env },
|
|
62
|
+
});
|
|
63
|
+
} finally {
|
|
64
|
+
closeSync(outFd);
|
|
65
|
+
if (errFd !== outFd) closeSync(errFd);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Build the exit promise and attach the 'error' listener BEFORE any throw,
|
|
69
|
+
// so an asynchronous spawn failure (ENOENT / EMFILE / EAGAIN) can never
|
|
70
|
+
// surface as an uncaught exception that takes pi down.
|
|
71
|
+
const exit = new Promise<SpawnExit>((resolve) => {
|
|
72
|
+
// Use 'exit' not 'close': 'close' waits for stdio to close, which
|
|
73
|
+
// includes grandchild processes that inherit file descriptors (e.g.
|
|
74
|
+
// `sleep 30 &`). 'exit' fires when the shell itself exits, returning
|
|
75
|
+
// control immediately. Output still flushes fine — the kernel writes
|
|
76
|
+
// directly to the file fd, no JS drain needed.
|
|
77
|
+
proc.on("exit", (code, signal) => resolve({ code, signal }));
|
|
78
|
+
proc.on("error", () => resolve({ code: 1, signal: null }));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!proc.pid) {
|
|
82
|
+
try { unlinkSync(args.logPath); } catch { /* best-effort */ }
|
|
83
|
+
if (args.errPath) {
|
|
84
|
+
try { unlinkSync(args.errPath); } catch { /* best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
throw new Error("Failed to spawn process");
|
|
87
|
+
}
|
|
88
|
+
const pid = proc.pid;
|
|
89
|
+
|
|
90
|
+
// Kill the process group on abort. Most callers manage abort themselves and
|
|
91
|
+
// do not pass a signal; this is offered for direct/background spawns.
|
|
92
|
+
const onAbort = () => killProcessTree(pid);
|
|
93
|
+
if (args.signal) {
|
|
94
|
+
if (args.signal.aborted) onAbort();
|
|
95
|
+
else args.signal.addEventListener("abort", onAbort, { once: true });
|
|
96
|
+
}
|
|
97
|
+
void exit.finally(() => args.signal?.removeEventListener("abort", onAbort));
|
|
98
|
+
|
|
99
|
+
proc.unref();
|
|
100
|
+
|
|
101
|
+
return { pid, logPath: args.logPath, exit };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The log dir is a constant (registry.LOG_DIR), so create it once per process
|
|
105
|
+
* instead of paying a recursive mkdir on every spawn. */
|
|
106
|
+
let logDirCreated = false;
|
|
107
|
+
function ensureLogDir(logPath: string): void {
|
|
108
|
+
if (logDirCreated) return;
|
|
109
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
110
|
+
logDirCreated = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Kill an entire process group via negative PID signal.
|
|
115
|
+
* Falls back to direct PID kill if group kill fails.
|
|
116
|
+
*/export function killProcessTree(
|
|
117
|
+
pid: number | undefined,
|
|
118
|
+
signal: NodeJS.Signals = "SIGTERM"
|
|
119
|
+
): void {
|
|
120
|
+
if (typeof pid !== "number" || pid <= 0) return;
|
|
121
|
+
try {
|
|
122
|
+
process.kill(-pid, signal);
|
|
123
|
+
} catch {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, signal);
|
|
126
|
+
} catch {
|
|
127
|
+
/* already dead */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Cheap liveness probe via signal 0. */
|
|
133
|
+
export function processExists(pid: number | undefined): boolean {
|
|
134
|
+
if (typeof pid !== "number" || pid <= 0) return false;
|
|
135
|
+
try {
|
|
136
|
+
process.kill(pid, 0);
|
|
137
|
+
return true;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared mutable state for the background-tasks extension.
|
|
3
|
+
*
|
|
4
|
+
* One instance per session, threaded through every tool and helper.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Job, ForegroundSlot } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export class BackgroundRegistry {
|
|
10
|
+
jobs = new Map<string, Job>();
|
|
11
|
+
foreground = new Map<string, ForegroundSlot>();
|
|
12
|
+
pendingDecisionJobId: string | undefined;
|
|
13
|
+
|
|
14
|
+
/** Per-job AbortController — abort() cancels all monitors/pollers for that job. */
|
|
15
|
+
jobAborts = new Map<string, AbortController>();
|
|
16
|
+
|
|
17
|
+
nonInteractive = false;
|
|
18
|
+
|
|
19
|
+
completedCount = 0;
|
|
20
|
+
failedCount = 0;
|
|
21
|
+
killedCount = 0;
|
|
22
|
+
totalStarted = 0;
|
|
23
|
+
totalDurationMs = 0;
|
|
24
|
+
recentTerminal: Job[] = [];
|
|
25
|
+
|
|
26
|
+
/** Live-duration ticker for the sidebar pills; runs while jobs are alive. */
|
|
27
|
+
sidebarTimer: NodeJS.Timeout | undefined = undefined;
|
|
28
|
+
/** Last rendered sidebar content — used to skip redundant widget updates. */
|
|
29
|
+
lastSidebarContent: string | undefined = undefined;
|
|
30
|
+
}
|