pi-async-bash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ }