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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The monitor session lifecycle — the tricky part of the bash_async_watch tool, lifted
|
|
3
|
+
* out of the tool action so its invariants are unit-testable through a fake
|
|
4
|
+
* MonitorSource (no real spawning, no real sockets).
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities: stream each batch of new log lines as a notification,
|
|
7
|
+
* rate-limit a firehose, emit exactly one terminal event (on natural exit,
|
|
8
|
+
* kill, timeout, or firehose), and tear the source down. The tool just
|
|
9
|
+
* validates, builds a source, and hands it here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { BackgroundRegistry } from "./state.ts";
|
|
14
|
+
import {
|
|
15
|
+
DELIVER_FOLLOWUP,
|
|
16
|
+
EVENT,
|
|
17
|
+
MONITOR_MAX_LINES_PER_WINDOW,
|
|
18
|
+
MONITOR_RATE_WINDOW_MS,
|
|
19
|
+
type Job,
|
|
20
|
+
type UiContext,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
import { renderSidebar } from "./registry.ts";
|
|
23
|
+
import { startBackgroundJob, terminateJobSilently } from "./lifecycle.ts";
|
|
24
|
+
import { followLines, type MonitorFollower } from "./monitor-follow.ts";
|
|
25
|
+
import type { MonitorSource } from "./monitor-source.ts";
|
|
26
|
+
import { completionSummary, sendTaskNotification, type TerminalStatus } from "./notify.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wire a monitor's source to its event stream, terminal event, deadline, and
|
|
30
|
+
* teardown. The job must already be in the registry. Returns nothing — the
|
|
31
|
+
* session runs until the source ends, the deadline fires, or it is killed.
|
|
32
|
+
*/
|
|
33
|
+
export function startMonitorSession(args: {
|
|
34
|
+
pi: ExtensionAPI;
|
|
35
|
+
reg: BackgroundRegistry;
|
|
36
|
+
ctx: UiContext;
|
|
37
|
+
job: Job;
|
|
38
|
+
source: MonitorSource;
|
|
39
|
+
description: string;
|
|
40
|
+
persistent: boolean;
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
}): void {
|
|
43
|
+
const { pi, reg, ctx, job, source, description, persistent, timeoutMs } = args;
|
|
44
|
+
const { id, logPath } = job;
|
|
45
|
+
|
|
46
|
+
let terminalEmitted = false;
|
|
47
|
+
let finishing = false;
|
|
48
|
+
let windowStart = Date.now();
|
|
49
|
+
let windowLines = 0;
|
|
50
|
+
|
|
51
|
+
// Stream events stay live but passive (delivered as a follow-up, no wake) —
|
|
52
|
+
// they carry data the agent is actively watching and surface on the agent's
|
|
53
|
+
// next natural turn without spawning an unsolicited one. The terminal
|
|
54
|
+
// notice (stream ended / stopped / failed) is its own <task-notification>
|
|
55
|
+
// (see finishMonitor), sent the moment the source ends.
|
|
56
|
+
const emitEvent = (lines: string[]): void => {
|
|
57
|
+
if (lines.length === 0) return;
|
|
58
|
+
pi.sendMessage(
|
|
59
|
+
{
|
|
60
|
+
customType: EVENT.monitorEvent,
|
|
61
|
+
content: `◉ ${description}\n${lines.join("\n")}`,
|
|
62
|
+
display: true,
|
|
63
|
+
details: { jobId: id, description, logPath, terminal: false },
|
|
64
|
+
},
|
|
65
|
+
DELIVER_FOLLOWUP
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const follower: MonitorFollower = followLines(logPath, (lines) => {
|
|
70
|
+
if (terminalEmitted) return;
|
|
71
|
+
|
|
72
|
+
// Sliding-window firehose check.
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
if (now - windowStart > MONITOR_RATE_WINDOW_MS) {
|
|
75
|
+
windowStart = now;
|
|
76
|
+
windowLines = 0;
|
|
77
|
+
}
|
|
78
|
+
windowLines += lines.length;
|
|
79
|
+
|
|
80
|
+
emitEvent(lines);
|
|
81
|
+
|
|
82
|
+
// Don't trip the firehose guard while draining the final flush.
|
|
83
|
+
if (!finishing && windowLines > MONITOR_MAX_LINES_PER_WINDOW) {
|
|
84
|
+
stopMonitor(
|
|
85
|
+
"killed",
|
|
86
|
+
`Monitor "${description}" stopped (too many events (>${MONITOR_MAX_LINES_PER_WINDOW}/${MONITOR_RATE_WINDOW_MS / 1000}s) — restart with a tighter filter)`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// job.stop: transient teardown invoked by the kill path. Tears down the
|
|
92
|
+
// source (closes the ws socket) — the follower is stopped *and flushed* by
|
|
93
|
+
// finishMonitor on the exit path (kill → process/socket close → exit →
|
|
94
|
+
// onExit), so a user-initiated kill stays lossless.
|
|
95
|
+
job.stop = source.stop;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Emit exactly one terminal <task-notification> for the monitor. Sent
|
|
99
|
+
* before the job is marked terminal (onExit runs ahead of completeJob), so
|
|
100
|
+
* the status/summary are explicit and eviction is left to completeJob.
|
|
101
|
+
*/
|
|
102
|
+
const finishMonitor = (status: TerminalStatus, summary: string): void => {
|
|
103
|
+
if (terminalEmitted) return;
|
|
104
|
+
// Flush remaining lines first (while terminalEmitted is still false so
|
|
105
|
+
// the follower callback emits them), then the terminal notification.
|
|
106
|
+
finishing = true;
|
|
107
|
+
follower.stop(true);
|
|
108
|
+
terminalEmitted = true;
|
|
109
|
+
sendTaskNotification({ reg, pi, job, status, summary, evict: false });
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Forced stop (timeout / firehose). Emits a terminal event, then routes
|
|
113
|
+
* through the standard silent-kill path (which calls job.stop). */
|
|
114
|
+
function stopMonitor(status: TerminalStatus, summary: string): void {
|
|
115
|
+
finishMonitor(status, summary);
|
|
116
|
+
terminateJobSilently(reg, job);
|
|
117
|
+
renderSidebar(reg, ctx);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Wire exit → terminal event. shouldNotify:false because the monitor owns
|
|
121
|
+
// its own terminal event (no double-fire from completeJob). Monitors stream their
|
|
122
|
+
// own output, so the prompt-stall heuristic is nonsensical here; the oversize
|
|
123
|
+
// cap is also suppressed for persistent watches (session-length log tails are
|
|
124
|
+
// expected to grow).
|
|
125
|
+
const jobAc = startBackgroundJob({
|
|
126
|
+
reg,
|
|
127
|
+
pi,
|
|
128
|
+
ctx,
|
|
129
|
+
job,
|
|
130
|
+
exit: source.exit,
|
|
131
|
+
shouldNotify: false,
|
|
132
|
+
disablePromptStall: true,
|
|
133
|
+
disableOversizeKill: persistent,
|
|
134
|
+
onExit: ({ code, signal }) => {
|
|
135
|
+
// A signal death (external kill) is the "stopped" summary, never
|
|
136
|
+
// "stream ended". Natural exits reuse completionSummary — the job
|
|
137
|
+
// carries name: description, so the summary names the watch.
|
|
138
|
+
if (job.status === "killed" || signal !== null) {
|
|
139
|
+
finishMonitor("killed", completionSummary(job, "killed"));
|
|
140
|
+
} else if (code === 0) {
|
|
141
|
+
finishMonitor("completed", completionSummary(job, "completed"));
|
|
142
|
+
} else {
|
|
143
|
+
// completeJob marks terminal after onExit — set the exit code
|
|
144
|
+
// now so completionSummary can name it.
|
|
145
|
+
job.exitCode = code ?? undefined;
|
|
146
|
+
finishMonitor("failed", completionSummary(job, "failed"));
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Deadline (skipped for persistent watches). Cleared when the job aborts
|
|
152
|
+
// (natural exit or kill) so a short monitor doesn't keep a live timer +
|
|
153
|
+
// closure alive for the whole timeout window.
|
|
154
|
+
if (!persistent) {
|
|
155
|
+
const deadline = setTimeout(() => {
|
|
156
|
+
stopMonitor(
|
|
157
|
+
"killed",
|
|
158
|
+
`Monitor "${description}" stopped (timeout after ${Math.round(timeoutMs / 1000)}s)`
|
|
159
|
+
);
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
(deadline as NodeJS.Timeout).unref();
|
|
162
|
+
jobAc.signal.addEventListener("abort", () => clearTimeout(deadline), { once: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A monitor's event source, behind one seam.
|
|
3
|
+
*
|
|
4
|
+
* Both kinds of monitor produce the same thing the session needs: a log file
|
|
5
|
+
* whose appended lines are events, a promise that resolves when the source
|
|
6
|
+
* ends, and a teardown hook. Naming that contract lets the session (see
|
|
7
|
+
* monitor-session.ts) treat command and WebSocket monitors identically, and
|
|
8
|
+
* makes a third source (named pipe, file replay, …) a drop-in later.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnWithFileOutput, type SpawnExit } from "./spawn.ts";
|
|
12
|
+
import { openWsSource, type WsSpec } from "./monitor-ws.ts";
|
|
13
|
+
|
|
14
|
+
export interface MonitorSource {
|
|
15
|
+
/** File the follower reads; appended lines become events. */
|
|
16
|
+
logPath: string;
|
|
17
|
+
/** OS pid backing the source, or 0 when there is no process (ws). */
|
|
18
|
+
pid: number;
|
|
19
|
+
/** Human-readable label shown in the sidebar / bash_async_list list. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Resolves when the source ends (process exit, or ws close as code-only). */
|
|
22
|
+
exit: Promise<SpawnExit>;
|
|
23
|
+
/** Teardown beyond the standard process kill (closes the ws socket). */
|
|
24
|
+
stop: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Command source: a shell child whose stdout is the event stream and whose
|
|
28
|
+
* stderr is captured separately (readable, never emitted). */
|
|
29
|
+
export function spawnCommandSource(args: {
|
|
30
|
+
command: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
logPath: string;
|
|
33
|
+
errPath: string;
|
|
34
|
+
}): MonitorSource {
|
|
35
|
+
const spawned = spawnWithFileOutput({
|
|
36
|
+
command: args.command,
|
|
37
|
+
cwd: args.cwd,
|
|
38
|
+
logPath: args.logPath,
|
|
39
|
+
errPath: args.errPath,
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
logPath: args.logPath,
|
|
43
|
+
pid: spawned.pid,
|
|
44
|
+
label: args.command,
|
|
45
|
+
exit: spawned.exit,
|
|
46
|
+
// The process group is killed by the standard kill path; nothing extra.
|
|
47
|
+
stop: () => {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** WebSocket source: each text frame is appended to the log as a line. */
|
|
52
|
+
export function openWsMonitorSource(spec: WsSpec, logPath: string): MonitorSource {
|
|
53
|
+
const ws = openWsSource(spec, logPath);
|
|
54
|
+
return {
|
|
55
|
+
logPath,
|
|
56
|
+
pid: 0,
|
|
57
|
+
label: `ws ${spec.url}`,
|
|
58
|
+
// A socket has no signal — the close code maps to the code half.
|
|
59
|
+
exit: ws.exit.then((code) => ({ code, signal: null })),
|
|
60
|
+
stop: ws.close,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket source for the bash_async_watch tool.
|
|
3
|
+
*
|
|
4
|
+
* A ws monitor has no child process. Instead of spawning, it opens a WebSocket
|
|
5
|
+
* and appends each incoming frame as a line to the same `<jobId>.log` the
|
|
6
|
+
* line-follower reads — so ws and command monitors share one emitter path and
|
|
7
|
+
* the existing jobs-list / attach / Read surface works unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Compatibility: uses the runtime's global `WebSocket` (stable since Node 22).
|
|
10
|
+
* When absent, isWsSupported() is false and the tool rejects the ws source with
|
|
11
|
+
* an actionable error rather than crashing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { closeSync, mkdirSync, openSync, writeSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
16
|
+
|
|
17
|
+
export interface WsSpec {
|
|
18
|
+
url: string;
|
|
19
|
+
protocols?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WsSource {
|
|
23
|
+
/** Resolves when the socket closes (0 = clean close, 1 = error/abnormal). */
|
|
24
|
+
exit: Promise<number>;
|
|
25
|
+
/** Close the socket. Idempotent. */
|
|
26
|
+
close: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when the runtime provides a global WebSocket constructor. */
|
|
30
|
+
export function isWsSupported(): boolean {
|
|
31
|
+
return typeof (globalThis as { WebSocket?: unknown }).WebSocket === "function";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Open `spec.url`, appending each event as a line to `logPath`. The log file is
|
|
36
|
+
* created (truncated) up front so the follower has something to stat. Returns
|
|
37
|
+
* an exit promise (resolved on close) and a close handle.
|
|
38
|
+
*
|
|
39
|
+
* Throws synchronously if WebSocket is unsupported or construction fails — the
|
|
40
|
+
* caller surfaces that as a tool error.
|
|
41
|
+
*/
|
|
42
|
+
export function openWsSource(spec: WsSpec, logPath: string): WsSource {
|
|
43
|
+
if (!isWsSupported()) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"WebSocket is not available in this runtime (needs Node 22+). " +
|
|
46
|
+
"Use a command source instead, e.g. " +
|
|
47
|
+
`command: 'websocat ${spec.url}' or 'wscat -c ${spec.url}'.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Ensure the log dir exists (a ws monitor may be the first background job of
|
|
52
|
+
// the session, so LOG_DIR may not have been created by a spawn yet), then
|
|
53
|
+
// hold one appendable fd open for the socket's lifetime — one writeSync per
|
|
54
|
+
// frame instead of an open/write/close trio.
|
|
55
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
56
|
+
const logFd = openSync(logPath, "w");
|
|
57
|
+
let fdClosed = false;
|
|
58
|
+
const closeFd = (): void => {
|
|
59
|
+
if (fdClosed) return;
|
|
60
|
+
fdClosed = true;
|
|
61
|
+
try {
|
|
62
|
+
closeSync(logFd);
|
|
63
|
+
} catch {
|
|
64
|
+
/* already closed */
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const append = (line: string): void => {
|
|
69
|
+
if (fdClosed) return;
|
|
70
|
+
try {
|
|
71
|
+
writeSync(logFd, line.endsWith("\n") ? line : `${line}\n`);
|
|
72
|
+
} catch {
|
|
73
|
+
/* best-effort: a vanished log dir shouldn't take down the socket */
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const WS = (globalThis as { WebSocket: typeof WebSocket }).WebSocket;
|
|
78
|
+
const ws = new WS(spec.url, spec.protocols);
|
|
79
|
+
|
|
80
|
+
let settled = false;
|
|
81
|
+
let resolveExit!: (code: number) => void;
|
|
82
|
+
const exit = new Promise<number>((resolve) => {
|
|
83
|
+
resolveExit = resolve;
|
|
84
|
+
});
|
|
85
|
+
const settle = (code: number): void => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
closeFd();
|
|
89
|
+
resolveExit(code);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
ws.addEventListener("message", (ev: MessageEvent) => {
|
|
93
|
+
const data: unknown = ev.data;
|
|
94
|
+
if (typeof data === "string") {
|
|
95
|
+
append(data);
|
|
96
|
+
} else if (data instanceof ArrayBuffer) {
|
|
97
|
+
append(`[binary frame, ${data.byteLength} bytes]`);
|
|
98
|
+
} else if (data && typeof (data as { byteLength?: number }).byteLength === "number") {
|
|
99
|
+
append(`[binary frame, ${(data as { byteLength: number }).byteLength} bytes]`);
|
|
100
|
+
} else {
|
|
101
|
+
append("[non-text frame]");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
ws.addEventListener("error", () => {
|
|
106
|
+
append("[websocket error]");
|
|
107
|
+
// Some implementations fire error without a following close.
|
|
108
|
+
try {
|
|
109
|
+
ws.close();
|
|
110
|
+
} catch {
|
|
111
|
+
/* already closing */
|
|
112
|
+
}
|
|
113
|
+
settle(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
ws.addEventListener("close", (ev: CloseEvent) => {
|
|
117
|
+
append(`[socket closed: code ${ev.code}${ev.reason ? ` ${ev.reason}` : ""}]`);
|
|
118
|
+
settle(ev.code === 1000 || ev.code === 1005 ? 0 : 1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
exit,
|
|
123
|
+
close() {
|
|
124
|
+
try {
|
|
125
|
+
ws.close();
|
|
126
|
+
} catch {
|
|
127
|
+
/* already closed */
|
|
128
|
+
}
|
|
129
|
+
settle(0);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stall detection for background jobs.
|
|
3
|
+
*
|
|
4
|
+
* Watches a job's log file and warns the agent when output stops growing while
|
|
5
|
+
* the tail looks like an interactive prompt, or kills the job when output
|
|
6
|
+
* exceeds the size cap. Progress streaming lives in output.ts (pollFileTail).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { openSync, readSync, closeSync, statSync as fsStatSync } from "node:fs";
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
DELIVER_FOLLOWUP,
|
|
13
|
+
DELIVER_STEER,
|
|
14
|
+
EVENT,
|
|
15
|
+
MAX_LOG_BYTES,
|
|
16
|
+
STALL_CHECK_INTERVAL_MS,
|
|
17
|
+
STALL_TAIL_BYTES,
|
|
18
|
+
STALL_THRESHOLD_MS,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
import { buildTaskNotification } from "./notify.ts";
|
|
21
|
+
import { describeJob } from "./format.ts";
|
|
22
|
+
|
|
23
|
+
// --- Stall watcher -------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Detect a stalled job. When the output file:
|
|
27
|
+
* 1. exceeds MAX_LOG_BYTES, call onOversize and report the job terminated;
|
|
28
|
+
* 2. has not grown for STALL_THRESHOLD_MS and its tail matches an interactive
|
|
29
|
+
* prompt pattern, send a bg-stall warning.
|
|
30
|
+
*
|
|
31
|
+
* Callers MUST invoke the returned cancel on completion to clear the interval.
|
|
32
|
+
*/
|
|
33
|
+
export function watchStalls(args: {
|
|
34
|
+
jobId: string;
|
|
35
|
+
command: string;
|
|
36
|
+
/** Job name (bash/bash_async `description`); falls back to the command. */
|
|
37
|
+
name?: string;
|
|
38
|
+
logPath: string;
|
|
39
|
+
pi: ExtensionAPI;
|
|
40
|
+
onOversize?: () => void;
|
|
41
|
+
/** Skip the interactive-prompt stall heuristic (used for monitors). */
|
|
42
|
+
disablePromptStall?: boolean;
|
|
43
|
+
/** Skip the oversize auto-kill (used for persistent monitors). */
|
|
44
|
+
disableOversizeKill?: boolean;
|
|
45
|
+
}): () => void {
|
|
46
|
+
let lastSize = 0;
|
|
47
|
+
let lastGrowth = Date.now();
|
|
48
|
+
let lastPromptCheckSize = -1;
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
|
|
51
|
+
const tick = () => {
|
|
52
|
+
if (cancelled) return;
|
|
53
|
+
try {
|
|
54
|
+
const { size } = fsStatSync(args.logPath);
|
|
55
|
+
|
|
56
|
+
if (size > MAX_LOG_BYTES && !args.disableOversizeKill) {
|
|
57
|
+
cancelled = true;
|
|
58
|
+
if (args.onOversize) args.onOversize();
|
|
59
|
+
args.pi.sendMessage(
|
|
60
|
+
{
|
|
61
|
+
customType: EVENT.stall,
|
|
62
|
+
content: `⚠️ Background job ${args.jobId} exceeded ${MAX_LOG_BYTES / (1024 * 1024)} MiB output. Terminated.`,
|
|
63
|
+
display: true,
|
|
64
|
+
details: { jobId: args.jobId, logPath: args.logPath, command: args.command },
|
|
65
|
+
},
|
|
66
|
+
DELIVER_FOLLOWUP
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (size > lastSize) {
|
|
72
|
+
lastSize = size;
|
|
73
|
+
lastGrowth = Date.now();
|
|
74
|
+
} else if (
|
|
75
|
+
!args.disablePromptStall &&
|
|
76
|
+
Date.now() - lastGrowth >= STALL_THRESHOLD_MS &&
|
|
77
|
+
size !== lastPromptCheckSize
|
|
78
|
+
) {
|
|
79
|
+
// Output is static and past the stall threshold. Read the tail
|
|
80
|
+
// once per size — re-reading identical bytes every tick is pure
|
|
81
|
+
// waste, since the prompt verdict cannot change until it grows.
|
|
82
|
+
lastPromptCheckSize = size;
|
|
83
|
+
const fd = openSync(args.logPath, "r");
|
|
84
|
+
try {
|
|
85
|
+
const readStart = Math.max(0, size - STALL_TAIL_BYTES);
|
|
86
|
+
const toRead = Math.min(size, STALL_TAIL_BYTES);
|
|
87
|
+
const buf = Buffer.alloc(toRead);
|
|
88
|
+
readSync(fd, buf, 0, toRead, readStart);
|
|
89
|
+
const tail = buf.toString("utf-8", 0, toRead);
|
|
90
|
+
if (looksLikePrompt(tail)) {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
sendStallPrompt(args.pi, args.jobId, describeJob(args.name, args.command), args.logPath, tail);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
closeSync(fd);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
/* File may not exist yet — retry next tick. */
|
|
101
|
+
}
|
|
102
|
+
schedule();
|
|
103
|
+
};
|
|
104
|
+
// Re-arm with a fresh timer each tick (rather than refresh()) so the
|
|
105
|
+
// watcher also works under node:test mock timers. Global setTimeout —
|
|
106
|
+
// a node:timers named import is snapshotted before mock.timers.enable.
|
|
107
|
+
let timer: NodeJS.Timeout;
|
|
108
|
+
const schedule = () => {
|
|
109
|
+
timer = setTimeout(tick, STALL_CHECK_INTERVAL_MS);
|
|
110
|
+
timer.unref();
|
|
111
|
+
};
|
|
112
|
+
schedule();
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
cancelled = true;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- Prompt pattern matching ---------------------------------------------
|
|
121
|
+
|
|
122
|
+
/** Patterns that identify an interactive prompt. */
|
|
123
|
+
export const PROMPT_PATTERNS = [
|
|
124
|
+
/\(y\/n\)/i,
|
|
125
|
+
/\[y\/n\]/i,
|
|
126
|
+
/\(yes\/no\)/i,
|
|
127
|
+
/\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i,
|
|
128
|
+
/Press (any key|Enter)/i,
|
|
129
|
+
/Continue\?/i,
|
|
130
|
+
/Overwrite\?/i,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/** True when the last line of the tail matches a prompt pattern. */
|
|
134
|
+
export function looksLikePrompt(tail: string): boolean {
|
|
135
|
+
const lastLine = tail.trimEnd().split("\n").pop() ?? "";
|
|
136
|
+
return PROMPT_PATTERNS.some((p) => p.test(lastLine));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Send the interactive-prompt stall warning: Claude Code's <task-notification>
|
|
141
|
+
* shape WITHOUT a <status> tag (CC omits it deliberately — the job is still
|
|
142
|
+
* running), followed by the output tail and remediation guidance as plain
|
|
143
|
+
* text, all as ONE message content. Delivered like a completion (steer +
|
|
144
|
+
* triggerTurn). Does NOT touch the job's `notified` latch — a later real
|
|
145
|
+
* completion must still notify.
|
|
146
|
+
*/
|
|
147
|
+
function sendStallPrompt(
|
|
148
|
+
pi: ExtensionAPI,
|
|
149
|
+
jobId: string,
|
|
150
|
+
description: string,
|
|
151
|
+
logPath: string,
|
|
152
|
+
tail: string
|
|
153
|
+
): void {
|
|
154
|
+
const summary = `Async command "${description}" appears to be waiting for interactive input`;
|
|
155
|
+
const content =
|
|
156
|
+
buildTaskNotification({ taskId: jobId, outputFile: logPath, summary }) +
|
|
157
|
+
`\nLast output:\n${tail.trimEnd()}\n\n` +
|
|
158
|
+
`The command is likely blocked on an interactive prompt. Kill this task and re-run ` +
|
|
159
|
+
`with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`;
|
|
160
|
+
|
|
161
|
+
pi.sendMessage(
|
|
162
|
+
{
|
|
163
|
+
customType: EVENT.taskNotification,
|
|
164
|
+
content,
|
|
165
|
+
display: true,
|
|
166
|
+
details: { jobId, logPath, summary },
|
|
167
|
+
},
|
|
168
|
+
DELIVER_STEER
|
|
169
|
+
);
|
|
170
|
+
}
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-completion notifications — Claude Code's <task-notification> engine.
|
|
3
|
+
*
|
|
4
|
+
* Every backgrounded job that reaches a terminal state enqueues its OWN
|
|
5
|
+
* <task-notification> XML message, exactly once, the moment it exits. There
|
|
6
|
+
* is no hold-and-flush and no coalescing window: pi's steer delivery queues
|
|
7
|
+
* the message while the agent is streaming and delivers it at the next
|
|
8
|
+
* tool-call boundary (CC's 'next' priority), or starts a turn when the agent
|
|
9
|
+
* is idle (triggerTurn: true).
|
|
10
|
+
*
|
|
11
|
+
* Exactly-once is enforced by the job's `notified` latch — a check-and-set
|
|
12
|
+
* done BEFORE the send, so any path that already surfaced the outcome (a
|
|
13
|
+
* bash_async_list output/attach read, a deliberate kill) suppresses the notification.
|
|
14
|
+
* A terminal job that has been notified is evicted from the live registry;
|
|
15
|
+
* its output log stays on disk and the notification carries the path.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { DELIVER_STEER, EVENT, type Job } from "./types.ts";
|
|
20
|
+
import type { BackgroundRegistry } from "./state.ts";
|
|
21
|
+
import { forget } from "./registry.ts";
|
|
22
|
+
import { describeJob } from "./format.ts";
|
|
23
|
+
|
|
24
|
+
/** Terminal statuses a <task-notification> can carry. */
|
|
25
|
+
export type TerminalStatus = "completed" | "failed" | "killed";
|
|
26
|
+
|
|
27
|
+
/** Escape the XML special characters CC escapes inside element text. */
|
|
28
|
+
export function escapeXml(s: string): string {
|
|
29
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build Claude Code's exact <task-notification> XML block. The <tool_use_id>
|
|
34
|
+
* line is included only when a toolUseId is present; the <status> line is
|
|
35
|
+
* omitted deliberately for stall warnings (CC parity).
|
|
36
|
+
*/
|
|
37
|
+
export function buildTaskNotification(args: {
|
|
38
|
+
taskId: string;
|
|
39
|
+
toolUseId?: string;
|
|
40
|
+
outputFile: string;
|
|
41
|
+
status?: TerminalStatus;
|
|
42
|
+
summary: string;
|
|
43
|
+
}): string {
|
|
44
|
+
const lines = [
|
|
45
|
+
"<task-notification>",
|
|
46
|
+
`<task_id>${escapeXml(args.taskId)}</task_id>`,
|
|
47
|
+
...(args.toolUseId ? [`<tool_use_id>${escapeXml(args.toolUseId)}</tool_use_id>`] : []),
|
|
48
|
+
`<output_file>${escapeXml(args.outputFile)}</output_file>`,
|
|
49
|
+
...(args.status ? [`<status>${args.status}</status>`] : []),
|
|
50
|
+
`<summary>${escapeXml(args.summary)}</summary>`,
|
|
51
|
+
"</task-notification>",
|
|
52
|
+
];
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Claude Code's exact completion summary for a terminal job. `status`
|
|
58
|
+
* overrides job.status for callers that notify before the job is marked
|
|
59
|
+
* terminal (the monitor exit path).
|
|
60
|
+
*/
|
|
61
|
+
export function completionSummary(job: Job, status?: TerminalStatus): string {
|
|
62
|
+
const s = status ?? (job.status as TerminalStatus);
|
|
63
|
+
const desc = describeJob(job.name, job.command);
|
|
64
|
+
if (job.kind === "monitor") {
|
|
65
|
+
if (s === "killed") return `Monitor "${desc}" stopped`;
|
|
66
|
+
if (s === "failed") return `Monitor "${desc}" script failed (exit ${job.exitCode ?? "unknown"})`;
|
|
67
|
+
return `Monitor "${desc}" stream ended`;
|
|
68
|
+
}
|
|
69
|
+
if (s === "killed") return `Async command "${desc}" was stopped`;
|
|
70
|
+
if (s === "failed") return `Async command "${desc}" failed with exit code ${job.exitCode ?? "unknown"}`;
|
|
71
|
+
return `Async command "${desc}" completed${job.exitCode != null ? ` (exit code ${job.exitCode})` : ""}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Set the notified latch (Claude Code's markTaskNotified). Idempotent. Called
|
|
76
|
+
* by every path that surfaces a job's outcome WITHOUT the notification: kill
|
|
77
|
+
* paths (before the kill, so the exit handler skips notifying) and terminal
|
|
78
|
+
* reads (bash_async_list output / attach).
|
|
79
|
+
*/
|
|
80
|
+
export function markNotified(job: Job): void {
|
|
81
|
+
job.notified = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Send a terminal job's <task-notification>, exactly once. The latch is set
|
|
86
|
+
* BEFORE the send, so a concurrent consumer can never produce a duplicate;
|
|
87
|
+
* if the send itself throws, the notification is lost rather than retried
|
|
88
|
+
* (exactly-once), and the terminal+notified job lingers until the lazy sweep
|
|
89
|
+
* in `bash_async_list list`.
|
|
90
|
+
*
|
|
91
|
+
* On success the job is evicted from the live registry (terminal + notified)
|
|
92
|
+
* unless `evict: false` — the monitor path sends before the job is marked
|
|
93
|
+
* terminal and lets completeJob evict instead.
|
|
94
|
+
*
|
|
95
|
+
* Returns true when the notification was sent.
|
|
96
|
+
*/
|
|
97
|
+
export function sendTaskNotification(args: {
|
|
98
|
+
reg: BackgroundRegistry;
|
|
99
|
+
pi: ExtensionAPI;
|
|
100
|
+
job: Job;
|
|
101
|
+
/** Explicit terminal status when the job isn't marked terminal yet. */
|
|
102
|
+
status?: TerminalStatus;
|
|
103
|
+
/** Explicit summary (monitors compose their own). */
|
|
104
|
+
summary?: string;
|
|
105
|
+
evict?: boolean;
|
|
106
|
+
}): boolean {
|
|
107
|
+
const { reg, pi, job } = args;
|
|
108
|
+
if (job.notified) return false;
|
|
109
|
+
job.notified = true;
|
|
110
|
+
const status = args.status ?? (job.status as TerminalStatus);
|
|
111
|
+
const summary = args.summary ?? completionSummary(job, status);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
pi.sendMessage(
|
|
115
|
+
{
|
|
116
|
+
customType: EVENT.taskNotification,
|
|
117
|
+
content: buildTaskNotification({
|
|
118
|
+
taskId: job.id,
|
|
119
|
+
toolUseId: job.toolCallId || undefined,
|
|
120
|
+
outputFile: job.logPath,
|
|
121
|
+
status,
|
|
122
|
+
summary,
|
|
123
|
+
}),
|
|
124
|
+
display: true,
|
|
125
|
+
details: {
|
|
126
|
+
jobId: job.id,
|
|
127
|
+
status,
|
|
128
|
+
summary,
|
|
129
|
+
outputFile: job.logPath,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
DELIVER_STEER
|
|
133
|
+
);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error("[bg-tasks] task notification failed:", err);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (args.evict !== false) forget(reg, job);
|
|
139
|
+
return true;
|
|
140
|
+
}
|