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,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>;
|