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,365 @@
1
+ /**
2
+ * `jobs` tool — manage background jobs.
3
+ *
4
+ * Actions:
5
+ * - list: show all jobs (running + recently terminal)
6
+ * - output: read a job's log tail (non-blocking peek)
7
+ * - kill: terminate a job
8
+ * - attach: follow a job's live output and wait for it to finish
9
+ * - search: regex-search all job output
10
+ * - cleanup: purge terminal jobs
11
+ * - stats: aggregate metrics
12
+ */
13
+
14
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
17
+ import type { BackgroundRegistry } from "../state.ts";
18
+ import {
19
+ isTerminalStatus,
20
+ OUTPUT_PREVIEW_CHARS,
21
+ PREVIEW_CHARS,
22
+ type UiContext,
23
+ } from "../types.ts";
24
+ import { processExists } from "../spawn.ts";
25
+ import {
26
+ cleanupTerminal,
27
+ findJob,
28
+ forget,
29
+ getStats,
30
+ readLogTail,
31
+ renderSidebar,
32
+ } from "../registry.ts";
33
+ import { formatDuration, formatJobLine, jobLabel, oneLine, textBlock } from "../format.ts";
34
+ import { streamLog } from "../output.ts";
35
+ import { searchLogs } from "../log-search.ts";
36
+ import { markNotified } from "../notify.ts";
37
+ import {
38
+ ensureCompletionPromise,
39
+ markTerminal,
40
+ terminateJobSilently,
41
+ } from "../lifecycle.ts";
42
+
43
+ /** Register the `jobs` tool. */
44
+ export function registerJobsTool(
45
+ pi: ExtensionAPI,
46
+ reg: BackgroundRegistry
47
+ ): void {
48
+ pi.registerTool({
49
+ name: "bash_async_list",
50
+ label: "Background Jobs",
51
+ description:
52
+ "Manage background jobs: list, output, kill, attach, search, cleanup, and stats.",
53
+ promptSnippet: "Inspect and manage background jobs",
54
+ promptGuidelines: [
55
+ "list: show all jobs",
56
+ "output: show the log tail for one job",
57
+ "kill: terminate a job",
58
+ "attach: follow a job's live output and wait for it to finish (use output for a non-blocking peek)",
59
+ "search: regex-search all job output",
60
+ "cleanup: purge terminal jobs",
61
+ "stats: show aggregate metrics",
62
+ "Completion notices are informational — call 'output' on a FAILED job, or on any job whose output is the deliverable (e.g. a test/build you must report). Don't call it just to acknowledge a completed job.",
63
+ ],
64
+ parameters: Type.Object({
65
+ action: StringEnum(
66
+ [
67
+ "list",
68
+ "output",
69
+ "kill",
70
+ "attach",
71
+ "search",
72
+ "cleanup",
73
+ "stats",
74
+ ] as const,
75
+ { description: "Action to perform" }
76
+ ),
77
+ jobId: Type.Optional(Type.String({ description: "Job ID" })),
78
+ pattern: Type.Optional(
79
+ Type.String({ description: "Regex pattern for search" })
80
+ ),
81
+ wait: Type.Optional(
82
+ Type.Boolean({
83
+ description: "Whether attach should wait for completion (default: true)",
84
+ })
85
+ ),
86
+ }),
87
+
88
+ async execute(_toolCallId, params, signal, onUpdate, ctx): Promise<AgentToolResult<undefined>> {
89
+ const p = params as {
90
+ action: "list" | "output" | "kill" | "attach" | "search" | "cleanup" | "stats";
91
+ jobId?: string;
92
+ pattern?: string;
93
+ wait?: boolean;
94
+ };
95
+ switch (p.action) {
96
+ case "list":
97
+ return listAction(reg);
98
+ case "output":
99
+ return await outputAction(reg, p.jobId!);
100
+ case "kill":
101
+ return await killAction(reg, p.jobId!, ctx);
102
+ case "attach":
103
+ return await attachAction(reg, p.jobId!, p.wait ?? true, signal, onUpdate, ctx);
104
+ case "search":
105
+ return await searchAction(reg, p.pattern ?? "");
106
+ case "cleanup":
107
+ return cleanupAction(reg, ctx);
108
+ case "stats":
109
+ return statsAction(reg);
110
+ }
111
+ },
112
+ });
113
+ }
114
+
115
+ // ─── list: show all jobs ─────────────────────────────────────────────────
116
+
117
+ function listAction(reg: BackgroundRegistry): AgentToolResult<undefined> {
118
+ // Lazy eviction sweep: terminal jobs whose outcome was already surfaced
119
+ // (notified — via the <task-notification>, a kill, or a read) leave the
120
+ // live registry here. Their output logs stay on disk.
121
+ for (const job of Array.from(reg.jobs.values())) {
122
+ if (isTerminalStatus(job.status) && job.notified) forget(reg, job);
123
+ }
124
+ const running = Array.from(reg.jobs.values()).filter(
125
+ (j) => j.status === "running"
126
+ );
127
+ const recent = reg.recentTerminal.slice(-5).reverse();
128
+ const lines = [
129
+ ...running.map((j) => formatJobLine(j)),
130
+ ...recent.map((j) => formatJobLine(j)),
131
+ ];
132
+ return {
133
+ content: [
134
+ textBlock(
135
+ lines.length > 0
136
+ ? `Background Jobs:\n${lines.join("\n")}`
137
+ : "No background jobs"
138
+ ),
139
+ ],
140
+ details: undefined,
141
+ };
142
+ }
143
+
144
+ // ─── output: read a job's log tail ───────────────────────────────────────
145
+
146
+ async function outputAction(
147
+ reg: BackgroundRegistry,
148
+ jobId: string
149
+ ): Promise<AgentToolResult<undefined>> {
150
+ const job = findJob(reg, jobId);
151
+ if (!job) throw new Error(`No task found with ID: ${jobId}`);
152
+ // CC's TaskOutputTool: a successful read of a TERMINAL job's output marks
153
+ // it notified, suppressing the separate <task-notification>. Peeking at a
154
+ // still-running job does NOT mark — its completion must still notify.
155
+ if (isTerminalStatus(job.status)) markNotified(job);
156
+ const out = readLogTail(job, OUTPUT_PREVIEW_CHARS).trimEnd();
157
+ const label = jobLabel(job);
158
+ return {
159
+ content: [
160
+ textBlock(
161
+ out
162
+ ? `Output for ${label} (${job.status})\n${out}`
163
+ : `No output yet for ${label} (${job.status}). Log: ${job.logPath}`
164
+ ),
165
+ ],
166
+ details: undefined,
167
+ };
168
+ }
169
+
170
+ // ─── kill: terminate a job ───────────────────────────────────────────────
171
+
172
+ async function killAction(
173
+ reg: BackgroundRegistry,
174
+ jobId: string,
175
+ ctx: UiContext
176
+ ): Promise<AgentToolResult<undefined>> {
177
+ const job = findJob(reg, jobId);
178
+ if (!job) throw new Error(`No task found with ID: ${jobId}`);
179
+ if (isTerminalStatus(job.status)) {
180
+ throw new Error(`Job is not running: ${job.id}`);
181
+ }
182
+ // terminateJobSilently latches `notified` BEFORE the kill, so the exit
183
+ // handler skips the <task-notification> — this result IS the outcome.
184
+ terminateJobSilently(reg, job);
185
+ renderSidebar(reg, ctx);
186
+ // Claude Code's TaskStopTool result string (command collapsed to one line).
187
+ return {
188
+ content: [
189
+ textBlock(`Successfully stopped task: ${job.id} (${oneLine(job.command)})`),
190
+ ],
191
+ details: undefined,
192
+ };
193
+ }
194
+
195
+ // ─── attach: follow live output until completion ─────────────────────────
196
+
197
+ async function attachAction(
198
+ reg: BackgroundRegistry,
199
+ jobId: string,
200
+ waitForCompletion: boolean,
201
+ signal: AbortSignal | undefined,
202
+ onUpdate: ((u: { content: Array<{ type: "text"; text: string }>; details: undefined }) => void) | undefined,
203
+ ctx: UiContext
204
+ ): Promise<AgentToolResult<undefined>> {
205
+ const job = findJob(reg, jobId);
206
+ if (!job) throw new Error(`No task found with ID: ${jobId}`);
207
+ const label = jobLabel(job);
208
+
209
+ if (job.status === "running" && waitForCompletion) {
210
+ ensureCompletionPromise(job);
211
+ // We're actively following this job — suppress its separate completion
212
+ // notification so the attach result is the single notification. Undone
213
+ // on the abort path below, so a job we detach from still reports when
214
+ // it ends.
215
+ job.notified = true;
216
+
217
+ // Bail early if the OS process already died.
218
+ if (job.pid > 0 && !processExists(job.pid)) {
219
+ markTerminal(job, "failed");
220
+ }
221
+
222
+ onUpdate?.({
223
+ content: [
224
+ textBlock(`Following ${label} live output — waiting for it to finish…`),
225
+ ],
226
+ details: undefined,
227
+ });
228
+
229
+ // Stream the live log tail while we wait, so "attach" shows progress
230
+ // instead of sitting silent.
231
+ const poller = streamLog(job.logPath, onUpdate);
232
+ let onAbort: (() => void) | undefined;
233
+ try {
234
+ if (signal && !signal.aborted) {
235
+ const abortPromise = new Promise<void>((resolve) => {
236
+ onAbort = resolve;
237
+ signal.addEventListener("abort", onAbort, { once: true });
238
+ });
239
+ await Promise.race([job.donePromise, abortPromise]);
240
+ } else {
241
+ await job.donePromise;
242
+ }
243
+ } finally {
244
+ poller.stop();
245
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
246
+ }
247
+
248
+ if (job.status === "running") {
249
+ // Aborted before completion — we never reported the finish, so let
250
+ // the job's own completion notification fire later.
251
+ job.notified = false;
252
+ return {
253
+ content: [
254
+ textBlock(
255
+ `Stopped following ${label} — it's still running in the background. Use bash_async_list output to check on it.`
256
+ ),
257
+ ],
258
+ details: undefined,
259
+ };
260
+ }
261
+ }
262
+
263
+ const message = `${label} finished. Status: ${job.status}`;
264
+ ctx.ui.notify(message, job.status === "failed" ? "error" : "info");
265
+ // The attach result IS the outcome notification — mark it notified so the
266
+ // <task-notification> is suppressed (CC parity). Covers both the "attached
267
+ // to an already-terminal job" and "waited then finished" cases; idempotent
268
+ // with the running-branch set above.
269
+ markNotified(job);
270
+ return {
271
+ content: [textBlock(`${message}. Use bash_async_list output for the full log.`)],
272
+ details: undefined,
273
+ };
274
+ }
275
+
276
+ // ─── search ──────────────────────────────────────────────────────────────
277
+
278
+ const SEARCH_DISPLAY_LIMIT_PER_JOB = 20;
279
+
280
+ async function searchAction(
281
+ reg: BackgroundRegistry,
282
+ pattern: string
283
+ ): Promise<AgentToolResult<undefined>> {
284
+ if (!pattern) throw new Error("search action requires a pattern");
285
+
286
+ let re: RegExp;
287
+ try {
288
+ re = new RegExp(pattern);
289
+ } catch (err) {
290
+ throw new Error(`Invalid regex: ${(err as Error).message}`);
291
+ }
292
+
293
+ const result = await searchLogs({
294
+ jobs: [...Array.from(reg.jobs.values()), ...reg.recentTerminal],
295
+ pattern: re,
296
+ maxHitsPerJob: SEARCH_DISPLAY_LIMIT_PER_JOB,
297
+ maxLineChars: PREVIEW_CHARS.line,
298
+ });
299
+
300
+ if (result.totalHits === 0) {
301
+ return {
302
+ content: [textBlock(`No matches for /${pattern}/ in any job log.`)],
303
+ details: undefined,
304
+ };
305
+ }
306
+
307
+ const blocks = result.groups.map((group) => {
308
+ const headLabel = group.name ? `${group.name} (${group.jobId})` : group.jobId;
309
+ const body = group.hits
310
+ .map((h) => ` ${h.path}:${h.line}: ${h.text}`)
311
+ .join("\n");
312
+ const more = group.count > group.hits.length
313
+ ? `\n ... and ${group.count - group.hits.length} more`
314
+ : "";
315
+ return `${headLabel} (${group.count} matches)\n${body}${more}`;
316
+ });
317
+
318
+ return {
319
+ content: [
320
+ textBlock(
321
+ `Found ${result.totalHits} matches for /${pattern}/ across ${result.groups.length} job(s):\n\n${blocks.join("\n\n")}`
322
+ ),
323
+ ],
324
+ details: undefined,
325
+ };
326
+ }
327
+
328
+ // ─── cleanup ─────────────────────────────────────────────────────────────
329
+
330
+ function cleanupAction(
331
+ reg: BackgroundRegistry,
332
+ ctx: UiContext
333
+ ): AgentToolResult<undefined> {
334
+ const { purged, bytesReclaimed } = cleanupTerminal(reg);
335
+ renderSidebar(reg, ctx);
336
+ const kb = Math.round(bytesReclaimed / 1024);
337
+ return {
338
+ content: [
339
+ textBlock(
340
+ `Cleaned up ${purged} terminal job(s). Reclaimed ${kb} KiB of disk.`
341
+ ),
342
+ ],
343
+ details: undefined,
344
+ };
345
+ }
346
+
347
+ // ─── stats ───────────────────────────────────────────────────────────────
348
+
349
+ function statsAction(reg: BackgroundRegistry): AgentToolResult<undefined> {
350
+ const s = getStats(reg);
351
+ const lines = [
352
+ `Total started: ${s.totalStarted}`,
353
+ `Currently running: ${s.running}`,
354
+ `Completed: ${s.completed}`,
355
+ `Failed: ${s.failed}`,
356
+ `Killed: ${s.killed}`,
357
+ `Recent terminal: ${s.recentTerminal}`,
358
+ `Average duration: ${formatDuration(s.averageDurationMs)}`,
359
+ `Total CPU time: ${formatDuration(s.totalDurationMs)}`,
360
+ ];
361
+ return {
362
+ content: [textBlock("Background Jobs Stats:\n" + lines.join("\n"))],
363
+ details: undefined,
364
+ };
365
+ }
@@ -0,0 +1,165 @@
1
+ // src/tools/monitor.ts
2
+ //
3
+ // `monitor` tool — a streaming-event background watch. Each stdout line (or
4
+ // WebSocket text frame) becomes one notification delivered into the agent's
5
+ // turn. This is distinct from bash_async/run_async (one notification on
6
+ // completion): monitor is for per-event streams (tail -f | grep, poll loops,
7
+ // file watches, ws feeds), not one-shot "wait until done".
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "@earendil-works/pi-ai";
11
+ import type { BackgroundRegistry } from "../state.ts";
12
+ import {
13
+ MONITOR_DEFAULT_TIMEOUT_MS,
14
+ MONITOR_MAX_TIMEOUT_MS,
15
+ type UiContext,
16
+ } from "../types.ts";
17
+ import { add, createRunningJob, errPathFor, newJobId, logPathFor } from "../registry.ts";
18
+ import { assertJobSlot, isBlankCommand, requireExistingCwd } from "../lifecycle.ts";
19
+ import { isWsSupported, type WsSpec } from "../monitor-ws.ts";
20
+ import {
21
+ openWsMonitorSource,
22
+ spawnCommandSource,
23
+ type MonitorSource,
24
+ } from "../monitor-source.ts";
25
+ import { startMonitorSession } from "../monitor-session.ts";
26
+ import { textBlock } from "../format.ts";
27
+
28
+ type MonitorCtx = UiContext & { cwd: string };
29
+
30
+ interface MonitorParams {
31
+ command?: string;
32
+ ws?: WsSpec;
33
+ description: string;
34
+ persistent?: boolean;
35
+ timeout_ms?: number;
36
+ }
37
+
38
+ export function registerMonitorTool(pi: ExtensionAPI, reg: BackgroundRegistry): void {
39
+ pi.registerTool({
40
+ name: "bash_async_watch",
41
+ label: "Monitor",
42
+ description:
43
+ "Stream events from a long-running process: each stdout line (or WebSocket " +
44
+ "text frame) becomes one notification, delivered while you keep working. " +
45
+ "Use this for per-event streams — NOT for one-shot 'wait until done' (use " +
46
+ "bash run_async for that). Exit ends the watch.",
47
+ promptSnippet:
48
+ "Stream per-event notifications from a process, log, poll loop, or WebSocket",
49
+ promptGuidelines: [
50
+ "Pick by notification count: ONE ('tell me when done') → bash run_async with an `until` loop that exits; ONE-PER-EVENT → bash_async_watch.",
51
+ "Don't use an unbounded command (tail -f, while true, inotifywait -m) for a single notification — it never exits and stays armed until timeout.",
52
+ "Every pipe stage must flush per line: grep needs --line-buffered, awk needs fflush(); never pipe to `head` (it buffers until N matches).",
53
+ "Silence is not success: your filter must match failure signatures too (e.g. grep -E --line-buffered 'done|Traceback|Error|FAILED|Killed|OOM'), or a crash looks identical to 'still running'.",
54
+ "Only stdout is the event stream; merge stderr with 2>&1 if its failures should notify. Poll remote APIs at 30s+, local checks at 0.5–1s, and guard transient failures with `|| true`.",
55
+ "Give a specific description — it is shown on every notification.",
56
+ "Use persistent:true for session-length watches (PR monitoring, log tails); stop it with the bash_async_list tool (action='kill').",
57
+ "Use the ws source for a WebSocket feed instead of `command: 'websocat …'` — each text frame becomes one event.",
58
+ ],
59
+ parameters: Type.Object({
60
+ command: Type.Optional(
61
+ Type.String({ description: "Shell script; each stdout line is an event. Mutually exclusive with ws." })
62
+ ),
63
+ ws: Type.Optional(
64
+ Type.Object(
65
+ {
66
+ url: Type.String({ description: "WebSocket URL (ws:// or wss://)" }),
67
+ protocols: Type.Optional(Type.Array(Type.String())),
68
+ },
69
+ { description: "WebSocket source; each text frame is an event. Mutually exclusive with command." }
70
+ )
71
+ ),
72
+ description: Type.String({ description: "Specific description, shown on every notification." }),
73
+ persistent: Type.Optional(
74
+ Type.Boolean({ description: "Run for the whole session (no timeout). Stop via bash_async_list action='kill'. Default false." })
75
+ ),
76
+ timeout_ms: Type.Optional(
77
+ Type.Number({ description: "Kill after this deadline (default 300000, max 3600000). Ignored when persistent." })
78
+ ),
79
+ }),
80
+
81
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
82
+ const p = params as MonitorParams;
83
+ const mctx = ctx as MonitorCtx;
84
+
85
+ // --- Validation: command XOR ws -------------------------------
86
+ const hasCommand = typeof p.command === "string" && !isBlankCommand(p.command);
87
+ const hasWs = !!p.ws && typeof p.ws.url === "string" && p.ws.url.length > 0;
88
+ if (hasCommand && hasWs) {
89
+ throw new Error("Provide either `command` or `ws`, not both.");
90
+ }
91
+ if (!hasCommand && !hasWs) {
92
+ throw new Error("A monitor needs a `command` or a `ws` source.");
93
+ }
94
+ if (!p.description || p.description.trim().length === 0) {
95
+ throw new Error("`description` is required (shown on every notification).");
96
+ }
97
+ if (hasWs && !isWsSupported()) {
98
+ throw new Error(
99
+ "WebSocket is not available in this runtime (needs Node 22+). " +
100
+ `Use command: 'websocat ${p.ws!.url}' or 'wscat -c ${p.ws!.url}' instead.`
101
+ );
102
+ }
103
+ if (hasCommand) requireExistingCwd(mctx.cwd);
104
+ assertJobSlot(reg);
105
+
106
+ const description = p.description.trim();
107
+ const persistent = p.persistent === true;
108
+ const timeoutMs = Math.min(
109
+ p.timeout_ms && p.timeout_ms > 0 ? p.timeout_ms : MONITOR_DEFAULT_TIMEOUT_MS,
110
+ MONITOR_MAX_TIMEOUT_MS
111
+ );
112
+
113
+ const id = newJobId("monitor", reg);
114
+ const logPath = logPathFor(id);
115
+
116
+ // Build the event source (command or ws) behind one seam, then hand
117
+ // it to the session, which owns the streaming/terminal lifecycle.
118
+ const source: MonitorSource = hasWs
119
+ ? openWsMonitorSource(p.ws!, logPath)
120
+ : spawnCommandSource({
121
+ command: p.command!,
122
+ cwd: mctx.cwd,
123
+ logPath,
124
+ errPath: errPathFor(id),
125
+ });
126
+
127
+ const job = createRunningJob({
128
+ id,
129
+ name: description,
130
+ command: source.label,
131
+ pid: source.pid,
132
+ logPath,
133
+ toolCallId: _toolCallId,
134
+ kind: "monitor",
135
+ });
136
+ add(reg, job);
137
+
138
+ startMonitorSession({
139
+ pi,
140
+ reg,
141
+ ctx: mctx,
142
+ job,
143
+ source,
144
+ description,
145
+ persistent,
146
+ timeoutMs,
147
+ });
148
+
149
+ const sourceDesc = hasWs ? `WebSocket ${p.ws!.url}` : "command";
150
+ const deadlineDesc = persistent
151
+ ? "persistent (stop via bash_async_list action='kill')"
152
+ : `timeout ${Math.round(timeoutMs / 1000)}s`;
153
+ return {
154
+ content: [
155
+ textBlock(
156
+ `Monitor ${id} started — ${sourceDesc}, ${deadlineDesc}. ` +
157
+ `Events ("${description}") will arrive as notifications. ` +
158
+ `Output: ${logPath}`
159
+ ),
160
+ ],
161
+ details: undefined,
162
+ };
163
+ },
164
+ });
165
+ }
package/src/types.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Type definitions and shared constants for the background-tasks extension.
3
+ */
4
+
5
+ import type { TUI, Component, KeybindingsManager } from "@earendil-works/pi-tui";
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
+ import type { ChildProcess } from "node:child_process";
8
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
9
+
10
+ // --- Configuration constants ---
11
+ export const DEFAULT_TIMEOUT_MS = 120_000;
12
+ export const QUICK_COMPLETION_MS = 2_000;
13
+ export const FOREGROUND_TAIL_BYTES = 4_096;
14
+ export const STALL_CHECK_INTERVAL_MS = 5_000;
15
+ export const STALL_THRESHOLD_MS = 45_000;
16
+ export const STALL_TAIL_BYTES = 1024;
17
+ export const MAX_LOG_BYTES = 100 * 1024 * 1024;
18
+ export const OUTPUT_PREVIEW_CHARS = 12_000;
19
+ export const RECENT_TERMINAL_KEEP = 20;
20
+ export const MAX_CONCURRENT_JOBS = 16;
21
+
22
+ // --- Monitor (streaming-event) constants ---
23
+ /** Poll cadence for the line-accurate follower. Lines read within one tick are
24
+ * batched into a single event — so this doubles as the ~200ms batch window. */
25
+ export const MONITOR_POLL_MS = 200;
26
+ /** Default streaming watch deadline (matches Claude Code's Monitor). */
27
+ export const MONITOR_DEFAULT_TIMEOUT_MS = 300_000;
28
+ /** Hard ceiling on a monitor's deadline. */
29
+ export const MONITOR_MAX_TIMEOUT_MS = 3_600_000;
30
+ /** Sliding window for firehose detection. */
31
+ export const MONITOR_RATE_WINDOW_MS = 10_000;
32
+ /** Max emitted lines per window before a monitor is auto-stopped. */
33
+ export const MONITOR_MAX_LINES_PER_WINDOW = 500;
34
+
35
+ export const PREVIEW_CHARS = {
36
+ sidebar: 25,
37
+ taskList: 40,
38
+ detail: 50,
39
+ line: 80,
40
+ /** Live progress line shown in the sidebar pill. */
41
+ progress: 60,
42
+ } as const;
43
+
44
+ // --- Domain types ---
45
+ /** Claude Code's task-status enum. "pending" exists for parity (a task that is
46
+ * registered but not yet started); every spawn path here starts "running". */
47
+ export type JobStatus = "pending" | "running" | "completed" | "failed" | "killed";
48
+
49
+ /** True once the job has reached a terminal state (completed | failed | killed). */
50
+ export function isTerminalStatus(status: JobStatus): boolean {
51
+ return status === "completed" || status === "failed" || status === "killed";
52
+ }
53
+
54
+ /** What kind of asynchronous job this is. "shell" is the default; "monitor"
55
+ * is a streaming-event watch from the bash_async_watch tool. */
56
+ export type JobKind = "shell" | "monitor";
57
+
58
+ /** Typed job identifiers use a kind prefix, the spawning process identifier, and
59
+ * eight random base36 characters (for example, `b1234-7f3k9a2x`). */
60
+ export const JOB_ID_PREFIX: Record<JobKind, string> = {
61
+ shell: "b",
62
+ monitor: "m",
63
+ };
64
+
65
+ export interface Job {
66
+ id: string;
67
+ name?: string;
68
+ command: string;
69
+ pid: number;
70
+ startTime: number;
71
+ status: JobStatus;
72
+ exitCode?: number;
73
+ logPath: string;
74
+ proc?: ChildProcess;
75
+ toolCallId: string;
76
+ donePromise?: Promise<void>;
77
+ resolveDone?: () => void;
78
+ /** Exactly-once latch for the terminal <task-notification> (Claude Code's
79
+ * `notified` flag). Set BEFORE the notification send, before a deliberate
80
+ * kill, and when the agent reads the outcome via bash_async_list output/attach — any
81
+ * path that already surfaced the result suppresses the notification. */
82
+ notified?: boolean;
83
+ isBackgrounded: boolean;
84
+ /** Defaults to "shell" when absent. */
85
+ kind?: JobKind;
86
+ /** Transient teardown hook (follower + ws socket). */
87
+ stop?: () => void;
88
+ }
89
+
90
+ export type BackgroundReason = "manual" | "timeout";
91
+
92
+ /** Transient handle for an in-flight foreground bash command, keyed by
93
+ * toolCallId in the registry. Async handoff and the timeout timer call
94
+ * requestPause to continue the command asynchronously. */
95
+ export interface ForegroundSlot {
96
+ requestPause: (reason: BackgroundReason) => void;
97
+ }
98
+
99
+ // --- Event types ---
100
+ export const EVENT = {
101
+ stall: "bg-stall",
102
+ taskNotification: "task-notification",
103
+ monitorEvent: "bg-monitor-event",
104
+ } as const;
105
+
106
+ export type EventName = (typeof EVENT)[keyof typeof EVENT];
107
+
108
+ // --- Deliver options ---
109
+ /** Steer the message into the current/next turn AND wake the agent.
110
+ * pi queues it while the agent is streaming and delivers it at the next
111
+ * tool-call boundary — Claude Code's 'next' priority. Use when the message
112
+ * IS something the agent must react to now: a background job's terminal
113
+ * <task-notification>, a stall warning, a deadline decision. */
114
+ export const DELIVER_STEER = { deliverAs: "steer", triggerTurn: true } as const;
115
+ /** Queue the message behind the current turn as a PASSIVE follow-up. The agent
116
+ * picks it up on its next natural turn (when the user re-engages or the
117
+ * current turn ends) but it does NOT spawn a new turn on its own. Monitor
118
+ * stream events are informational and never force an unsolicited
119
+ * acknowledgment or starve user input.
120
+ * NOTE: sendMessage-only — `pi.sendUserMessage` rejects `triggerTurn` and
121
+ * takes just `{ deliverAs: "followUp" }`. */
122
+ export const DELIVER_FOLLOWUP = { deliverAs: "followUp", triggerTurn: false } as const;
123
+
124
+ // --- UI context ---
125
+ export interface UiContext {
126
+ ui: {
127
+ notify(message: string, level?: "info" | "warning" | "error"): void;
128
+ setWidget(
129
+ name: string,
130
+ content: string[] | undefined,
131
+ options?: { placement?: "aboveEditor" | "belowEditor" }
132
+ ): void;
133
+ setStatus(name: string, content: unknown): void;
134
+ theme: { fg(colour: string, text: string): string };
135
+ select(title: string, options: string[]): Promise<string | undefined>;
136
+ editor(title: string, content: string): Promise<string | undefined>;
137
+ custom?<T>(
138
+ factory: (
139
+ tui: TUI,
140
+ theme: Theme,
141
+ keybindings: KeybindingsManager,
142
+ done: (result: T) => void,
143
+ ) => Component,
144
+ ): Promise<T>;
145
+ };
146
+ }
147
+
148
+ export type ToolResult = AgentToolResult<unknown>;