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,116 @@
1
+ // src/tools/bash-bg.ts
2
+ //
3
+ // `bash_async` starts a Bash command asynchronously and returns its job ID.
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "@earendil-works/pi-ai";
7
+ import { appendFileSync } from "node:fs";
8
+ import type { BackgroundRegistry } from "../state.ts";
9
+ import { isTerminalStatus, type UiContext } from "../types.ts";
10
+ import { killProcessTree, spawnWithFileOutput } from "../spawn.ts";
11
+ import { add, createRunningJob, newJobId, logPathFor } from "../registry.ts";
12
+ import {
13
+ assertJobSlot,
14
+ detectBlockedSleep,
15
+ isAutoBackgroundAllowed,
16
+ isBlankCommand,
17
+ requireExistingCwd,
18
+ SLEEP_WAIT_GUIDANCE,
19
+ startBackgroundJob,
20
+ } from "../lifecycle.ts";
21
+ import { textBlock } from "../format.ts";
22
+ import { renderBashAsyncCall } from "../render.ts";
23
+
24
+ type BashAsyncContext = UiContext & { cwd: string };
25
+
26
+ /** Register immediate asynchronous Bash execution. */
27
+ export function registerBashBgTool(pi: ExtensionAPI, reg: BackgroundRegistry): void {
28
+ pi.registerTool({
29
+ name: "bash_async",
30
+ label: "Async Bash",
31
+ description: "Start a Bash command asynchronously. Output is saved to /tmp/pi-bg/<jobId>.log.",
32
+ promptSnippet: "Start long-running Bash commands asynchronously",
33
+ promptGuidelines: [
34
+ "Use bash_async when a command should start asynchronously immediately.",
35
+ "Use bash_async_watch for per-event streams; bash_async reports one terminal outcome.",
36
+ "Do not start a fixed sleep asynchronously. Use bash_async_list action='attach', bash_async_watch, or an until loop that exits when ready.",
37
+ "Give the job a name when it will be easier to track with bash_async_list.",
38
+ ],
39
+ parameters: Type.Object({
40
+ command: Type.String({ description: "Bash command to run" }),
41
+ name: Type.Optional(Type.String({ description: "Label shown by bash_async_list" })),
42
+ timeout: Type.Optional(Type.Number({ description: "Decision timeout in seconds" })),
43
+ notify: Type.Optional(Type.Boolean({ description: "Send terminal notification (default: true)" })),
44
+ }),
45
+ renderCall: renderBashAsyncCall,
46
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
47
+ const input = params as { command: string; name?: string; timeout?: number; notify?: boolean };
48
+ const asyncContext = ctx as BashAsyncContext;
49
+ if (isBlankCommand(input.command)) throw new Error("Command is empty.");
50
+ const sleepMatch = detectBlockedSleep(input.command);
51
+ if (sleepMatch) throw new Error(`Blocked: ${sleepMatch}. ${SLEEP_WAIT_GUIDANCE}`);
52
+ requireExistingCwd(asyncContext.cwd);
53
+ assertJobSlot(reg);
54
+
55
+ const id = newJobId("shell", reg);
56
+ const logPath = logPathFor(id);
57
+ const spawned = spawnWithFileOutput({ command: input.command, cwd: asyncContext.cwd, logPath });
58
+ const job = createRunningJob({
59
+ id,
60
+ name: input.name,
61
+ command: input.command,
62
+ pid: spawned.pid,
63
+ logPath,
64
+ toolCallId,
65
+ });
66
+ add(reg, job);
67
+ const jobAbort = startBackgroundJob({
68
+ reg,
69
+ pi,
70
+ ctx: asyncContext,
71
+ job,
72
+ exit: spawned.exit,
73
+ shouldNotify: input.notify !== false,
74
+ });
75
+ scheduleDecisionTimeout(reg, asyncContext, job, input.timeout, jobAbort, logPath);
76
+
77
+ return {
78
+ content: [textBlock(
79
+ `Command running asynchronously with ID: ${id}.` +
80
+ `${input.name ? ` Name: ${input.name}.` : ""} Output is being written to: ${logPath}`,
81
+ )],
82
+ details: undefined,
83
+ };
84
+ },
85
+ });
86
+ }
87
+
88
+ function scheduleDecisionTimeout(
89
+ reg: BackgroundRegistry,
90
+ ctx: BashAsyncContext,
91
+ job: ReturnType<typeof createRunningJob>,
92
+ timeout: number | undefined,
93
+ jobAbort: AbortController,
94
+ logPath: string,
95
+ ): void {
96
+ if (!timeout) return;
97
+ const timer = setTimeout(() => {
98
+ if (isTerminalStatus(job.status) || reg.nonInteractive) return;
99
+ if (!isAutoBackgroundAllowed(job.command)) {
100
+ try {
101
+ appendFileSync(logPath, `Command timed out after ${timeout}s\n`);
102
+ } catch {
103
+ // Process termination remains correct if diagnostics cannot be written.
104
+ }
105
+ killProcessTree(job.pid, "SIGTERM");
106
+ return;
107
+ }
108
+ reg.pendingDecisionJobId = job.id;
109
+ ctx.ui.notify(
110
+ `Async Bash job ${job.id} exceeded ${timeout}s. Use bash_async_decide to keep, stop, or inspect it.`,
111
+ "warning",
112
+ );
113
+ }, timeout * 1000);
114
+ timer.unref();
115
+ jobAbort.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
116
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared bash parameter schema (TypeBox) used by the overridden `bash` tool.
3
+ */
4
+
5
+ import { Type } from "@earendil-works/pi-ai";
6
+
7
+ export const bashParamSchema = Type.Object({
8
+ command: Type.String({ description: "Shell command to run" }),
9
+ timeout: Type.Optional(
10
+ Type.Number({ description: "Timeout in seconds (default: 120)" })
11
+ ),
12
+ run_async: Type.Optional(
13
+ Type.Boolean({
14
+ description:
15
+ "Set to true to run this command in the background immediately. " +
16
+ "Output is saved to /tmp/pi-bg/<jobId>.log.",
17
+ })
18
+ ),
19
+ description: Type.Optional(
20
+ Type.String({ description: "Short description of what this command does" })
21
+ ),
22
+ });
@@ -0,0 +1,347 @@
1
+ /**
2
+ * `bash` tool override.
3
+ *
4
+ * Single file-descriptor backend (no tmux):
5
+ * - run_async=true starts asynchronous execution and returns a job handle
6
+ * - foreground commands race completion against asynchronous handoff
7
+ * - a 2s quick-completion window skips handoff machinery
8
+ * - `/bash-async`, cooperative input, or the timeout timer move a command to async execution
9
+ */
10
+
11
+ import type {
12
+ AgentToolResult,
13
+ AgentToolUpdateCallback,
14
+ } from "@earendil-works/pi-agent-core";
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import {
17
+ createBashToolDefinition,
18
+ type BashToolDetails,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import { appendFileSync, unlinkSync } from "node:fs";
21
+ import type { BackgroundRegistry } from "../state.ts";
22
+ import {
23
+ DEFAULT_TIMEOUT_MS,
24
+ OUTPUT_PREVIEW_CHARS,
25
+ QUICK_COMPLETION_MS,
26
+ type ForegroundSlot,
27
+ type UiContext,
28
+ } from "../types.ts";
29
+ import { spawnWithFileOutput, killProcessTree, type SpawnExit } from "../spawn.ts";
30
+ import { streamLog } from "../output.ts";
31
+ import { showBackgroundHint, clearBackgroundHint } from "../hint.ts";
32
+ import {
33
+ add,
34
+ createRunningJob,
35
+ markStarted,
36
+ newJobId,
37
+ logPathFor,
38
+ readLogTail,
39
+ } from "../registry.ts";
40
+ import {
41
+ assertJobSlot,
42
+ detectBlockedSleep,
43
+ SLEEP_WAIT_GUIDANCE,
44
+ isAutoBackgroundAllowed,
45
+ isBlankCommand,
46
+ requireExistingCwd,
47
+ startBackgroundJob,
48
+ } from "../lifecycle.ts";
49
+ import { textBlock } from "../format.ts";
50
+ import { bashParamSchema } from "./bash-params.ts";
51
+
52
+ /** UI context + cwd is all this tool needs from the host context. */
53
+ type BashCtx = UiContext & { cwd: string };
54
+
55
+ /** Register the overridden `bash` tool. */
56
+ export function registerBashTool(
57
+ pi: ExtensionAPI,
58
+ reg: BackgroundRegistry,
59
+ originalBash: ReturnType<typeof createBashToolDefinition>
60
+ ): void {
61
+ pi.registerTool({
62
+ ...originalBash,
63
+ name: "bash",
64
+ description:
65
+ "Run a Bash command. Long-running commands continue asynchronously after timeout. " +
66
+ "Set run_async=true to start asynchronously immediately. " +
67
+ "Use /bash-async to hand off a running command.",
68
+ promptSnippet:
69
+ "Run shell commands; long-running commands continue asynchronously or use run_async=true",
70
+ promptGuidelines: [
71
+ "Use bash with run_async=true when a command is expected to run for a long time.",
72
+ "run_async is for ONE notification (the command exits when done). For per-event streaming (watching logs, polling an API, file changes), use the bash_async_watch tool instead.",
73
+ "Never `sleep N` to wait for something — the job lingers for the full sleep. Wait on a background job with bash_async_list action='attach', watch with the bash_async_watch tool, or poll with an `until` loop that exits when ready.",
74
+ "Check background job status with bash_async_list action='list'.",
75
+ "Read background output with bash_async_list action='output'.",
76
+ ],
77
+ parameters: bashParamSchema,
78
+
79
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
80
+ const p = params as {
81
+ command: string;
82
+ timeout?: number;
83
+ run_async?: boolean;
84
+ description?: string;
85
+ };
86
+ const bashCtx = ctx as BashCtx;
87
+
88
+ if (isBlankCommand(p.command)) throw new Error("Command is empty.");
89
+ requireExistingCwd(bashCtx.cwd);
90
+
91
+ const sleepMatch = detectBlockedSleep(p.command);
92
+ if (sleepMatch) {
93
+ throw new Error(`Blocked: ${sleepMatch}. ${SLEEP_WAIT_GUIDANCE}`);
94
+ }
95
+
96
+ assertJobSlot(reg);
97
+
98
+ // Explicit background mode — spawn and return immediately.
99
+ if (p.run_async) {
100
+ return spawnBackground({
101
+ toolCallId,
102
+ command: p.command,
103
+ name: p.description,
104
+ cwd: bashCtx.cwd,
105
+ reg,
106
+ pi,
107
+ ctx: bashCtx,
108
+ });
109
+ }
110
+
111
+ // Foreground mode — race completion against backgrounding.
112
+ return runForeground({
113
+ toolCallId,
114
+ command: p.command,
115
+ timeoutMs: p.timeout ? p.timeout * 1000 : DEFAULT_TIMEOUT_MS,
116
+ signal,
117
+ onUpdate,
118
+ ctx: bashCtx,
119
+ reg,
120
+ pi,
121
+ });
122
+ },
123
+ });
124
+ }
125
+
126
+ // --- Foreground backend --------------------------------------------------
127
+
128
+ async function runForeground(args: {
129
+ toolCallId: string;
130
+ command: string;
131
+ timeoutMs: number;
132
+ signal: AbortSignal | undefined;
133
+ onUpdate: AgentToolUpdateCallback<BashToolDetails | undefined> | undefined;
134
+ ctx: BashCtx;
135
+ reg: BackgroundRegistry;
136
+ pi: ExtensionAPI;
137
+ }): Promise<AgentToolResult<BashToolDetails | undefined>> {
138
+ const { toolCallId, command, timeoutMs, signal, onUpdate, ctx, reg, pi } =
139
+ args;
140
+ const id = newJobId("shell", reg);
141
+ const logPath = logPathFor(id);
142
+
143
+ // Spawn WITHOUT wiring the turn signal to a process kill. Cooperative
144
+ // steering aborts the turn (ctx.abort) to move this command to the
145
+ // background; if the turn signal killed the process group, that abort would
146
+ // kill the very command we just backgrounded. We manage the signal manually
147
+ // below and only kill on a genuine cancel (abort with no pause requested).
148
+ const spawned = spawnWithFileOutput({
149
+ command,
150
+ cwd: ctx.cwd,
151
+ logPath,
152
+ });
153
+
154
+ // Register the foreground slot so `/bash-async` can find this command.
155
+ let pauseRequested = false;
156
+ let handedToBackground = false;
157
+ let pauseResolve: ((reason: "manual" | "timeout") => void) | null = null;
158
+ const pausePromise = new Promise<"manual" | "timeout">((r) => {
159
+ pauseResolve = r;
160
+ });
161
+ const requestPause = (reason: "manual" | "timeout") => {
162
+ pauseRequested = true;
163
+ pauseResolve?.(reason);
164
+ };
165
+
166
+ // Claude Code parity for the turn's abort signal:
167
+ // - No pause requested → a genuine cancel (Esc / 'user-cancel'): kill the
168
+ // process group, like CC's ShellCommand.#abortHandler.
169
+ // - Pause already requested → cooperative input, `/bash-async`, or timeout
170
+ // moving the command to async execution: leave it running.
171
+ // Long-running work is protected the CC way — by auto-backgrounding at the
172
+ // timeout — not by refusing to honor a deliberate cancel.
173
+ const onTurnAbort = () => {
174
+ if (!pauseRequested) killProcessTree(spawned.pid, "SIGTERM");
175
+ };
176
+ if (signal) {
177
+ if (signal.aborted) onTurnAbort();
178
+ else signal.addEventListener("abort", onTurnAbort);
179
+ }
180
+
181
+ const slot: ForegroundSlot = { requestPause };
182
+ reg.foreground.set(toolCallId, slot);
183
+
184
+ const job = createRunningJob({
185
+ id,
186
+ command,
187
+ pid: spawned.pid,
188
+ logPath,
189
+ toolCallId,
190
+ isBackgrounded: false,
191
+ });
192
+ // Foreground jobs are tracked for the sidebar and async handoff but are not
193
+ // counted as started until they become asynchronous.
194
+ reg.jobs.set(id, job);
195
+
196
+ // Promote the running command to an asynchronous job (cooperative input,
197
+ // `/bash-async`, or timeout). Idempotent.
198
+ const promoteToBackground = () => {
199
+ if (handedToBackground) return;
200
+ handedToBackground = true;
201
+ // Clear the foreground slot now (not only in `finally`) so a backgrounded
202
+ // command can't strand a stale slot when cooperative steering tears down
203
+ // the turn right after requesting the pause.
204
+ reg.foreground.delete(toolCallId);
205
+ job.isBackgrounded = true;
206
+ markStarted(reg);
207
+ startBackgroundJob({ reg, pi, ctx, job, exit: spawned.exit });
208
+ };
209
+
210
+ // Timeout timer.
211
+ const timeoutTimer = setTimeout(() => {
212
+ if (reg.nonInteractive) return;
213
+ if (!reg.foreground.has(toolCallId)) return;
214
+ if (!isAutoBackgroundAllowed(command)) {
215
+ // Not eligible for auto-background (e.g. `sleep`) — kill it, but
216
+ // leave a marker in the log first so the model can tell a timeout
217
+ // kill apart from a normal failure (Claude Code prepends
218
+ // "Command timed out after {duration}" to the output).
219
+ try {
220
+ appendFileSync(logPath, `Command timed out after ${Math.round(timeoutMs / 1000)}s\n`);
221
+ } catch { /* best-effort — the kill below still happens */ }
222
+ killProcessTree(spawned.pid, "SIGTERM");
223
+ return;
224
+ }
225
+ requestPause("timeout");
226
+ }, timeoutMs);
227
+ (timeoutTimer as NodeJS.Timeout).unref();
228
+
229
+ let progressPoller: { stop: () => void } | undefined;
230
+ let hintShown = false;
231
+
232
+ const cleanup = () => {
233
+ progressPoller?.stop();
234
+ clearTimeout(timeoutTimer);
235
+ if (signal) signal.removeEventListener("abort", onTurnAbort);
236
+ };
237
+
238
+ // Foreground completion (quick or normal): read output, surface errors.
239
+ // Registry teardown happens in `finally` so no exit path can strand the job.
240
+ const finishForeground = (
241
+ exit: SpawnExit
242
+ ): AgentToolResult<BashToolDetails | undefined> => {
243
+ const output = readLogTail(job, OUTPUT_PREVIEW_CHARS);
244
+ // A signal death (e.g. Esc-cancel killed the process group) is a
245
+ // deliberate cancel, not a command failure — never an error result.
246
+ if (exit.signal === null && exit.code !== 0) {
247
+ throw new Error(output || `Command exited with code ${exit.code ?? 1}`);
248
+ }
249
+ return { content: [textBlock(output || "(no output)")], details: undefined };
250
+ };
251
+
252
+ try {
253
+ // Quick completion window (2s). Keep the timer referenced while the
254
+ // detached child is the only active handle, then clear it on exit.
255
+ let quickTimer: NodeJS.Timeout | undefined;
256
+ const quickResult = await Promise.race<SpawnExit | null>([
257
+ spawned.exit,
258
+ new Promise<null>((resolve) => {
259
+ quickTimer = setTimeout(() => resolve(null), QUICK_COMPLETION_MS);
260
+ }),
261
+ ]);
262
+ if (quickTimer) clearTimeout(quickTimer);
263
+
264
+ if (quickResult !== null) {
265
+ return finishForeground(quickResult);
266
+ }
267
+
268
+ // Still running past the quick window — stream progress and show the
269
+ // `/bash-async` handoff hint.
270
+ progressPoller = streamLog(logPath, onUpdate);
271
+ showBackgroundHint(ctx);
272
+ hintShown = true;
273
+
274
+ // Race: completion vs backgrounding.
275
+ const race = await Promise.race<
276
+ | { kind: "completed"; exit: SpawnExit }
277
+ | { kind: "backgrounded"; reason: "manual" | "timeout" }
278
+ >([
279
+ spawned.exit.then((exit) => ({ kind: "completed" as const, exit })),
280
+ pausePromise.then((reason) => ({ kind: "backgrounded" as const, reason })),
281
+ ]);
282
+
283
+ if (race.kind === "backgrounded") {
284
+ promoteToBackground();
285
+ // Claude Code's exact tool-result strings: a distinct line for a
286
+ // manual background, one generic line for the timeout path.
287
+ const text =
288
+ race.reason === "manual"
289
+ ? `Command was moved to async execution with ID: ${id}. Output is being written to: ${logPath}`
290
+ : `Command running asynchronously with ID: ${id}. Output is being written to: ${logPath}`;
291
+ return { content: [textBlock(text)], details: undefined };
292
+ }
293
+
294
+ // Normal completion.
295
+ return finishForeground(race.exit);
296
+ } finally {
297
+ // Single teardown for every exit path (return, throw, background hand-off).
298
+ cleanup();
299
+ if (hintShown) clearBackgroundHint(ctx);
300
+ reg.foreground.delete(toolCallId);
301
+ if (!handedToBackground) {
302
+ reg.jobs.delete(id);
303
+ try { unlinkSync(logPath); } catch { /* best-effort */ }
304
+ }
305
+ }
306
+ }
307
+
308
+ // --- Background backend --------------------------------------------------
309
+
310
+ function spawnBackground(args: {
311
+ toolCallId: string;
312
+ command: string;
313
+ name?: string;
314
+ cwd: string;
315
+ reg: BackgroundRegistry;
316
+ pi: ExtensionAPI;
317
+ ctx: UiContext;
318
+ }): AgentToolResult<BashToolDetails | undefined> {
319
+ const id = newJobId("shell", args.reg);
320
+ const logPath = logPathFor(id);
321
+
322
+ const spawned = spawnWithFileOutput({
323
+ command: args.command,
324
+ cwd: args.cwd,
325
+ logPath,
326
+ });
327
+
328
+ const job = createRunningJob({
329
+ id,
330
+ name: args.name,
331
+ command: args.command,
332
+ pid: spawned.pid,
333
+ logPath,
334
+ toolCallId: args.toolCallId,
335
+ });
336
+ add(args.reg, job);
337
+ startBackgroundJob({ reg: args.reg, pi: args.pi, ctx: args.ctx, job, exit: spawned.exit });
338
+
339
+ return {
340
+ content: [
341
+ textBlock(
342
+ `Command running asynchronously with ID: ${id}. Output is being written to: ${logPath}`
343
+ ),
344
+ ],
345
+ details: undefined,
346
+ };
347
+ }
@@ -0,0 +1,60 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
3
+ import type { BackgroundRegistry } from "../state.ts";
4
+ import { OUTPUT_PREVIEW_CHARS, type UiContext } from "../types.ts";
5
+ import { findJob, readLogTail, renderSidebar } from "../registry.ts";
6
+ import { terminateJobSilently } from "../lifecycle.ts";
7
+ import { jobLabel, textBlock } from "../format.ts";
8
+
9
+ /** Register decisions for timed asynchronous Bash commands. */
10
+ export function registerJobDecideTool(pi: ExtensionAPI, reg: BackgroundRegistry): void {
11
+ pi.registerTool({
12
+ name: "bash_async_decide",
13
+ label: "Async Bash Decision",
14
+ description: "Keep, stop, or inspect an asynchronous Bash command that exceeded its timeout.",
15
+ promptSnippet: "Resolve a timed asynchronous Bash command",
16
+ promptGuidelines: [
17
+ "Use bash_async_decide after a bash_async timeout notification.",
18
+ "keep leaves the command running, kill stops it, and check shows its current output.",
19
+ ],
20
+ parameters: Type.Object({
21
+ jobId: Type.String({ description: "Timed asynchronous Bash job ID" }),
22
+ decision: StringEnum(["keep", "kill", "check"] as const, {
23
+ description: "Action to take for the timed job",
24
+ }),
25
+ }),
26
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
27
+ const { jobId, decision } = params as {
28
+ jobId: string;
29
+ decision: "keep" | "kill" | "check";
30
+ };
31
+ const job = findJob(reg, jobId);
32
+ if (!job) {
33
+ if (reg.pendingDecisionJobId === jobId) {
34
+ reg.pendingDecisionJobId = undefined;
35
+ }
36
+ return { content: [textBlock(`No async Bash job found with ID: ${jobId}.`)], details: undefined };
37
+ }
38
+
39
+ if (decision === "check") {
40
+ const output = readLogTail(job, OUTPUT_PREVIEW_CHARS);
41
+ return {
42
+ content: [textBlock(`Output for ${jobLabel(job)}:\n${output || "(no output yet)"}`)],
43
+ details: undefined,
44
+ };
45
+ }
46
+
47
+ reg.pendingDecisionJobId = undefined;
48
+ if (decision === "kill") {
49
+ terminateJobSilently(reg, job);
50
+ renderSidebar(reg, ctx as UiContext);
51
+ return { content: [textBlock(`Stopped ${jobLabel(job)}.`)], details: undefined };
52
+ }
53
+
54
+ return {
55
+ content: [textBlock(`Keeping ${jobLabel(job)} running. Use bash_async_list to inspect it later.`)],
56
+ details: undefined,
57
+ };
58
+ },
59
+ });
60
+ }