From d5cad3c11ce52178e539dc7d858c63454b30800f Mon Sep 17 00:00:00 2001 From: Dustin Fox Date: Sun, 20 Sep 2026 22:43:59 -0400 Subject: [PATCH 1/2] feat(bash)!: fold bash_async into bash tool Remove the separate bash_async tool so every shell command runs through the permission-checked bash tool name. Start async work with bash and run_async=true; the name parameter becomes description. notify suppresses the terminal notification for run_async starts only, while a command backgrounded from the foreground always notifies. With run_async, timeout is the decision timeout resolved by bash_async_decide; foreground timeout still auto-backgrounds. Delete the unused async call renderer. --- README.md | 22 +-- src/__tests__/bash-async-timeout.test.ts | 168 +++++++++++++++++++++++ src/__tests__/bash-bg.test.ts | 80 ----------- src/__tests__/bash-results.test.ts | 47 ++++++- src/__tests__/features.test.ts | 4 +- src/__tests__/interface.test.ts | 4 +- src/__tests__/notify.test.ts | 2 +- src/__tests__/render.test.ts | 107 --------------- src/__tests__/sleep-guard.test.ts | 18 +-- src/index.ts | 6 +- src/lifecycle.ts | 6 +- src/monitoring.ts | 2 +- src/registry.ts | 2 +- src/render.ts | 56 -------- src/tools/bash-bg.ts | 116 ---------------- src/tools/bash-params.ts | 23 +++- src/tools/bash.ts | 82 ++++++++++- src/tools/job-decide.ts | 2 +- src/tools/monitor.ts | 2 +- 19 files changed, 349 insertions(+), 400 deletions(-) create mode 100644 src/__tests__/bash-async-timeout.test.ts delete mode 100644 src/__tests__/bash-bg.test.ts delete mode 100644 src/__tests__/render.test.ts delete mode 100644 src/render.ts delete mode 100644 src/tools/bash-bg.ts diff --git a/README.md b/README.md index 806e6b1..b912f1e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ During development, add the local checkout to Pi settings: ```json { "packages": [ - "/local/home/dustinfx/workplace/pi/pi-async-bash" + "/path/to/pi-async-bash" ] } ``` @@ -31,11 +31,10 @@ During development, add the local checkout to Pi settings: | Tool | Parameters | Behavior | | --- | --- | --- | -| `bash` | `command`; optional `timeout`, `run_async`, `description` | Overrides Pi's Bash tool. Commands start in the foreground by default. `run_async: true` starts detached work immediately. A long foreground command continues asynchronously after `timeout`. | -| `bash_async` | `command`; optional `name`, `timeout`, `notify` | Starts a command asynchronously, shows the command in the call row, and returns its job ID and `/tmp/pi-bg/.log` path. It sends one terminal notification unless `notify: false`. | +| `bash` | `command`; optional `timeout`, `run_async`, `description`, `notify` | Overrides Pi's Bash tool. Commands start in the foreground by default. `run_async: true` starts detached work immediately; an optional `timeout` then becomes a decision timeout resolved by `bash_async_decide`. A long foreground command continues asynchronously after `timeout`. `run_async` jobs send one terminal notification unless `notify: false`; commands backgrounded from the foreground always notify. | | `bash_async_list` | `action`: `list`, `output`, `kill`, `attach`, `search`, `cleanup`, or `stats`; action-specific `jobId`, `pattern`, `wait` | Lists, reads, follows, searches, stops, cleans up, and reports on async jobs and watches. | | `bash_async_watch` | exactly one of `command` or `ws`; `description`; optional `persistent`, `timeout_ms` | Returns immediately and delivers each stdout line or WebSocket text frame as an event. It stops on source exit, timeout, event-rate protection, or `bash_async_list` `kill`. | -| `bash_async_decide` | `jobId`; `decision`: `keep`, `kill`, or `check` | Resolves a `bash_async` command that has exceeded its optional timeout. | +| `bash_async_decide` | `jobId`; `decision`: `keep`, `kill`, or `check` | Resolves a `bash` command started with `run_async` that has exceeded its optional decision timeout. | `run_in_background` is not supported. Use `run_async` instead. @@ -58,7 +57,7 @@ The 2-second quick-completion window still returns fast commands inline. Values at or below 2 seconds background commands immediately after that window; the 5-second setting above gives slower commands three more seconds to finish. A `timeout` passed to an individual `bash` call overrides this setting. This -setting does not affect the separate decision timeout on `bash_async`. +setting does not affect the separate decision timeout on a `run_async` command. ## Slash commands @@ -78,8 +77,8 @@ a follow-up. `/tmp/pi-bg`. - `bash_async_list attach` streams output while it waits. Use `output` for a bounded non-blocking read. -- `bash_async_watch` is for many events. Use `bash` with `run_async: true` or - `bash_async` when only the terminal result matters. +- `bash_async_watch` is for many events. Use `bash` with `run_async: true` + when only the terminal result matters. - The extension rejects naïve long `sleep` commands, warns about interactive prompts, caps non-persistent log growth at 100 MiB, limits concurrent jobs, and removes stale log files at startup. @@ -88,10 +87,17 @@ a follow-up. ## Migration from upstream +Upgrading this package from a previous release: the separate `bash_async` +tool was removed. Use `bash` with `run_async: true`. Its `name` parameter is +now `description`, `notify` (which applies to `run_async` starts) is unchanged, and `timeout` on `run_async` is the +decision timeout resolved by `bash_async_decide`. The async tool result is now +the plain Claude Code handle string and no longer repeats the job label; the +label still shows in `bash_async_list` and job notifications. + | Upstream | pi-async-bash | | --- | --- | | `bash` with `run_in_background` | `bash` with `run_async` | -| `bash_bg` | `bash_async` | +| `bash_bg` | `bash` with `run_async` | | `jobs` | `bash_async_list` | | `monitor` | `bash_async_watch` | | `job_decide` | `bash_async_decide` | diff --git a/src/__tests__/bash-async-timeout.test.ts b/src/__tests__/bash-async-timeout.test.ts new file mode 100644 index 0000000..d03cf57 --- /dev/null +++ b/src/__tests__/bash-async-timeout.test.ts @@ -0,0 +1,168 @@ +/** + * `bash run_async` — decision timeout behavior: + * - kill branch: a log marker plus a killed (the agent + * must learn its command was timeout-killed), + * - decision branch: a pending decision plus a warning for the model to + * resolve via bash_async_decide, with the process left alive. + * + * Also covers the `notify` plumbing: notify=false suppresses the terminal + * while still evicting the job from the live registry. + */ + +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { BackgroundRegistry } from "../state.ts"; +import { registerBashTool } from "../tools/bash.ts"; +import { EVENT, isTerminalStatus } from "../types.ts"; +import { killProcessTree, processExists } from "../spawn.ts"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +interface ToolDef { + execute: ( + toolCallId: string, + params: unknown, + signal: unknown, + onUpdate: unknown, + ctx: unknown + ) => Promise<{ content: Array<{ type: "text"; text: string }> }>; +} + +interface CapturedMessage { + customType: string; + content: string; +} + +function harness() { + let tool: ToolDef | undefined; + const messages: CapturedMessage[] = []; + const notifications: Array<{ message: string; level: string }> = []; + const pi = { + registerTool: (def: ToolDef) => { tool = def; }, + sendMessage: (m: CapturedMessage) => { messages.push(m); }, + }; + const reg = new BackgroundRegistry(); + registerBashTool(pi as never, reg, {} as never); + const ctx = { + cwd: process.cwd(), + ui: { + notify: (message: string, level: string) => { + notifications.push({ message, level }); + }, + setWidget: () => {}, + setStatus: () => {}, + theme: { fg: (_c: string, t: string) => t }, + }, + }; + return { tool: tool!, reg, ctx, messages, notifications }; +} + +const spawnedPids: number[] = []; + +void describe("bash run_async — decision timeout", () => { + void it("kill branch: marks the log AND sends a killed ", async () => { + const { tool, ctx, messages } = harness(); + // `sleep` is excluded from auto-backgrounding, and a float duration + // slips past the blocked-sleep guard — so the decision timeout hits + // the kill path (same trick as the foreground timeout test). + const res = await tool.execute( + "t1", + { command: "sleep 1.5", run_async: true, timeout: 1 }, + undefined, + undefined, + ctx + ); + const logPath = /Output is being written to: (\S+)/.exec(res.content[0].text)?.[1]; + assert.ok(logPath, "tool result carries the log path"); + + // Bounded poll: the 1s timeout fires, SIGTERM, exit handler notifies. + const terminals = () => messages.filter((m) => m.customType === EVENT.taskNotification); + for (let i = 0; i < 60 && terminals().length === 0; i++) { + await sleep(50); + } + + assert.equal(terminals().length, 1, "the agent learns its command was timeout-killed"); + assert.ok(terminals()[0].content.includes("killed")); + assert.ok(terminals()[0].content.includes("was stopped")); + assert.match( + readFileSync(logPath, "utf-8"), + /Command timed out after 1s/, + "log marker tells a timeout kill apart from a normal failure" + ); + }); + + void it("decision branch: marks the decision pending, warns, and leaves the process alive", async () => { + const { tool, reg, ctx, notifications } = harness(); + // `tail -f` is auto-background-eligible, so the decision timeout hits + // the decision path instead of the kill path. + const res = await tool.execute( + "t1-decide", + { command: "tail -f /dev/null", run_async: true, timeout: 1 }, + undefined, + undefined, + ctx + ); + const jobId = /Command running asynchronously with ID: (\S+)\./.exec(res.content[0].text)?.[1]; + assert.ok(jobId, "tool result carries the job id"); + const job = reg.jobs.get(jobId); + if (job) spawnedPids.push(job.pid); + assert.ok(job, "job is registered"); + + // Bounded poll: don't assert synchronously on a ~200ms margin after a + // 1000ms timer (timing flake under load). + for (let i = 0; i < 60 && reg.pendingDecisionJobId !== jobId; i++) { + await sleep(50); + } + + assert.equal(reg.pendingDecisionJobId, jobId, "the job awaits bash_async_decide"); + assert.equal(job!.status, "running", "the job was not killed"); + assert.ok(processExists(job!.pid), "the process is STILL alive"); + const warnings = notifications.filter((n) => n.level === "warning"); + assert.equal(warnings.length, 1, "the model is notified to decide"); + assert.ok(warnings[0].message.includes(`exceeded 1s`)); + assert.ok(warnings[0].message.includes(jobId)); + }); +}); + +void describe("bash run_async — notify plumbing", () => { + void it("notify: false sends no task-notification and evicts the job", async () => { + const { tool, reg, ctx, messages } = harness(); + await tool.execute( + "t-notify-off", + { command: "echo hi", run_async: true, notify: false }, + undefined, + undefined, + ctx + ); + // Wait for the job to reach a terminal status. + for (let i = 0; i < 50 && [...reg.jobs.values()].some((j) => !isTerminalStatus(j.status)); i++) { + await sleep(50); + } + const terminals = messages.filter((m) => m.customType === EVENT.taskNotification); + assert.equal(terminals.length, 0, "notification suppressed"); + assert.equal(reg.jobs.size, 0, "job evicted from the live registry"); + }); + + void it("default notify sends exactly one task-notification", async () => { + const { tool, reg, ctx, messages } = harness(); + await tool.execute( + "t-notify-on", + { command: "echo hi", run_async: true }, + undefined, + undefined, + ctx + ); + for (let i = 0; i < 50 && messages.length === 0; i++) { + await sleep(50); + } + const terminals = messages.filter((m) => m.customType === EVENT.taskNotification); + assert.equal(terminals.length, 1, "positive control: one notification"); + }); +}); + +after(() => { + for (const pid of spawnedPids) { + try { killProcessTree(pid, "SIGKILL"); } catch { /* already gone */ } + } +}); diff --git a/src/__tests__/bash-bg.test.ts b/src/__tests__/bash-bg.test.ts deleted file mode 100644 index 99f1a30..0000000 --- a/src/__tests__/bash-bg.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * `bash_async` tool — timeout kill must be loud: a log marker plus a killed - * , mirroring the foreground bash timeout path (the agent - * must learn its command was timeout-killed). - */ - -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { BackgroundRegistry } from "../state.ts"; -import { registerBashBgTool } from "../tools/bash-bg.ts"; -import { EVENT } from "../types.ts"; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -interface ToolDef { - execute: ( - toolCallId: string, - params: unknown, - signal: unknown, - onUpdate: unknown, - ctx: unknown - ) => Promise<{ content: Array<{ type: "text"; text: string }> }>; -} - -interface CapturedMessage { - customType: string; - content: string; -} - -function harness() { - let tool: ToolDef | undefined; - const messages: CapturedMessage[] = []; - const pi = { - registerTool: (def: ToolDef) => { tool = def; }, - sendMessage: (m: CapturedMessage) => { messages.push(m); }, - }; - const reg = new BackgroundRegistry(); - registerBashBgTool(pi as never, reg); - const ctx = { - cwd: process.cwd(), - ui: { - notify: () => {}, - setWidget: () => {}, - setStatus: () => {}, - theme: { fg: (_c: string, t: string) => t }, - }, - }; - return { tool: tool!, reg, ctx, messages }; -} - -void describe("bash_async — timeout kill is loud", () => { - void it("marks the log AND sends a killed ", async () => { - const { tool, ctx, messages } = harness(); - // `sleep` is excluded from auto-backgrounding, and a float duration - // slips past the blocked-sleep guard — so the timeout hits the kill - // path (same trick as the foreground timeout test). - const res = await tool.execute( - "t1", - { command: "sleep 1.5", timeout: 1 }, - undefined, - undefined, - ctx - ); - const logPath = /Output is being written to: (\S+)/.exec(res.content[0].text)?.[1]; - assert.ok(logPath, "tool result carries the log path"); - - await sleep(1_600); // the 1s timeout fires, SIGTERM, exit handler notifies - - const terminals = messages.filter((m) => m.customType === EVENT.taskNotification); - assert.equal(terminals.length, 1, "the agent learns its command was timeout-killed"); - assert.ok(terminals[0].content.includes("killed")); - assert.ok(terminals[0].content.includes("was stopped")); - assert.match( - readFileSync(logPath, "utf-8"), - /Command timed out after 1s/, - "log marker tells a timeout kill apart from a normal failure" - ); - }); -}); diff --git a/src/__tests__/bash-results.test.ts b/src/__tests__/bash-results.test.ts index 5d9dba0..82ff467 100644 --- a/src/__tests__/bash-results.test.ts +++ b/src/__tests__/bash-results.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { BackgroundRegistry } from "../state.ts"; import { registerBashTool } from "../tools/bash.ts"; import { killProcessTree } from "../spawn.ts"; -import { EVENT, type Job } from "../types.ts"; +import { EVENT, isTerminalStatus, type Job } from "../types.ts"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -63,6 +63,7 @@ void describe("bash tool — Claude Code tool-result strings", () => { ); const job = onlyJob(reg); spawnedPids.push(job.pid); + assert.equal(job.name, "my job"); assert.equal( res.content[0].text, `Command running asynchronously with ID: ${job.id}. Output is being written to: ${job.logPath}` @@ -89,6 +90,50 @@ void describe("bash tool — Claude Code tool-result strings", () => { ); }); + void it("manual background with a description keeps the label on the promoted job", async () => { + const { tool, reg, ctx } = harness(); + const pending = tool.execute( + "t2-desc", + { command: "tail -f /dev/null", description: "my job" }, + undefined, + undefined, + ctx + ); + await sleep(400); + reg.foreground.get("t2-desc")?.requestPause("manual"); + await pending; + const job = onlyJob(reg); + spawnedPids.push(job.pid); + assert.equal(job.name, "my job", "the description becomes the async label"); + }); + + void it("manual background always notifies even when notify: false", async () => { + const { tool, reg, ctx, messages } = harness(); + const pending = tool.execute( + "t2-notify-off", + { command: "tail -f /dev/null", notify: false }, + undefined, + undefined, + ctx + ); + await sleep(400); + reg.foreground.get("t2-notify-off")?.requestPause("manual"); + await pending; + const job = onlyJob(reg); + spawnedPids.push(job.pid); + killProcessTree(job.pid, "SIGKILL"); + for (let i = 0; i < 50 && !isTerminalStatus(job.status); i++) { + await sleep(50); + } + assert.equal(job.status, "killed", "the job reached a terminal state before checking notifications"); + const terminals = () => messages.filter((m) => m.customType === EVENT.taskNotification); + // Bounded poll: give the exit handler time to send the notification. + for (let i = 0; i < 50 && terminals().length === 0; i++) { + await sleep(50); + } + assert.equal(terminals().length, 1, "a foreground command backgrounded manually always notifies, even with notify: false"); + }); + void it("uses the configured default timeout when no per-call timeout is provided", async () => { const { tool, reg, ctx } = harness(); reg.defaultTimeoutMs = 1_000; diff --git a/src/__tests__/features.test.ts b/src/__tests__/features.test.ts index f658f09..5d6616c 100644 --- a/src/__tests__/features.test.ts +++ b/src/__tests__/features.test.ts @@ -3,7 +3,7 @@ * - jobs.search 정규식 검색 * - jobs.cleanup 종료된 잡 일괄 제거 * - jobs.stats 집계 메트릭 - * - bash_async의 --name 라벨 + * - bash run_async의 --name 라벨 * * 툴의 register 함수를 직접 호출하지 않고, registry + 포맷터 수준의 * 단위 테스트로 동작을 검증한다. @@ -41,7 +41,7 @@ function makeJob(overrides: Partial = {}): Job { }; } -void describe("bash_async --name 라벨", () => { +void describe("job name label", () => { void it("formatJobLine은 이름 있는 잡에 대해 'name (job-id)' 헤더 사용", () => { const job = makeJob({ id: "job-1", name: "build" }); const line = formatJobLine(job); diff --git a/src/__tests__/interface.test.ts b/src/__tests__/interface.test.ts index 2dd4ee6..0ce3bcc 100644 --- a/src/__tests__/interface.test.ts +++ b/src/__tests__/interface.test.ts @@ -28,12 +28,11 @@ void describe("pi-async-bash public interface", () => { const { tools } = loadExtension(); assert.deepEqual([...tools.keys()].sort(), [ "bash", - "bash_async", "bash_async_decide", "bash_async_list", "bash_async_watch", ]); - for (const legacy of ["bash_bg", "jobs", "monitor", "job_decide", "agent_bg"]) { + for (const legacy of ["bash_async", "bash_bg", "jobs", "monitor", "job_decide", "agent_bg"]) { assert.equal(tools.has(legacy), false, `${legacy} must not be registered`); } }); @@ -50,6 +49,7 @@ void describe("pi-async-bash public interface", () => { const { tools } = loadExtension(); const bash = tools.get("bash") as Tool & { parameters: { properties: Record } }; assert.equal("run_async" in bash.parameters.properties, true); + assert.equal("notify" in bash.parameters.properties, true); assert.equal("run_in_background" in bash.parameters.properties, false); }); }); diff --git a/src/__tests__/notify.test.ts b/src/__tests__/notify.test.ts index 3101ff8..1b08146 100644 --- a/src/__tests__/notify.test.ts +++ b/src/__tests__/notify.test.ts @@ -293,7 +293,7 @@ void describe("completeJob — exit-path notification", () => { assert.equal(reg.jobs.has("job-9-3"), false); }); - void it("shouldNotify: false without prior notice still evicts (bash_async notify: false)", () => { + void it("shouldNotify: false without prior notice still evicts", () => { const { reg, pi, ctx, messages } = harness(); const job = mkJob({ id: "job-9-4", status: "running", exitCode: undefined }); add(reg, job); diff --git a/src/__tests__/render.test.ts b/src/__tests__/render.test.ts deleted file mode 100644 index c2ef0e7..0000000 --- a/src/__tests__/render.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { Text } from "@earendil-works/pi-tui"; -import { renderBashAsyncCall } from "../render.ts"; - -const theme = { - fg: (color: "muted" | "toolTitle", text: string) => `[${color}:${text}]`, - bold: (text: string) => `*${text}*`, -}; - -test("renders an async command without its name", () => { - const component = renderBashAsyncCall( - { command: "echo hello", name: "greeting" }, - theme, - {}, - ); - const output = renderedText(component); - - assert.equal(output, "[toolTitle:*$ echo hello*]"); - assert.doesNotMatch(output, /greeting/); -}); - -test("renders an async command with a timeout", () => { - const component = renderBashAsyncCall( - { command: "pnpm test", timeout: 30 }, - theme, - {}, - ); - - assert.equal( - renderedText(component), - "[toolTitle:*$ pnpm test*][muted: (timeout 30s)]", - ); -}); - -test("omits the timeout suffix when timeout is absent", () => { - const component = renderBashAsyncCall({ command: "pnpm check" }, theme, {}); - - assert.equal(renderedText(component), "[toolTitle:*$ pnpm check*]"); -}); - -test("omits the timeout suffix when timeout is not positive", () => { - for (const timeout of [0, -5]) { - const component = renderBashAsyncCall( - { command: "pnpm check", timeout }, - theme, - {}, - ); - - assert.equal(renderedText(component), "[toolTitle:*$ pnpm check*]"); - } -}); - -test("renders missing arguments with an ellipsis", () => { - const component = renderBashAsyncCall(undefined, theme, {}); - - assert.equal(renderedText(component), "[toolTitle:*$ *][muted:...]"); -}); - -test("renders a whitespace-only command with an ellipsis", () => { - const component = renderBashAsyncCall({ command: " " }, theme, {}); - - assert.equal(renderedText(component), "[toolTitle:*$ *][muted:...]"); -}); - -test("reuses and updates the last component", () => { - const firstComponent = renderBashAsyncCall( - { command: "echo first" }, - theme, - {}, - ); - const secondComponent = renderBashAsyncCall( - { command: "echo second" }, - theme, - { lastComponent: firstComponent }, - ); - - assert.strictEqual(secondComponent, firstComponent); - assert.equal(renderedText(secondComponent), "[toolTitle:*$ echo second*]"); -}); - -test("replaces a foreign cached component", () => { - const foreignComponent = {}; - const component = renderBashAsyncCall( - { command: "echo fresh" }, - theme, - { lastComponent: foreignComponent as unknown as Text }, - ); - - assert.ok(component instanceof Text); - assert.notStrictEqual(component, foreignComponent); - assert.equal(renderedText(component), "[toolTitle:*$ echo fresh*]"); -}); - -test("renders wide glyphs", () => { - const component = renderBashAsyncCall( - { command: "echo 你好 🎉" }, - theme, - {}, - ); - - assert.equal(renderedText(component), "[toolTitle:*$ echo 你好 🎉*]"); -}); - -function renderedText(component: Text): string { - return component.render(200).map((line) => line.trimEnd()).join("\n"); -} diff --git a/src/__tests__/sleep-guard.test.ts b/src/__tests__/sleep-guard.test.ts index 7b3449c..135ad90 100644 --- a/src/__tests__/sleep-guard.test.ts +++ b/src/__tests__/sleep-guard.test.ts @@ -2,7 +2,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { detectBlockedSleep } from "../lifecycle.ts"; import { BackgroundRegistry } from "../state.ts"; -import { registerBashBgTool } from "../tools/bash-bg.ts"; +import { registerBashTool } from "../tools/bash.ts"; void describe("detectBlockedSleep — naive-wait detection", () => { void it("blocks a standalone long sleep", () => { @@ -58,11 +58,11 @@ void describe("detectBlockedSleep — naive-wait detection", () => { }); }); -void describe("bash_async — rejects a backgrounded sleep wait", () => { - function bashBg() { +void describe("bash run_async — rejects a backgrounded sleep wait", () => { + function bashAsync() { let tool: { execute: (id: string, p: unknown, s: unknown, u: unknown, c: unknown) => Promise } | undefined; const pi = { registerTool: (def: typeof tool) => { tool = def; }, sendMessage() {} }; - registerBashBgTool(pi as never, new BackgroundRegistry()); + registerBashTool(pi as never, new BackgroundRegistry(), {} as never); const ctx = { cwd: process.cwd(), ui: { notify() {}, setWidget() {}, setStatus() {}, theme: { fg: (_c: string, t: string) => t } }, @@ -70,17 +70,17 @@ void describe("bash_async — rejects a backgrounded sleep wait", () => { return { tool: tool!, ctx }; } - void it("blocks an embedded sleep in bash_async (previously unguarded)", async () => { - const { tool, ctx } = bashBg(); + void it("blocks an embedded sleep in run_async (previously unguarded)", async () => { + const { tool, ctx } = bashAsync(); await assert.rejects( - () => tool.execute("t1", { command: "cd /repo; sleep 600; cat log" }, undefined, undefined, ctx), + () => tool.execute("t1", { command: "cd /repo; sleep 600; cat log", run_async: true }, undefined, undefined, ctx), /Blocked: sleep 600.*bash_async_list action='attach'/s ); }); void it("still allows a real backgrounded command", async () => { - const { tool, ctx } = bashBg(); - const res = await tool.execute("t2", { command: "echo hi" }, undefined, undefined, ctx); + const { tool, ctx } = bashAsync(); + const res = await tool.execute("t2", { command: "echo hi", run_async: true }, undefined, undefined, ctx); assert.ok(res, "non-sleep command runs"); }); }); diff --git a/src/index.ts b/src/index.ts index 0abec93..e4b2382 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ /** * pi-async-bash — asynchronous Bash execution for the Pi agent. * - * Registers `bash`, `bash_async`, `bash_async_list`, `bash_async_watch`, and - * `bash_async_decide`, plus `/bash-async` and `/bash-async-list`. + * Registers `bash` (with `run_async`), `bash_async_list`, `bash_async_watch`, + * and `bash_async_decide`, plus `/bash-async` and `/bash-async-list`. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -12,7 +12,6 @@ import { cleanupStaleRuntimeArtifacts, detectNonInteractive, reviveAndValidate, import { stopSidebarTicker } from "./registry.ts"; import { EVENT } from "./types.ts"; import { registerBashTool } from "./tools/bash.ts"; -import { registerBashBgTool } from "./tools/bash-bg.ts"; import { registerJobsTool } from "./tools/jobs.ts"; import { registerMonitorTool } from "./tools/monitor.ts"; import { registerCommands } from "./commands.ts"; @@ -30,7 +29,6 @@ export default function (pi: ExtensionAPI): void { // that drops them). const originalBash = createBashToolDefinition(process.cwd()); registerBashTool(pi, reg, originalBash); - registerBashBgTool(pi, reg); registerJobsTool(pi, reg); registerMonitorTool(pi, reg); registerJobDecideTool(pi, reg); diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 7eea0f4..8d4bf76 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -90,8 +90,8 @@ export function startBackgroundJob(args: { /** * Standard completion flow after a job exits — abortJob → markTerminal → - * notify → renderSidebar. Shared by every tool's exit callback (bash, - * bash_async, and bash_async_watch as the canonical termination protocol. + * notify → renderSidebar. Shared by every tool's exit callback (bash and + * bash_async_watch) as the canonical termination protocol. * * The notification is Claude Code's per-job , sent the * moment the job exits (see notify.ts). A successful send evicts the job @@ -99,7 +99,7 @@ export function startBackgroundJob(args: { * already known (killed silently, or read via bash_async_list output/attach) skip the * notification and linger until the lazy sweep in `bash_async_list list`. Monitors own * their terminal notification (monitor-session, shouldNotify: false) and are - * evicted here once it has fired. A `shouldNotify: false` job (bash_async + * evicted here once it has fired. A `shouldNotify: false` job (bash run_async * `notify: false`) is latched notified WITHOUT sending — "don't notify" IS * notified — so it evicts too and never lingers as a permanent entry. */ diff --git a/src/monitoring.ts b/src/monitoring.ts index 4c611b0..48cff2f 100644 --- a/src/monitoring.ts +++ b/src/monitoring.ts @@ -33,7 +33,7 @@ import { describeJob } from "./format.ts"; export function watchStalls(args: { jobId: string; command: string; - /** Job name (bash/bash_async `description`); falls back to the command. */ + /** Job name (bash `description`); falls back to the command. */ name?: string; logPath: string; pi: ExtensionAPI; diff --git a/src/registry.ts b/src/registry.ts index 1fc63a2..ea5d2fe 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -58,7 +58,7 @@ export function errPathFor(jobId: string): string { /** * Build a fresh running Job. Centralizes the Job shape so the new `kind`/`stop` - * fields (and any future additions) don't drift across the bash/bash_async/ + * fields (and any future additions) don't drift across the bash/ * bash_async_watch construction sites. */ export function createRunningJob(args: { diff --git a/src/render.ts b/src/render.ts deleted file mode 100644 index 1fce5d8..0000000 --- a/src/render.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Text, type Component } from "@earendil-works/pi-tui"; - -type CallArgs = Record; - -type RenderTheme = { - fg(color: "muted" | "toolTitle", text: string): string; - bold(text: string): string; -}; - -type RenderContext = { - lastComponent?: Component; -}; - -export function renderBashAsyncCall( - args: unknown, - theme: RenderTheme, - context: RenderContext, -): Text { - const values = asCallArgs(args); - const command = nonEmptyString(values.command); - const commandDisplay = command === undefined - ? styledTitle("$ ", theme) + theme.fg("muted", "...") - : styledTitle(`$ ${command}`, theme); - const timeoutSuffix = renderTimeout(values.timeout, theme); - - return renderCall(commandDisplay + timeoutSuffix, context); -} - -function renderCall(content: string, context: RenderContext): Text { - const previousComponent = context.lastComponent; - const text = previousComponent instanceof Text - ? previousComponent - : new Text("", 0, 0); - text.setText(content); - return text; -} - -function renderTimeout(value: unknown, theme: RenderTheme): string { - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? theme.fg("muted", ` (timeout ${value}s)`) - : ""; -} - -function styledTitle(title: string, theme: RenderTheme): string { - return theme.fg("toolTitle", theme.bold(title)); -} - -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 - ? value - : undefined; -} - -function asCallArgs(args: unknown): CallArgs { - return typeof args === "object" && args !== null ? args as CallArgs : {}; -} diff --git a/src/tools/bash-bg.ts b/src/tools/bash-bg.ts deleted file mode 100644 index e76d527..0000000 --- a/src/tools/bash-bg.ts +++ /dev/null @@ -1,116 +0,0 @@ -// src/tools/bash-bg.ts -// -// `bash_async` starts a Bash command asynchronously and returns its job ID. - -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { Type } from "@earendil-works/pi-ai"; -import { appendFileSync } from "node:fs"; -import type { BackgroundRegistry } from "../state.ts"; -import { isTerminalStatus, type UiContext } from "../types.ts"; -import { killProcessTree, spawnWithFileOutput } from "../spawn.ts"; -import { add, createRunningJob, newJobId, logPathFor } from "../registry.ts"; -import { - assertJobSlot, - detectBlockedSleep, - isAutoBackgroundAllowed, - isBlankCommand, - requireExistingCwd, - SLEEP_WAIT_GUIDANCE, - startBackgroundJob, -} from "../lifecycle.ts"; -import { textBlock } from "../format.ts"; -import { renderBashAsyncCall } from "../render.ts"; - -type BashAsyncContext = UiContext & { cwd: string }; - -/** Register immediate asynchronous Bash execution. */ -export function registerBashBgTool(pi: ExtensionAPI, reg: BackgroundRegistry): void { - pi.registerTool({ - name: "bash_async", - label: "Async Bash", - description: "Start a Bash command asynchronously. Output is saved to /tmp/pi-bg/.log.", - promptSnippet: "Start long-running Bash commands asynchronously", - promptGuidelines: [ - "Use bash_async when a command should start asynchronously immediately.", - "Use bash_async_watch for per-event streams; bash_async reports one terminal outcome.", - "Do not start a fixed sleep asynchronously. Use bash_async_list action='attach', bash_async_watch, or an until loop that exits when ready.", - "Give the job a name when it will be easier to track with bash_async_list.", - ], - parameters: Type.Object({ - command: Type.String({ description: "Bash command to run" }), - name: Type.Optional(Type.String({ description: "Label shown by bash_async_list" })), - timeout: Type.Optional(Type.Number({ description: "Decision timeout in seconds" })), - notify: Type.Optional(Type.Boolean({ description: "Send terminal notification (default: true)" })), - }), - renderCall: renderBashAsyncCall, - async execute(toolCallId, params, _signal, _onUpdate, ctx) { - const input = params as { command: string; name?: string; timeout?: number; notify?: boolean }; - const asyncContext = ctx as BashAsyncContext; - if (isBlankCommand(input.command)) throw new Error("Command is empty."); - const sleepMatch = detectBlockedSleep(input.command); - if (sleepMatch) throw new Error(`Blocked: ${sleepMatch}. ${SLEEP_WAIT_GUIDANCE}`); - requireExistingCwd(asyncContext.cwd); - assertJobSlot(reg); - - const id = newJobId("shell", reg); - const logPath = logPathFor(id); - const spawned = spawnWithFileOutput({ command: input.command, cwd: asyncContext.cwd, logPath }); - const job = createRunningJob({ - id, - name: input.name, - command: input.command, - pid: spawned.pid, - logPath, - toolCallId, - }); - add(reg, job); - const jobAbort = startBackgroundJob({ - reg, - pi, - ctx: asyncContext, - job, - exit: spawned.exit, - shouldNotify: input.notify !== false, - }); - scheduleDecisionTimeout(reg, asyncContext, job, input.timeout, jobAbort, logPath); - - return { - content: [textBlock( - `Command running asynchronously with ID: ${id}.` + - `${input.name ? ` Name: ${input.name}.` : ""} Output is being written to: ${logPath}`, - )], - details: undefined, - }; - }, - }); -} - -function scheduleDecisionTimeout( - reg: BackgroundRegistry, - ctx: BashAsyncContext, - job: ReturnType, - timeout: number | undefined, - jobAbort: AbortController, - logPath: string, -): void { - if (!timeout) return; - const timer = setTimeout(() => { - if (isTerminalStatus(job.status) || reg.nonInteractive) return; - if (!isAutoBackgroundAllowed(job.command)) { - try { - appendFileSync(logPath, `Command timed out after ${timeout}s\n`); - } catch { - // Process termination remains correct if diagnostics cannot be written. - } - killProcessTree(job.pid, "SIGTERM"); - return; - } - reg.pendingDecisionJobId = job.id; - ctx.ui.notify( - `Async Bash job ${job.id} exceeded ${timeout}s. Use bash_async_decide to keep, stop, or inspect it.`, - "warning", - ); - }, timeout * 1000); - timer.unref(); - jobAbort.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); -} diff --git a/src/tools/bash-params.ts b/src/tools/bash-params.ts index 225c958..5aaa7e5 100644 --- a/src/tools/bash-params.ts +++ b/src/tools/bash-params.ts @@ -9,18 +9,33 @@ export const bashParamSchema = Type.Object({ timeout: Type.Optional( Type.Number({ description: - "Timeout in seconds (default: pi-async-bash.defaultTimeoutSeconds " + - "from settings.json, or 120).", + "Seconds. With run_async=true this is a DECISION timeout and has no default: " + + "when it expires you are notified and resolve the job with bash_async_decide, " + + "unless the command is not eligible for auto-backgrounding (for example a short " + + "fixed sleep), which is killed. Without run_async it auto-backgrounds a " + + "still-running command when it expires, unless the command is not eligible for " + + "auto-backgrounding, in which case it is killed (default: " + + "pi-async-bash.defaultTimeoutSeconds from settings.json, or 120).", }) ), run_async: Type.Optional( Type.Boolean({ description: - "Set to true to run this command in the background immediately. " + - "Output is saved to /tmp/pi-bg/.log.", + "Set to true to run this command in the background immediately and return its " + + "job ID. Output is saved to /tmp/pi-bg/.log. description becomes the " + + "bash_async_list label.", }) ), description: Type.Optional( Type.String({ description: "Short description of what this command does" }) ), + notify: Type.Optional( + Type.Boolean({ + description: + "Send a terminal notification when a run_async job finishes (default: true). " + + "A command that starts in the foreground and is later backgrounded always notifies. " + + "Set false to suppress the run_async notification; the finished job is then removed " + + "from the live bash_async_list set.", + }) + ), }); diff --git a/src/tools/bash.ts b/src/tools/bash.ts index af7a941..c33cacc 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -3,6 +3,9 @@ * * Single file-descriptor backend (no tmux): * - run_async=true starts asynchronous execution and returns a job handle + * - with run_async, an optional timeout is a decision timeout resolved by bash_async_decide + * - run_async jobs send one terminal notification unless notify=false; + * commands backgrounded from the foreground always notify * - foreground commands race completion against asynchronous handoff * - a 2s quick-completion window skips handoff machinery * - `/bash-async`, cooperative input, or the timeout timer move a command to async execution @@ -24,6 +27,8 @@ import { OUTPUT_PREVIEW_CHARS, QUICK_COMPLETION_MS, type ForegroundSlot, + isTerminalStatus, + type Job, type UiContext, } from "../types.ts"; import { spawnWithFileOutput, killProcessTree, type SpawnExit } from "../spawn.ts"; @@ -63,14 +68,18 @@ export function registerBashTool( name: "bash", description: "Run a Bash command. Long-running commands continue asynchronously after timeout. " + - "Set run_async=true to start asynchronously immediately. " + + "Set run_async=true to start asynchronously immediately; an optional timeout then acts " + + "as a decision timeout resolved by bash_async_decide. " + "Use /bash-async to hand off a running command.", promptSnippet: "Run shell commands; long-running commands continue asynchronously or use run_async=true", promptGuidelines: [ "Use bash with run_async=true when a command is expected to run for a long time.", + "With run_async=true, an optional timeout is a DECISION timeout: after it expires you are notified and bash_async_decide keeps, kills, or inspects the job. Without run_async, timeout auto-backgrounds a still-running command.", + "run_async jobs send one terminal notification; pass notify=false to suppress it. A command that starts in the foreground and is later backgrounded always notifies.", "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.", "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.", + "Give the job a description when it will be easier to track with bash_async_list.", "Check background job status with bash_async_list action='list'.", "Read background output with bash_async_list action='output'.", ], @@ -82,6 +91,7 @@ export function registerBashTool( timeout?: number; run_async?: boolean; description?: string; + notify?: boolean; }; const bashCtx = ctx as BashCtx; @@ -105,6 +115,8 @@ export function registerBashTool( reg, pi, ctx: bashCtx, + timeout: p.timeout, + shouldNotify: p.notify, }); } @@ -119,6 +131,7 @@ export function registerBashTool( ctx: bashCtx, reg, pi, + description: p.description, }); }, }); @@ -135,6 +148,7 @@ async function runForeground(args: { ctx: BashCtx; reg: BackgroundRegistry; pi: ExtensionAPI; + description?: string; }): Promise> { const { toolCallId, command, timeoutMs, signal, onUpdate, ctx, reg, pi } = args; @@ -184,6 +198,7 @@ async function runForeground(args: { const job = createRunningJob({ id, + name: args.description, command, pid: spawned.pid, logPath, @@ -205,7 +220,13 @@ async function runForeground(args: { reg.foreground.delete(toolCallId); job.isBackgrounded = true; markStarted(reg); - startBackgroundJob({ reg, pi, ctx, job, exit: spawned.exit }); + startBackgroundJob({ + reg, + pi, + ctx, + job, + exit: spawned.exit, + }); }; // Timeout timer. @@ -316,6 +337,8 @@ function spawnBackground(args: { reg: BackgroundRegistry; pi: ExtensionAPI; ctx: UiContext; + timeout?: number; + shouldNotify?: boolean; }): AgentToolResult { const id = newJobId("shell", args.reg); const logPath = logPathFor(id); @@ -335,7 +358,22 @@ function spawnBackground(args: { toolCallId: args.toolCallId, }); add(args.reg, job); - startBackgroundJob({ reg: args.reg, pi: args.pi, ctx: args.ctx, job, exit: spawned.exit }); + const jobAbort = startBackgroundJob({ + reg: args.reg, + pi: args.pi, + ctx: args.ctx, + job, + exit: spawned.exit, + shouldNotify: args.shouldNotify, + }); + scheduleDecisionTimeout({ + reg: args.reg, + ctx: args.ctx, + job, + timeout: args.timeout, + jobAbort, + logPath, + }); return { content: [ @@ -346,3 +384,41 @@ function spawnBackground(args: { details: undefined, }; } + +// --- Decision timeout (run_async path) ----------------------------------- + +/** + * After `timeout` seconds, hand the still-running async job to the model: + * non-eligible commands are killed loudly, eligible ones raise a warning the + * model resolves via `bash_async_decide`. + */ +function scheduleDecisionTimeout(args: { + reg: BackgroundRegistry; + ctx: UiContext; + job: Job; + timeout: number | undefined; + jobAbort: AbortController; + logPath: string; +}): void { + const { reg, ctx, job, timeout, jobAbort, logPath } = args; + if (!timeout) return; + const timer = setTimeout(() => { + if (isTerminalStatus(job.status) || reg.nonInteractive) return; + if (!isAutoBackgroundAllowed(job.command)) { + try { + appendFileSync(logPath, `Command timed out after ${timeout}s\n`); + } catch { + // Process termination remains correct if diagnostics cannot be written. + } + killProcessTree(job.pid, "SIGTERM"); + return; + } + reg.pendingDecisionJobId = job.id; + ctx.ui.notify( + `Async Bash job ${job.id} exceeded ${timeout}s. Use bash_async_decide to keep, stop, or inspect it.`, + "warning", + ); + }, timeout * 1000); + timer.unref(); + jobAbort.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); +} diff --git a/src/tools/job-decide.ts b/src/tools/job-decide.ts index d9de55a..00660c8 100644 --- a/src/tools/job-decide.ts +++ b/src/tools/job-decide.ts @@ -14,7 +14,7 @@ export function registerJobDecideTool(pi: ExtensionAPI, reg: BackgroundRegistry) description: "Keep, stop, or inspect an asynchronous Bash command that exceeded its timeout.", promptSnippet: "Resolve a timed asynchronous Bash command", promptGuidelines: [ - "Use bash_async_decide after a bash_async timeout notification.", + "Use bash_async_decide after a bash run_async timeout notification.", "keep leaves the command running, kill stops it, and check shows its current output.", ], parameters: Type.Object({ diff --git a/src/tools/monitor.ts b/src/tools/monitor.ts index b7e061d..643f52f 100644 --- a/src/tools/monitor.ts +++ b/src/tools/monitor.ts @@ -2,7 +2,7 @@ // // `monitor` tool — a streaming-event background watch. Each stdout line (or // WebSocket text frame) becomes one notification delivered into the agent's -// turn. This is distinct from bash_async/run_async (one notification on +// turn. This is distinct from run_async (one notification on // completion): monitor is for per-event streams (tail -f | grep, poll loops, // file watches, ws feeds), not one-shot "wait until done". From 1a72f573b9553658947d8bd8b40fea78321b65c5 Mon Sep 17 00:00:00 2001 From: Dustin Fox Date: Sun, 20 Sep 2026 23:27:50 -0400 Subject: [PATCH 2/2] feat(bash)!: simplify the async tool surface Reduce the tool surface to bash and bash_async. Remove the bash_async decide and watch tools plus the async decision-timeout flow, so bash timeout is foreground-only and is ignored with run_async=true. Rename the job-management tool bash_async_list to bash_async, and drop the machinery the removed tools left behind. Start async work with bash and run_async=true. --- README.md | 46 +++--- package.json | 5 +- src/__tests__/bash-async-notify.test.ts | 86 ++++++++++ src/__tests__/bash-async-timeout.test.ts | 168 ------------------- src/__tests__/bash-results.test.ts | 21 ++- src/__tests__/features.test.ts | 2 +- src/__tests__/format.test.ts | 4 +- src/__tests__/index.test.ts | 6 +- src/__tests__/interface.test.ts | 11 +- src/__tests__/job-decide.test.ts | 32 ---- src/__tests__/jobs.test.ts | 6 +- src/__tests__/monitor-follow.test.ts | 102 ------------ src/__tests__/monitor-session.test.ts | 155 ------------------ src/__tests__/monitor.test.ts | 196 ----------------------- src/__tests__/notify.test.ts | 38 +---- src/__tests__/registry.test.ts | 7 +- src/__tests__/sleep-guard.test.ts | 2 +- src/__tests__/spawn.test.ts | 2 +- src/format.ts | 8 +- src/index.ts | 8 +- src/lifecycle.ts | 49 ++---- src/log-search.ts | 2 +- src/monitor-follow.ts | 103 ------------ src/monitor-session.ts | 164 ------------------- src/monitor-source.ts | 62 ------- src/monitor-ws.ts | 132 --------------- src/monitoring.ts | 7 +- src/notify.ts | 37 ++--- src/output.ts | 2 +- src/registry.ts | 26 +-- src/spawn.ts | 22 +-- src/state.ts | 3 +- src/tools/bash-params.ts | 15 +- src/tools/bash.ts | 71 ++------ src/tools/job-decide.ts | 60 ------- src/tools/jobs.ts | 8 +- src/tools/monitor.ts | 165 ------------------- src/types.ts | 39 +---- src/ui.ts | 2 +- 39 files changed, 228 insertions(+), 1646 deletions(-) create mode 100644 src/__tests__/bash-async-notify.test.ts delete mode 100644 src/__tests__/bash-async-timeout.test.ts delete mode 100644 src/__tests__/job-decide.test.ts delete mode 100644 src/__tests__/monitor-follow.test.ts delete mode 100644 src/__tests__/monitor-session.test.ts delete mode 100644 src/__tests__/monitor.test.ts delete mode 100644 src/monitor-follow.ts delete mode 100644 src/monitor-session.ts delete mode 100644 src/monitor-source.ts delete mode 100644 src/monitor-ws.ts delete mode 100644 src/tools/job-decide.ts delete mode 100644 src/tools/monitor.ts diff --git a/README.md b/README.md index b912f1e..663d3c3 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![Node.js](https://img.shields.io/node/v/pi-async-bash)](https://www.npmjs.com/package/pi-async-bash) [![License](https://img.shields.io/github/license/unrelentingfox/pi-async-bash)](https://github.com/unrelentingfox/pi-async-bash/blob/main/LICENSE) -`pi-async-bash` adds asynchronous Bash execution, task management, and event -watches to [Pi](https://github.com/badlogic/pi-mono). +`pi-async-bash` adds asynchronous Bash execution and task management to +[Pi](https://github.com/badlogic/pi-mono). ## Install @@ -31,10 +31,8 @@ During development, add the local checkout to Pi settings: | Tool | Parameters | Behavior | | --- | --- | --- | -| `bash` | `command`; optional `timeout`, `run_async`, `description`, `notify` | Overrides Pi's Bash tool. Commands start in the foreground by default. `run_async: true` starts detached work immediately; an optional `timeout` then becomes a decision timeout resolved by `bash_async_decide`. A long foreground command continues asynchronously after `timeout`. `run_async` jobs send one terminal notification unless `notify: false`; commands backgrounded from the foreground always notify. | -| `bash_async_list` | `action`: `list`, `output`, `kill`, `attach`, `search`, `cleanup`, or `stats`; action-specific `jobId`, `pattern`, `wait` | Lists, reads, follows, searches, stops, cleans up, and reports on async jobs and watches. | -| `bash_async_watch` | exactly one of `command` or `ws`; `description`; optional `persistent`, `timeout_ms` | Returns immediately and delivers each stdout line or WebSocket text frame as an event. It stops on source exit, timeout, event-rate protection, or `bash_async_list` `kill`. | -| `bash_async_decide` | `jobId`; `decision`: `keep`, `kill`, or `check` | Resolves a `bash` command started with `run_async` that has exceeded its optional decision timeout. | +| `bash` | `command`; optional `timeout`, `run_async`, `description`, `notify` | Overrides Pi's Bash tool. Commands start in the foreground by default. `run_async: true` starts detached work immediately. `timeout` applies to the foreground path: a long foreground command auto-backgrounds after `timeout`, and is ignored with `run_async=true`. `run_async` jobs send one terminal notification unless `notify: false`; commands backgrounded from the foreground always notify. | +| `bash_async` | `action`: `list`, `output`, `kill`, `attach`, `search`, `cleanup`, or `stats`; action-specific `jobId`, `pattern`, `wait` | Lists, reads, follows, searches, stops, cleans up, and reports on async jobs. | `run_in_background` is not supported. Use `run_async` instead. @@ -57,7 +55,7 @@ The 2-second quick-completion window still returns fast commands inline. Values at or below 2 seconds background commands immediately after that window; the 5-second setting above gives slower commands three more seconds to finish. A `timeout` passed to an individual `bash` call overrides this setting. This -setting does not affect the separate decision timeout on a `run_async` command. +setting applies to foreground commands; it is ignored for a `run_async` command. ## Slash commands @@ -75,32 +73,40 @@ a follow-up. - Async commands use a detached process group and capture combined output in `/tmp/pi-bg`. -- `bash_async_list attach` streams output while it waits. Use `output` for a +- `bash_async attach` streams output while it waits. Use `output` for a bounded non-blocking read. -- `bash_async_watch` is for many events. Use `bash` with `run_async: true` - when only the terminal result matters. - The extension rejects naïve long `sleep` commands, warns about interactive prompts, caps non-persistent log growth at 100 MiB, limits concurrent jobs, and removes stale log files at startup. - Same-process `/reload` restores compatible running job records. A fresh Pi process never adopts or signals an old process ID, preventing PID reuse bugs. -## Migration from upstream +## Upgrading from pi-async-bash 0.x + +The START tool named `bash_async` was removed — start async jobs with `bash` +and `run_async: true`. Separately, the job-MANAGEMENT tool named +`bash_async_list` was renamed to `bash_async`; it is not the old start tool +and does not start jobs. Also: the start tool's `name` parameter is now +`description`, `notify` (which applies to `run_async` starts) is unchanged, +`timeout` now applies only to the foreground path (it is ignored with +`run_async`), and the `bash_async_decide` tool was also removed. The async +tool result is now the plain Claude Code handle string and no longer repeats +the job label; the label still shows in `bash_async` and job notifications. -Upgrading this package from a previous release: the separate `bash_async` -tool was removed. Use `bash` with `run_async: true`. Its `name` parameter is -now `description`, `notify` (which applies to `run_async` starts) is unchanged, and `timeout` on `run_async` is the -decision timeout resolved by `bash_async_decide`. The async tool result is now -the plain Claude Code handle string and no longer repeats the job label; the -label still shows in `bash_async_list` and job notifications. +| 0.x | current | +| --- | --- | +| `bash_async` (start tool) | `bash` with `run_async` | +| `bash_async_list` | `bash_async` | + +## Migration from upstream | Upstream | pi-async-bash | | --- | --- | | `bash` with `run_in_background` | `bash` with `run_async` | | `bash_bg` | `bash` with `run_async` | -| `jobs` | `bash_async_list` | -| `monitor` | `bash_async_watch` | -| `job_decide` | `bash_async_decide` | +| `jobs` | `bash_async` | +| `monitor` | removed | +| `job_decide` | removed; use `bash_async` | | `/bg` | `/bash-async` | | `/bg-list` | `/bash-async-list` | | `/bg-version` | removed | diff --git a/package.json b/package.json index ec245f0..917c970 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,14 @@ { "name": "pi-async-bash", "version": "0.1.0", - "description": "Asynchronous Bash tools for Pi: foreground Bash handoff, async jobs, event watches, and task management.", + "description": "Asynchronous Bash execution and task management for Pi.", "type": "module", "keywords": [ "pi-package", "pi-extension", "async", "bash", - "background-jobs", - "monitoring" + "background-jobs" ], "author": "Dustin Fox", "license": "MIT", diff --git a/src/__tests__/bash-async-notify.test.ts b/src/__tests__/bash-async-notify.test.ts new file mode 100644 index 0000000..1601dbc --- /dev/null +++ b/src/__tests__/bash-async-notify.test.ts @@ -0,0 +1,86 @@ +/** + * `bash run_async` — notify plumbing: + * - notify=false suppresses the terminal while still + * evicting the job from the live registry, + * - default notify sends exactly one terminal . + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { BackgroundRegistry } from "../state.ts"; +import { registerBashTool } from "../tools/bash.ts"; +import { EVENT, isTerminalStatus } from "../types.ts"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +interface ToolDef { + execute: ( + toolCallId: string, + params: unknown, + signal: unknown, + onUpdate: unknown, + ctx: unknown + ) => Promise<{ content: Array<{ type: "text"; text: string }> }>; +} + +interface CapturedMessage { + customType: string; + content: string; +} + +function harness() { + let tool: ToolDef | undefined; + const messages: CapturedMessage[] = []; + const pi = { + registerTool: (def: ToolDef) => { tool = def; }, + sendMessage: (m: CapturedMessage) => { messages.push(m); }, + }; + const reg = new BackgroundRegistry(); + registerBashTool(pi as never, reg, {} as never); + const ctx = { + cwd: process.cwd(), + ui: { + notify: () => {}, + setWidget: () => {}, + setStatus: () => {}, + theme: { fg: (_c: string, t: string) => t }, + }, + }; + return { tool: tool!, reg, ctx, messages }; +} + +void describe("bash run_async — notify plumbing", () => { + void it("notify: false sends no task-notification and evicts the job", async () => { + const { tool, reg, ctx, messages } = harness(); + await tool.execute( + "t-notify-off", + { command: "echo hi", run_async: true, notify: false }, + undefined, + undefined, + ctx + ); + // Wait for the job to reach a terminal status. + for (let i = 0; i < 50 && [...reg.jobs.values()].some((j) => !isTerminalStatus(j.status)); i++) { + await sleep(50); + } + const terminals = messages.filter((m) => m.customType === EVENT.taskNotification); + assert.equal(terminals.length, 0, "notification suppressed"); + assert.equal(reg.jobs.size, 0, "job evicted from the live registry"); + }); + + void it("default notify sends exactly one task-notification", async () => { + const { tool, reg, ctx, messages } = harness(); + await tool.execute( + "t-notify-on", + { command: "echo hi", run_async: true }, + undefined, + undefined, + ctx + ); + for (let i = 0; i < 50 && messages.length === 0; i++) { + await sleep(50); + } + const terminals = messages.filter((m) => m.customType === EVENT.taskNotification); + assert.equal(terminals.length, 1, "positive control: one notification"); + }); +}); diff --git a/src/__tests__/bash-async-timeout.test.ts b/src/__tests__/bash-async-timeout.test.ts deleted file mode 100644 index d03cf57..0000000 --- a/src/__tests__/bash-async-timeout.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * `bash run_async` — decision timeout behavior: - * - kill branch: a log marker plus a killed (the agent - * must learn its command was timeout-killed), - * - decision branch: a pending decision plus a warning for the model to - * resolve via bash_async_decide, with the process left alive. - * - * Also covers the `notify` plumbing: notify=false suppresses the terminal - * while still evicting the job from the live registry. - */ - -import { describe, it, after } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { BackgroundRegistry } from "../state.ts"; -import { registerBashTool } from "../tools/bash.ts"; -import { EVENT, isTerminalStatus } from "../types.ts"; -import { killProcessTree, processExists } from "../spawn.ts"; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -interface ToolDef { - execute: ( - toolCallId: string, - params: unknown, - signal: unknown, - onUpdate: unknown, - ctx: unknown - ) => Promise<{ content: Array<{ type: "text"; text: string }> }>; -} - -interface CapturedMessage { - customType: string; - content: string; -} - -function harness() { - let tool: ToolDef | undefined; - const messages: CapturedMessage[] = []; - const notifications: Array<{ message: string; level: string }> = []; - const pi = { - registerTool: (def: ToolDef) => { tool = def; }, - sendMessage: (m: CapturedMessage) => { messages.push(m); }, - }; - const reg = new BackgroundRegistry(); - registerBashTool(pi as never, reg, {} as never); - const ctx = { - cwd: process.cwd(), - ui: { - notify: (message: string, level: string) => { - notifications.push({ message, level }); - }, - setWidget: () => {}, - setStatus: () => {}, - theme: { fg: (_c: string, t: string) => t }, - }, - }; - return { tool: tool!, reg, ctx, messages, notifications }; -} - -const spawnedPids: number[] = []; - -void describe("bash run_async — decision timeout", () => { - void it("kill branch: marks the log AND sends a killed ", async () => { - const { tool, ctx, messages } = harness(); - // `sleep` is excluded from auto-backgrounding, and a float duration - // slips past the blocked-sleep guard — so the decision timeout hits - // the kill path (same trick as the foreground timeout test). - const res = await tool.execute( - "t1", - { command: "sleep 1.5", run_async: true, timeout: 1 }, - undefined, - undefined, - ctx - ); - const logPath = /Output is being written to: (\S+)/.exec(res.content[0].text)?.[1]; - assert.ok(logPath, "tool result carries the log path"); - - // Bounded poll: the 1s timeout fires, SIGTERM, exit handler notifies. - const terminals = () => messages.filter((m) => m.customType === EVENT.taskNotification); - for (let i = 0; i < 60 && terminals().length === 0; i++) { - await sleep(50); - } - - assert.equal(terminals().length, 1, "the agent learns its command was timeout-killed"); - assert.ok(terminals()[0].content.includes("killed")); - assert.ok(terminals()[0].content.includes("was stopped")); - assert.match( - readFileSync(logPath, "utf-8"), - /Command timed out after 1s/, - "log marker tells a timeout kill apart from a normal failure" - ); - }); - - void it("decision branch: marks the decision pending, warns, and leaves the process alive", async () => { - const { tool, reg, ctx, notifications } = harness(); - // `tail -f` is auto-background-eligible, so the decision timeout hits - // the decision path instead of the kill path. - const res = await tool.execute( - "t1-decide", - { command: "tail -f /dev/null", run_async: true, timeout: 1 }, - undefined, - undefined, - ctx - ); - const jobId = /Command running asynchronously with ID: (\S+)\./.exec(res.content[0].text)?.[1]; - assert.ok(jobId, "tool result carries the job id"); - const job = reg.jobs.get(jobId); - if (job) spawnedPids.push(job.pid); - assert.ok(job, "job is registered"); - - // Bounded poll: don't assert synchronously on a ~200ms margin after a - // 1000ms timer (timing flake under load). - for (let i = 0; i < 60 && reg.pendingDecisionJobId !== jobId; i++) { - await sleep(50); - } - - assert.equal(reg.pendingDecisionJobId, jobId, "the job awaits bash_async_decide"); - assert.equal(job!.status, "running", "the job was not killed"); - assert.ok(processExists(job!.pid), "the process is STILL alive"); - const warnings = notifications.filter((n) => n.level === "warning"); - assert.equal(warnings.length, 1, "the model is notified to decide"); - assert.ok(warnings[0].message.includes(`exceeded 1s`)); - assert.ok(warnings[0].message.includes(jobId)); - }); -}); - -void describe("bash run_async — notify plumbing", () => { - void it("notify: false sends no task-notification and evicts the job", async () => { - const { tool, reg, ctx, messages } = harness(); - await tool.execute( - "t-notify-off", - { command: "echo hi", run_async: true, notify: false }, - undefined, - undefined, - ctx - ); - // Wait for the job to reach a terminal status. - for (let i = 0; i < 50 && [...reg.jobs.values()].some((j) => !isTerminalStatus(j.status)); i++) { - await sleep(50); - } - const terminals = messages.filter((m) => m.customType === EVENT.taskNotification); - assert.equal(terminals.length, 0, "notification suppressed"); - assert.equal(reg.jobs.size, 0, "job evicted from the live registry"); - }); - - void it("default notify sends exactly one task-notification", async () => { - const { tool, reg, ctx, messages } = harness(); - await tool.execute( - "t-notify-on", - { command: "echo hi", run_async: true }, - undefined, - undefined, - ctx - ); - for (let i = 0; i < 50 && messages.length === 0; i++) { - await sleep(50); - } - const terminals = messages.filter((m) => m.customType === EVENT.taskNotification); - assert.equal(terminals.length, 1, "positive control: one notification"); - }); -}); - -after(() => { - for (const pid of spawnedPids) { - try { killProcessTree(pid, "SIGKILL"); } catch { /* already gone */ } - } -}); diff --git a/src/__tests__/bash-results.test.ts b/src/__tests__/bash-results.test.ts index 82ff467..9e3e9e5 100644 --- a/src/__tests__/bash-results.test.ts +++ b/src/__tests__/bash-results.test.ts @@ -1,8 +1,9 @@ import { describe, it, after } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { BackgroundRegistry } from "../state.ts"; import { registerBashTool } from "../tools/bash.ts"; -import { killProcessTree } from "../spawn.ts"; +import { killProcessTree, processExists } from "../spawn.ts"; import { EVENT, isTerminalStatus, type Job } from "../types.ts"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -201,6 +202,24 @@ void describe("bash tool — Claude Code tool-result strings", () => { assert.match(res.content[0].text, /Command timed out after 1s/); }); + void it("run_async ignores timeout — the job is still running 1.5s past a 1s timeout", async () => { + const { tool, reg, ctx } = harness(); + await tool.execute( + "t6-timeout-ignored", + { command: "tail -f /dev/null", run_async: true, timeout: 1 }, + undefined, + undefined, + ctx + ); + const job = onlyJob(reg); + spawnedPids.push(job.pid); + await sleep(1500); + assert.equal(job.status, "running", "timeout must not terminal an async job"); + assert.equal(processExists(job.pid), true, "the process survived the timeout"); + const log = readFileSync(job.logPath, "utf8"); + assert.ok(!log.includes("Command timed out after"), "no timeout marker in the log"); + }); + void it("an external signal death is reported as killed ('was stopped'), never completed", async () => { const { tool, reg, ctx, messages } = harness(); await tool.execute( diff --git a/src/__tests__/features.test.ts b/src/__tests__/features.test.ts index 5d6616c..1ad184e 100644 --- a/src/__tests__/features.test.ts +++ b/src/__tests__/features.test.ts @@ -29,7 +29,7 @@ const TMP = "/tmp/pi-patty-features-test"; function makeJob(overrides: Partial = {}): Job { return { - id: newJobId("shell"), + id: newJobId(), command: "echo hello", pid: 1, startTime: Date.now(), diff --git a/src/__tests__/format.test.ts b/src/__tests__/format.test.ts index afbefa4..3f0061d 100644 --- a/src/__tests__/format.test.ts +++ b/src/__tests__/format.test.ts @@ -120,7 +120,7 @@ void describe("formatJobLine", () => { toolCallId: "tc-1", isBackgrounded: true, }; - assert.match(formatJobLine(job), /^job-1-1 \[shell\]: sleep 60 - ▶ bg \(5s\) \(5s\)$/); + assert.match(formatJobLine(job), /^job-1-1: sleep 60 - ▶ bg \(5s\) \(5s\)$/); }); void it("named jobs show the name first", () => { const job: Job = { @@ -134,7 +134,7 @@ void describe("formatJobLine", () => { toolCallId: "tc-1", isBackgrounded: false, }; - assert.match(formatJobLine(job), /^build \(job-1-2\) \[shell\]:/); + assert.match(formatJobLine(job), /^build \(job-1-2\):/); }); void it("terminal jobs render their status label", () => { const job = makeJob({ status: "completed" }); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 24aa445..de2ec9c 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -74,12 +74,12 @@ void describe("session reload continuity", () => { isProjectTrusted: () => false, sessionManager: { getEntries: () => [snapshot] }, }); - const list = await second.tools.get("bash_async_list")!.execute( + const list = await second.tools.get("bash_async")!.execute( "tool-2", { action: "list" }, undefined, undefined, context, ) as { content: Array<{ text: string }> }; assert.match(list.content[0]!.text, new RegExp(id!)); - const restored = second.tools.get("bash_async_list")!; + const restored = second.tools.get("bash_async")!; const stopped = await restored.execute( "tool-4", { action: "kill", jobId: id }, undefined, undefined, context, ) as { content: Array<{ text: string }> }; @@ -112,7 +112,7 @@ void describe("session reload continuity", () => { isProjectTrusted: () => false, sessionManager: { getEntries: () => [snapshot] }, }); - const list = await h.tools.get("bash_async_list")!.execute( + const list = await h.tools.get("bash_async")!.execute( "tool-3", { action: "list" }, undefined, undefined, context, ) as { content: Array<{ text: string }> }; assert.equal(list.content[0]!.text, "No background jobs"); diff --git a/src/__tests__/interface.test.ts b/src/__tests__/interface.test.ts index 0ce3bcc..9d2a7b4 100644 --- a/src/__tests__/interface.test.ts +++ b/src/__tests__/interface.test.ts @@ -28,11 +28,18 @@ void describe("pi-async-bash public interface", () => { const { tools } = loadExtension(); assert.deepEqual([...tools.keys()].sort(), [ "bash", + "bash_async", + ]); + for (const legacy of [ "bash_async_decide", "bash_async_list", "bash_async_watch", - ]); - for (const legacy of ["bash_async", "bash_bg", "jobs", "monitor", "job_decide", "agent_bg"]) { + "bash_bg", + "jobs", + "monitor", + "job_decide", + "agent_bg", + ]) { assert.equal(tools.has(legacy), false, `${legacy} must not be registered`); } }); diff --git a/src/__tests__/job-decide.test.ts b/src/__tests__/job-decide.test.ts deleted file mode 100644 index 242b5ef..0000000 --- a/src/__tests__/job-decide.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { BackgroundRegistry } from "../state.ts"; -import { add, createRunningJob } from "../registry.ts"; -import { registerJobDecideTool } from "../tools/job-decide.ts"; - -function harness() { - let tool: { execute: (id: string, input: unknown, signal: unknown, update: unknown, ctx: unknown) => Promise } | undefined; - registerJobDecideTool({ registerTool(value: typeof tool) { tool = value; } } as never, new BackgroundRegistry()); - return tool!; -} - -void describe("bash_async_decide", () => { - it("keeps a timed job running without marking its terminal outcome consumed", async () => { - const registry = new BackgroundRegistry(); - const job = createRunningJob({ - id: `b${process.pid}-decision`, - command: "sleep 60", - pid: 0, - logPath: "/tmp/decision.log", - toolCallId: "tool-1", - }); - add(registry, job); - registry.pendingDecisionJobId = job.id; - let tool: { execute: (id: string, input: unknown, signal: unknown, update: unknown, ctx: unknown) => Promise } | undefined; - registerJobDecideTool({ registerTool(value: typeof tool) { tool = value; } } as never, registry); - await tool!.execute("tool-2", { jobId: job.id, decision: "keep" }, undefined, undefined, {}); - assert.equal(registry.pendingDecisionJobId, undefined); - assert.equal(job.notified, undefined); - assert.equal(job.status, "running"); - }); -}); diff --git a/src/__tests__/jobs.test.ts b/src/__tests__/jobs.test.ts index dd78d9e..10fc88c 100644 --- a/src/__tests__/jobs.test.ts +++ b/src/__tests__/jobs.test.ts @@ -1,7 +1,7 @@ /** * `jobs` tool actions — kill result strings (CC's TaskStopTool), unknown-id * errors, read-marks-notified (CC's TaskOutputTool), and the lazy eviction - * sweep in `bash_async_list list`. + * sweep in `bash_async list`. */ import { describe, it } from "node:test"; @@ -97,7 +97,7 @@ void describe("jobs kill — CC's TaskStopTool", () => { }); }); -void describe("bash_async_list output — read-marks-notified", () => { +void describe("bash_async output — read-marks-notified", () => { void it("marks a terminal job notified on read", async () => { const { tool, reg, ctx } = harness(); const job = mkJob(reg, { id: `job-${process.pid}-o1` }); @@ -123,7 +123,7 @@ void describe("bash_async_list output — read-marks-notified", () => { }); }); -void describe("bash_async_list list — lazy sweep", () => { +void describe("bash_async list — lazy sweep", () => { void it("sweeps terminal+notified jobs into the recent-terminal ring", async () => { const { tool, reg, ctx } = harness(); const running = mkJob(reg, { id: `job-${process.pid}-l1`, command: "run-me" }); diff --git a/src/__tests__/monitor-follow.test.ts b/src/__tests__/monitor-follow.test.ts deleted file mode 100644 index 9bc8b32..0000000 --- a/src/__tests__/monitor-follow.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { appendFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { followLines } from "../monitor-follow.ts"; - -const dir = join(tmpdir(), `pi-bg-follow-${process.pid}`); -mkdirSync(dir, { recursive: true }); - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -const TICK = 15; - -void describe("monitor-follow / followLines", () => { - void it("emits only complete lines and holds a partial trailing line", async () => { - const p = join(dir, "partial.log"); - writeFileSync(p, ""); - const batches: string[][] = []; - const f = followLines(p, (lines) => batches.push(lines), TICK); - - appendFileSync(p, "alpha\nbeta\npar"); // 'par' has no newline yet - await sleep(TICK * 3); - assert.deepEqual(batches.flat(), ["alpha", "beta"]); - - appendFileSync(p, "tial\n"); // completes 'partial' - await sleep(TICK * 3); - f.stop(); - assert.deepEqual(batches.flat(), ["alpha", "beta", "partial"]); - }); - - void it("batches lines that land within one tick into a single event", async () => { - const p = join(dir, "batch.log"); - writeFileSync(p, ""); - const batches: string[][] = []; - const f = followLines(p, (lines) => batches.push(lines), TICK); - - appendFileSync(p, "one\ntwo\nthree\n"); - await sleep(TICK * 3); - f.stop(); - assert.equal(batches.length, 1); - assert.deepEqual(batches[0], ["one", "two", "three"]); - }); - - void it("tracks offset forward across successive appends", async () => { - const p = join(dir, "offset.log"); - writeFileSync(p, ""); - const seen: string[] = []; - const f = followLines(p, (lines) => seen.push(...lines), TICK); - - appendFileSync(p, "a\n"); - await sleep(TICK * 2); - appendFileSync(p, "b\n"); - await sleep(TICK * 2); - appendFileSync(p, "c\n"); - await sleep(TICK * 2); - f.stop(); - assert.deepEqual(seen, ["a", "b", "c"]); // no re-emits - }); - - void it("flushes a final newline-less line on stop(true)", async () => { - const p = join(dir, "flush.log"); - writeFileSync(p, ""); - const seen: string[] = []; - const f = followLines(p, (lines) => seen.push(...lines), TICK); - - appendFileSync(p, "done\nlast-without-newline"); - await sleep(TICK * 2); - f.stop(true); - assert.deepEqual(seen, ["done", "last-without-newline"]); - }); - - void it("does not emit the trailing partial when stop() omits flush", async () => { - const p = join(dir, "noflush.log"); - writeFileSync(p, ""); - const seen: string[] = []; - const f = followLines(p, (lines) => seen.push(...lines), TICK); - - appendFileSync(p, "kept\ndropped-partial"); - await sleep(TICK * 2); - f.stop(false); - assert.deepEqual(seen, ["kept"]); - }); - - void it("tolerates a not-yet-created log file", async () => { - const p = join(dir, "later.log"); - const seen: string[] = []; - const f = followLines(p, (lines) => seen.push(...lines), TICK); - await sleep(TICK * 2); - writeFileSync(p, "finally\n"); - await sleep(TICK * 3); - f.stop(); - assert.deepEqual(seen, ["finally"]); - }); -}); - -process.on("exit", () => { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - /* best-effort */ - } -}); diff --git a/src/__tests__/monitor-session.test.ts b/src/__tests__/monitor-session.test.ts deleted file mode 100644 index ee8464f..0000000 --- a/src/__tests__/monitor-session.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { appendFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BackgroundRegistry } from "../state.ts"; -import { add, createRunningJob } from "../registry.ts"; -import { startMonitorSession } from "../monitor-session.ts"; -import type { MonitorSource } from "../monitor-source.ts"; -import type { SpawnExit } from "../spawn.ts"; -import { EVENT, type UiContext } from "../types.ts"; - -const dir = join(tmpdir(), `pi-bg-session-${process.pid}`); -mkdirSync(dir, { recursive: true }); -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -interface Msg { - customType: string; - content: string; - details?: { terminal?: boolean }; -} - -function harness(logPath: string) { - const messages: Msg[] = []; - const pi = { sendMessage: (m: Msg) => messages.push(m) }; - const ctx = { - ui: { notify() {}, setWidget() {}, setStatus() {}, theme: { fg: (_c: string, t: string) => t } }, - } as unknown as UiContext; - const reg = new BackgroundRegistry(); - - let resolveExit!: (result: SpawnExit) => void; - const exit = new Promise((res) => { - resolveExit = res; - }); - let stopped = false; - const source: MonitorSource = { - logPath, - pid: 0, - label: "fake", - exit, - stop: () => { - stopped = true; - }, - }; - - const job = createRunningJob({ - id: `job-${process.pid}-1`, - name: "watch", // production sets name: description (see tools/monitor.ts) - command: source.label, - pid: source.pid, - logPath, - toolCallId: "t", - kind: "monitor", - }); - add(reg, job); - - const start = (over?: { persistent?: boolean; timeoutMs?: number }) => - startMonitorSession({ - pi: pi as never, - reg, - ctx, - job, - source, - description: "watch", - persistent: over?.persistent ?? false, - timeoutMs: over?.timeoutMs ?? 60_000, - }); - - // The terminal notice is its own , sent the moment the - // source ends — no coalescing window, no flush to force. - const terminals = () => messages.filter((m) => m.customType === EVENT.taskNotification); - const allText = () => messages.map((m) => m.content).join("\n"); - - return { messages, reg, job, start, resolveExit, isStopped: () => stopped, terminals, allText }; -} - -void describe("monitor-session — lifecycle via a fake source", () => { - void it("streams lines and emits exactly one 'stream ended' terminal on clean exit", async () => { - const logPath = join(dir, "ok.log"); - writeFileSync(logPath, ""); - const h = harness(logPath); - h.start(); - appendFileSync(logPath, "line-A\nline-B\n"); - await sleep(40); - h.resolveExit({ code: 0, signal: null }); - await sleep(60); - - assert.match(h.allText(), /line-A/); - assert.match(h.allText(), /line-B/); - assert.equal(h.terminals().length, 1); - const xml = h.terminals()[0].content; - assert.ok(xml.includes("completed")); - assert.ok(xml.includes(`Monitor "watch" stream ended`)); - // Evicted by completeJob once the notification has fired. - assert.equal(h.reg.jobs.has(h.job.id), false); - }); - - void it("maps a non-zero exit to a failure terminal", async () => { - const logPath = join(dir, "fail.log"); - writeFileSync(logPath, ""); - const h = harness(logPath); - h.start(); - h.resolveExit({ code: 1, signal: null }); - await sleep(60); - assert.equal(h.terminals().length, 1); - assert.ok(h.terminals()[0].content.includes("failed")); - assert.ok(h.terminals()[0].content.includes(`Monitor "watch" script failed (exit 1)`)); - }); - - void it("maps a signal death to a 'stopped' terminal", async () => { - const logPath = join(dir, "signal.log"); - writeFileSync(logPath, ""); - const h = harness(logPath); - h.start(); - h.resolveExit({ code: null, signal: "SIGKILL" }); - await sleep(60); - assert.equal(h.terminals().length, 1); - assert.ok(h.terminals()[0].content.includes("killed")); - assert.ok(h.terminals()[0].content.includes(`Monitor "watch" stopped`)); - }); - - void it("trips the firehose guard, tears down the source, and kills the job", async () => { - const logPath = join(dir, "flood.log"); - writeFileSync(logPath, ""); - const h = harness(logPath); - h.start(); - appendFileSync(logPath, Array.from({ length: 600 }, (_, i) => `e${i}`).join("\n") + "\n"); - await sleep(300); // let a follower tick read the burst - - assert.equal(h.terminals().length, 1, "exactly one terminal"); - assert.match(h.terminals()[0].content, /too many events/); - assert.ok(h.isStopped(), "source.stop was called via the kill path"); - assert.equal(h.job.status, "killed"); - }); - - void it("does not emit a second terminal after the source exits", async () => { - const logPath = join(dir, "once.log"); - writeFileSync(logPath, ""); - const h = harness(logPath); - h.start(); - h.resolveExit({ code: 0, signal: null }); - await sleep(60); - h.resolveExit({ code: 1, signal: null }); // ignored — promise already settled - await sleep(40); - assert.equal(h.terminals().length, 1); - }); -}); - -process.on("exit", () => { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - /* best-effort */ - } -}); diff --git a/src/__tests__/monitor.test.ts b/src/__tests__/monitor.test.ts deleted file mode 100644 index bf0ef69..0000000 --- a/src/__tests__/monitor.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, it, before, after } from "node:test"; -import assert from "node:assert/strict"; -import { mkdirSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BackgroundRegistry } from "../state.ts"; -import { registerMonitorTool } from "../tools/monitor.ts"; -import { spawnWithFileOutput } from "../spawn.ts"; -import { openWsSource, isWsSupported } from "../monitor-ws.ts"; -import { EVENT } from "../types.ts"; - -const dir = join(tmpdir(), `pi-bg-monitor-${process.pid}`); -mkdirSync(dir, { recursive: true }); -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -interface CapturedTool { - execute: ( - toolCallId: string, - params: unknown, - signal: unknown, - onUpdate: unknown, - ctx: unknown - ) => Promise<{ content: { type: "text"; text: string }[] }>; -} - -function makeHarness() { - const messages: { customType: string; details?: Record }[] = []; - let tool: CapturedTool | undefined; - const pi = { - registerTool(def: CapturedTool) { - tool = def; - }, - sendMessage(msg: { customType: string; details?: Record }) { - messages.push(msg); - }, - }; - const reg = new BackgroundRegistry(); - registerMonitorTool(pi as never, reg); - const ctx = { - cwd: process.cwd(), - ui: { - notify() {}, - setWidget() {}, - setStatus() {}, - theme: { fg: (_c: string, t: string) => t }, - }, - }; - return { tool: tool!, reg, ctx, messages, pi }; -} - -void describe("bash_async_watch tool — validation", () => { - void it("rejects when neither command nor ws is given", async () => { - const { tool, ctx } = makeHarness(); - await assert.rejects( - () => tool.execute("t1", { description: "x" }, undefined, undefined, ctx), - /needs a `command` or a `ws`/ - ); - }); - - void it("rejects when both command and ws are given", async () => { - const { tool, ctx } = makeHarness(); - await assert.rejects( - () => - tool.execute( - "t2", - { command: "echo hi", ws: { url: "ws://x" }, description: "x" }, - undefined, - undefined, - ctx - ), - /not both/ - ); - }); - - void it("rejects a blank description", async () => { - const { tool, ctx } = makeHarness(); - await assert.rejects( - () => tool.execute("t3", { command: "echo hi", description: " " }, undefined, undefined, ctx), - /description` is required/ - ); - }); -}); - -void describe("bash_async_watch tool — command lifecycle", () => { - void it("streams lines live, and sends one terminal ", async () => { - const { tool, ctx, messages } = makeHarness(); - const res = await tool.execute( - "t4", - { command: "printf 'line-A\\nline-B\\n'", description: "test stream" }, - undefined, - undefined, - ctx - ); - assert.match(res.content[0].text, /Monitor m[0-9]+-[0-9a-z]{8} started/); - - await sleep(300); // let the source exit + terminal notify - - // Stream lines are delivered live as monitor events. - const monitorEvents = messages.filter((m) => m.customType === EVENT.monitorEvent); - const streamText = monitorEvents - .map((m) => (m as unknown as { content: string }).content) - .join("\n"); - assert.ok(monitorEvents.length >= 1, "expected at least one live stream event"); - assert.match(streamText, /line-A/); - assert.match(streamText, /line-B/); - - // The terminal notice is its own task-notification, sent at exit. - const terminals = messages.filter((m) => m.customType === EVENT.taskNotification); - assert.equal(terminals.length, 1, "exactly one terminal notification"); - const content = (terminals[0] as unknown as { content: string }).content; - assert.ok(content.includes("completed")); - assert.ok(content.includes(`Monitor "test stream" stream ended`)); - }); -}); - -void describe("monitor — split spawn output", () => { - void it("writes stdout and stderr to separate files when errPath is set", async () => { - const logPath = join(dir, "split.log"); - const errPath = join(dir, "split.err"); - const r = spawnWithFileOutput({ - command: "echo OUT; echo ERR >&2", - cwd: process.cwd(), - logPath, - errPath, - }); - const keepAlive = setTimeout(() => {}, 5_000); - try { - await r.exit; - } finally { - clearTimeout(keepAlive); - } - assert.match(readFileSync(logPath, "utf-8"), /OUT/); - assert.doesNotMatch(readFileSync(logPath, "utf-8"), /ERR/); - assert.match(readFileSync(errPath, "utf-8"), /ERR/); - }); -}); - -// --- WebSocket source (stubbed global WebSocket) ------------------------- - -class FakeWS { - static instances: FakeWS[] = []; - listeners: Record void)[]> = {}; - url: string; - protocols?: string[]; - constructor(url: string, protocols?: string[]) { - this.url = url; - this.protocols = protocols; - FakeWS.instances.push(this); - } - addEventListener(type: string, cb: (ev: unknown) => void) { - (this.listeners[type] ??= []).push(cb); - } - close() { - this.dispatch("close", { code: 1000, reason: "" }); - } - dispatch(type: string, ev: unknown) { - for (const cb of this.listeners[type] ?? []) cb(ev); - } -} - -void describe("monitor — ws source mapping", () => { - const original = (globalThis as { WebSocket?: unknown }).WebSocket; - before(() => { - (globalThis as { WebSocket?: unknown }).WebSocket = FakeWS as unknown; - }); - after(() => { - (globalThis as { WebSocket?: unknown }).WebSocket = original; - }); - - void it("appends text frames and a binary placeholder to the log", async () => { - assert.ok(isWsSupported()); - const logPath = join(dir, "ws.log"); - const src = openWsSource({ url: "wss://example/feed" }, logPath); - const ws = FakeWS.instances[FakeWS.instances.length - 1]; - - ws.dispatch("message", { data: "hello" }); - ws.dispatch("message", { data: new ArrayBuffer(7) }); - ws.dispatch("message", { data: "world" }); - ws.dispatch("close", { code: 1000, reason: "" }); - - await src.exit; - const body = readFileSync(logPath, "utf-8"); - assert.match(body, /hello/); - assert.match(body, /\[binary frame, 7 bytes\]/); - assert.match(body, /world/); - assert.match(body, /socket closed: code 1000/); - }); -}); - -after(() => { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - /* best-effort */ - } -}); diff --git a/src/__tests__/notify.test.ts b/src/__tests__/notify.test.ts index 1b08146..76a2179 100644 --- a/src/__tests__/notify.test.ts +++ b/src/__tests__/notify.test.ts @@ -149,21 +149,6 @@ void describe("completionSummary — CC's exact strings", () => { `Async command "tests" completed (exit code 0)` ); }); - void it("monitor summaries", () => { - const base = { kind: "monitor" as const, name: "API health" }; - assert.equal( - completionSummary(mkJob({ ...base, status: "completed" })), - `Monitor "API health" stream ended` - ); - assert.equal( - completionSummary(mkJob({ ...base, status: "failed", exitCode: 2 })), - `Monitor "API health" script failed (exit 2)` - ); - assert.equal( - completionSummary(mkJob({ ...base, status: "killed" })), - `Monitor "API health" stopped` - ); - }); }); void describe("sendTaskNotification — exactly-once + eviction", () => { @@ -224,23 +209,6 @@ void describe("sendTaskNotification — exactly-once + eviction", () => { assert.equal(reg.jobs.has("job-1-1"), true, "lingers for the lazy sweep"); }); - void it("evict: false keeps the job in the registry (monitor path)", () => { - const { reg, pi, messages } = harness(); - const job = mkJob({ status: "running" as Job["status"] }); - add(reg, job); - const sent = sendTaskNotification({ - reg, - pi: pi as never, - job, - status: "completed", - summary: `Monitor "watch" stream ended`, - evict: false, - }); - assert.equal(sent, true); - assert.equal(reg.jobs.has("job-1-1"), true); - assert.ok(messages[0].content.includes("completed")); - }); - void it("a failed send does not retry and does not evict (exactly-once)", () => { const { reg, pi, messages } = harness({ deliverThrows: true }); const job = mkJob({}); @@ -276,18 +244,18 @@ void describe("completeJob — exit-path notification", () => { const { reg, pi, ctx, messages } = harness(); const job = mkJob({ id: "job-9-2", status: "running", exitCode: undefined }); add(reg, job); - markNotified(job); // bash_async_list output / attach raced the exit + markNotified(job); // bash_async output / attach raced the exit completeJob({ job, code: 1, reg, pi: pi as never, ctx }); assert.equal(messages.length, 0); assert.equal(job.status, "failed"); assert.equal(reg.jobs.has("job-9-2"), true); }); - void it("shouldNotify: false + notified evicts without sending (monitor path)", () => { + void it("shouldNotify: false + notified evicts without sending", () => { const { reg, pi, ctx, messages } = harness(); const job = mkJob({ id: "job-9-3", status: "running", exitCode: undefined }); add(reg, job); - markNotified(job); // monitor already sent its own terminal notification + markNotified(job); // the outcome was already surfaced by a read completeJob({ job, code: 0, reg, pi: pi as never, ctx, shouldNotify: false }); assert.equal(messages.length, 0); assert.equal(reg.jobs.has("job-9-3"), false); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index 0bb608d..358baaa 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -34,12 +34,11 @@ function makeJob(overrides: Partial = {}): Job { } void describe("newJobId", () => { - void it("includes a kind prefix, spawning process ID, and random suffix", () => { - assert.match(newJobId("shell"), new RegExp(`^b${process.pid}-[0-9a-z]{8}$`)); - assert.match(newJobId("monitor"), new RegExp(`^m${process.pid}-[0-9a-z]{8}$`)); + void it("includes the b prefix, spawning process ID, and random suffix", () => { + assert.match(newJobId(), new RegExp(`^b${process.pid}-[0-9a-z]{8}$`)); }); void it("random — consecutive ids differ", () => { - assert.notEqual(newJobId("shell"), newJobId("shell")); + assert.notEqual(newJobId(), newJobId()); }); }); diff --git a/src/__tests__/sleep-guard.test.ts b/src/__tests__/sleep-guard.test.ts index 135ad90..f3ae214 100644 --- a/src/__tests__/sleep-guard.test.ts +++ b/src/__tests__/sleep-guard.test.ts @@ -74,7 +74,7 @@ void describe("bash run_async — rejects a backgrounded sleep wait", () => { const { tool, ctx } = bashAsync(); await assert.rejects( () => tool.execute("t1", { command: "cd /repo; sleep 600; cat log", run_async: true }, undefined, undefined, ctx), - /Blocked: sleep 600.*bash_async_list action='attach'/s + /Blocked: sleep 600.*bash_async action='attach'/s ); }); diff --git a/src/__tests__/spawn.test.ts b/src/__tests__/spawn.test.ts index cee336f..b246a3c 100644 --- a/src/__tests__/spawn.test.ts +++ b/src/__tests__/spawn.test.ts @@ -1,7 +1,7 @@ // src/__tests__/spawn.test.ts import { describe, test } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs"; +import { readFileSync, unlinkSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/src/format.ts b/src/format.ts index 189b8e3..5b3b5a9 100644 --- a/src/format.ts +++ b/src/format.ts @@ -40,7 +40,6 @@ export function statusLabel(job: Job, duration?: string): string { case "pending": return "◌ pending"; case "running": - if (job.kind === "monitor") return `◉ monitor (${dur})`; return job.isBackgrounded ? `▶ bg (${dur})` : `▶ fg (${dur})`; case "completed": return "✓ completed"; @@ -51,17 +50,14 @@ export function statusLabel(job: Job, duration?: string): string { } } -/** "b7f3k9a2x1 [shell]: ls -la (last 80 chars)" — single line for `bash_async_list list`. - * Every line carries the kind tag (shell/agent/monitor) so the unified list - * shows what each task is. */ +/** "b7f3k9a2x1: ls -la (last 80 chars)" — single line for `bash_async list`. */ export function formatJobLine(job: Job): string { const head = job.name ? `${job.name} (${job.id})` : job.id; - const kind = job.kind ?? "shell"; const duration = job.status === "running" ? ` (${formatDuration(Date.now() - job.startTime)})` : ""; - return `${head} [${kind}]: ${job.command.slice(0, PREVIEW_CHARS.line)} - ${statusLabel(job)}${duration}`; + return `${head}: ${job.command.slice(0, PREVIEW_CHARS.line)} - ${statusLabel(job)}${duration}`; } /** Truncate a tail with a consistent "showing last N chars" marker. */ diff --git a/src/index.ts b/src/index.ts index e4b2382..281793b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ /** * pi-async-bash — asynchronous Bash execution for the Pi agent. * - * Registers `bash` (with `run_async`), `bash_async_list`, `bash_async_watch`, - * and `bash_async_decide`, plus `/bash-async` and `/bash-async-list`. + * Registers `bash` (with `run_async`) and `bash_async`, + * plus `/bash-async` and `/bash-async-list`. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -13,9 +13,7 @@ import { stopSidebarTicker } from "./registry.ts"; import { EVENT } from "./types.ts"; import { registerBashTool } from "./tools/bash.ts"; import { registerJobsTool } from "./tools/jobs.ts"; -import { registerMonitorTool } from "./tools/monitor.ts"; import { registerCommands } from "./commands.ts"; -import { registerJobDecideTool } from "./tools/job-decide.ts"; import { registerInputHandlers } from "./input.ts"; import { loadConfig } from "./config.ts"; @@ -30,8 +28,6 @@ export default function (pi: ExtensionAPI): void { const originalBash = createBashToolDefinition(process.cwd()); registerBashTool(pi, reg, originalBash); registerJobsTool(pi, reg); - registerMonitorTool(pi, reg); - registerJobDecideTool(pi, reg); // ── Commands and cooperative steering ───────────────────────── registerCommands(pi, reg); diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 8d4bf76..08dc76e 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -3,10 +3,9 @@ * * Collects the cross-cutting concerns — completion notification, timeout * scheduling, terminal-state marking, and cleanup (kill) — in one place. - * Monitoring (progress polling, stall detection) lives in monitoring.ts. + * Stall detection lives in monitoring.ts. */ -import { readFileSync } from "node:fs"; import { statSync as fsStatSync } from "node:fs"; import { readdir, stat, unlink } from "node:fs/promises"; import { join as pathJoin } from "node:path"; @@ -38,9 +37,8 @@ export function assertJobSlot(reg: BackgroundRegistry): void { /** * Wire a background job's lifecycle: completion promise, abort controller, - * stall watcher, and the exit→completeJob hand-off. The job must already be in - * the registry. Returns the job's AbortController so callers can attach extra - * monitoring or timeout cleanup. + * stall watcher, and the exit→completeJob hand-off. The job must already be + * in the registry. */ export function startBackgroundJob(args: { reg: BackgroundRegistry; @@ -49,14 +47,7 @@ export function startBackgroundJob(args: { job: Job; exit: Promise; shouldNotify?: boolean; - /** Suppress the interactive-prompt stall heuristic (monitors stream their - * own output, so a quiet tail is normal, not a stuck prompt). */ - disablePromptStall?: boolean; - /** Suppress the oversize auto-kill (persistent log tails are expected to - * grow without bound). */ - disableOversizeKill?: boolean; - onExit?: (result: SpawnExit) => void; -}): AbortController { +}): void { ensureCompletionPromise(args.job); const jobAc = createJobAbort(args.reg, args.job.id); const cancelStall = watchStalls({ @@ -65,13 +56,10 @@ export function startBackgroundJob(args: { name: args.job.name, logPath: args.job.logPath, pi: args.pi, - disablePromptStall: args.disablePromptStall, - disableOversizeKill: args.disableOversizeKill, onOversize: () => terminateJobSilently(args.reg, args.job), }); jobAc.signal.addEventListener("abort", cancelStall, { once: true }); void args.exit.then((result) => { - args.onExit?.(result); completeJob({ job: args.job, code: result.code, @@ -83,23 +71,20 @@ export function startBackgroundJob(args: { }); }); renderSidebar(args.reg, args.ctx); - return jobAc; } // --- Terminal-state marking ---------------------------------------------- /** * Standard completion flow after a job exits — abortJob → markTerminal → - * notify → renderSidebar. Shared by every tool's exit callback (bash and - * bash_async_watch) as the canonical termination protocol. + * notify → renderSidebar. Shared by the bash tool's exit callback as the + * canonical termination protocol. * * The notification is Claude Code's per-job , sent the * moment the job exits (see notify.ts). A successful send evicts the job * from the live registry (terminal + notified). Jobs whose outcome is - * already known (killed silently, or read via bash_async_list output/attach) skip the - * notification and linger until the lazy sweep in `bash_async_list list`. Monitors own - * their terminal notification (monitor-session, shouldNotify: false) and are - * evicted here once it has fired. A `shouldNotify: false` job (bash run_async + * already known (killed silently, or read via bash_async output/attach) skip the + * notification and linger until the lazy sweep in `bash_async list`. A `shouldNotify: false` job (bash run_async * `notify: false`) is latched notified WITHOUT sending — "don't notify" IS * notified — so it evicts too and never lingers as a permanent entry. */ @@ -186,7 +171,7 @@ export function markKilledSilently(job: Job): void { markNotified(job); } -/** Kill a job quietly and abort its registered monitors/timers. The notified +/** Kill a job quietly and abort its registered timers. The notified * latch is set BEFORE the kill so the exit handler's notification is * suppressed (Ctrl+Shift+X, jobs kill, session quit). */ export function terminateJobSilently(reg: BackgroundRegistry, job: Job): void { @@ -198,7 +183,7 @@ export function terminateJobSilently(reg: BackgroundRegistry, job: Job): void { // --- Per-job abort (cleanup) --------------------------------------------- -/** Create an AbortController for a job. Aborting it cancels all monitors. */ +/** Create an AbortController for a job. Aborting it cancels its watcher. */ export function createJobAbort( reg: BackgroundRegistry, jobId: string @@ -210,7 +195,7 @@ export function createJobAbort( return ac; } -/** Abort all monitors for a job and remove the controller. */ +/** Abort a job's watchers and remove the controller. */ export function abortJob(reg: BackgroundRegistry, jobId: string): void { const ac = reg.jobAborts.get(jobId); if (ac) { @@ -225,10 +210,6 @@ export function abortJob(reg: BackgroundRegistry, jobId: string): void { * was already dropped). */ export function terminateJob(job: Job): void { - // Monitors carry a transient teardown hook (follower + ws socket). A ws - // monitor has pid 0, so the process-tree kill below is a no-op for it and - // job.stop does the real work; a command monitor needs both. - job.stop?.(); // No liveness probe: killProcessTree already swallows ESRCH, and probing // first would be a TOCTOU race. killProcessTree itself guards pid <= 0. killProcessTree(job.proc?.pid ?? job.pid, "SIGTERM"); @@ -338,9 +319,9 @@ export function isAutoBackgroundAllowed(command: string): boolean { export const SLEEP_WAIT_GUIDANCE = "A fixed `sleep N` to wait wastes time and leaves a job lingering for the " + "full duration. Instead:\n" + - "• Waiting on a background job you started? Use bash_async_list action='attach' — it " + + "• Waiting on a background job you started? Use bash_async action='attach' — it " + "returns as soon as that job finishes.\n" + - "• Waiting for a condition? Use the bash_async_watch tool, or a poll loop that EXITS " + + "• Waiting for a condition? Use a poll loop that EXITS " + "when ready (e.g. `until grep -q READY log; do sleep 0.5; done`).\n" + "• Just pacing/rate-limiting? Keep it under 2 seconds."; @@ -435,8 +416,8 @@ export async function cleanupStaleRuntimeArtifacts(): Promise { /** Serialize only data that can be safely restored after a same-process reload. */ export function serializeJobs( jobs: Iterable, -): Array> { - return Array.from(jobs, ({ proc: _proc, donePromise: _done, resolveDone: _resolve, stop: _stop, ...job }) => job); +): Array> { + return Array.from(jobs, ({ proc: _proc, donePromise: _done, resolveDone: _resolve, ...job }) => job); } diff --git a/src/log-search.ts b/src/log-search.ts index beb50bd..8ae69dc 100644 --- a/src/log-search.ts +++ b/src/log-search.ts @@ -1,7 +1,7 @@ /** * Streaming log search service. * - * The bash_async_list tool owns parameter parsing and result formatting; this module owns + * The bash_async tool owns parameter parsing and result formatting; this module owns * full-log scanning, match counting, bounded display-hit retention, and the * tail fallback used when the log file cannot be streamed line-by-line. */ diff --git a/src/monitor-follow.ts b/src/monitor-follow.ts deleted file mode 100644 index 581e075..0000000 --- a/src/monitor-follow.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Line-accurate tail follower for the bash_async_watch tool. - * - * Unlike output.ts/pollFileTail (a bounded 4 KB tail deduped by content, fine - * for progress display but lossy under bursts), this follower tracks a byte - * offset forward from 0 and emits only *complete* newly-appended lines. A - * partial trailing line is held until its newline arrives. Lines read within a - * single poll tick are delivered together, so the poll cadence doubles as the - * batch window. - * - * This is the single emitter path for both monitor sources: command monitors - * append to the log via the child's stdout fd; ws monitors append each frame as - * a line. The follower does not care which. - */ - -import { closeSync, openSync, readSync, statSync } from "node:fs"; -import { MONITOR_POLL_MS } from "./types.ts"; - -export interface MonitorFollower { - /** Stop polling. When flush is true, do a final synchronous read and emit - * any remaining complete lines plus a trailing partial line (the process - * ended, so the last unterminated line is final). */ - stop(flush?: boolean): void; -} - -/** - * Follow `logPath`, invoking `onLines` with each batch of newly-completed - * lines. Starts at offset 0. Reads are bounded by available bytes, not file - * size history, so growth is O(new bytes) per tick. - */ -export function followLines( - logPath: string, - onLines: (lines: string[]) => void, - intervalMs: number = MONITOR_POLL_MS -): MonitorFollower { - let offset = 0; - let remainder = ""; - let stopped = false; - - /** Read everything appended since `offset`, split into complete lines. - * Returns complete lines; updates offset and remainder. */ - function readNew(): string[] { - let size: number; - try { - size = statSync(logPath).size; - } catch { - return []; // file not created yet - } - // Truncation/rotation: the file shrank below our offset. Reset so we - // don't go permanently silent reading past the new end. - if (size < offset) { - offset = 0; - remainder = ""; - } - if (size <= offset) return []; - - const toRead = size - offset; - // allocUnsafe is safe here: only the [0, n) slice that readSync fills is - // ever consumed below. - const buf = Buffer.allocUnsafe(toRead); - let fd: number; - try { - fd = openSync(logPath, "r"); - } catch { - return []; - } - try { - const n = readSync(fd, buf, 0, toRead, offset); - offset += n; - const text = remainder + buf.toString("utf-8", 0, n); - const parts = text.split("\n"); - remainder = parts.pop() ?? ""; // trailing partial (no newline yet) - return parts; - } finally { - closeSync(fd); - } - } - - const timer = setTimeout(function tick() { - if (stopped) return; - const lines = readNew(); - if (lines.length > 0) onLines(lines); - if (!stopped) timer.refresh(); - }, intervalMs); - (timer as NodeJS.Timeout).unref(); - - return { - stop(flush = false) { - if (stopped) return; - stopped = true; - clearTimeout(timer); - if (flush) { - const lines = readNew(); - // A non-empty remainder is a final, newline-less last line. - if (remainder.length > 0) { - lines.push(remainder); - remainder = ""; - } - if (lines.length > 0) onLines(lines); - } - }, - }; -} diff --git a/src/monitor-session.ts b/src/monitor-session.ts deleted file mode 100644 index ee8014e..0000000 --- a/src/monitor-session.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * The monitor session lifecycle — the tricky part of the bash_async_watch tool, lifted - * out of the tool action so its invariants are unit-testable through a fake - * MonitorSource (no real spawning, no real sockets). - * - * Responsibilities: stream each batch of new log lines as a notification, - * rate-limit a firehose, emit exactly one terminal event (on natural exit, - * kill, timeout, or firehose), and tear the source down. The tool just - * validates, builds a source, and hands it here. - */ - -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import type { BackgroundRegistry } from "./state.ts"; -import { - DELIVER_FOLLOWUP, - EVENT, - MONITOR_MAX_LINES_PER_WINDOW, - MONITOR_RATE_WINDOW_MS, - type Job, - type UiContext, -} from "./types.ts"; -import { renderSidebar } from "./registry.ts"; -import { startBackgroundJob, terminateJobSilently } from "./lifecycle.ts"; -import { followLines, type MonitorFollower } from "./monitor-follow.ts"; -import type { MonitorSource } from "./monitor-source.ts"; -import { completionSummary, sendTaskNotification, type TerminalStatus } from "./notify.ts"; - -/** - * Wire a monitor's source to its event stream, terminal event, deadline, and - * teardown. The job must already be in the registry. Returns nothing — the - * session runs until the source ends, the deadline fires, or it is killed. - */ -export function startMonitorSession(args: { - pi: ExtensionAPI; - reg: BackgroundRegistry; - ctx: UiContext; - job: Job; - source: MonitorSource; - description: string; - persistent: boolean; - timeoutMs: number; -}): void { - const { pi, reg, ctx, job, source, description, persistent, timeoutMs } = args; - const { id, logPath } = job; - - let terminalEmitted = false; - let finishing = false; - let windowStart = Date.now(); - let windowLines = 0; - - // Stream events stay live but passive (delivered as a follow-up, no wake) — - // they carry data the agent is actively watching and surface on the agent's - // next natural turn without spawning an unsolicited one. The terminal - // notice (stream ended / stopped / failed) is its own - // (see finishMonitor), sent the moment the source ends. - const emitEvent = (lines: string[]): void => { - if (lines.length === 0) return; - pi.sendMessage( - { - customType: EVENT.monitorEvent, - content: `◉ ${description}\n${lines.join("\n")}`, - display: true, - details: { jobId: id, description, logPath, terminal: false }, - }, - DELIVER_FOLLOWUP - ); - }; - - const follower: MonitorFollower = followLines(logPath, (lines) => { - if (terminalEmitted) return; - - // Sliding-window firehose check. - const now = Date.now(); - if (now - windowStart > MONITOR_RATE_WINDOW_MS) { - windowStart = now; - windowLines = 0; - } - windowLines += lines.length; - - emitEvent(lines); - - // Don't trip the firehose guard while draining the final flush. - if (!finishing && windowLines > MONITOR_MAX_LINES_PER_WINDOW) { - stopMonitor( - "killed", - `Monitor "${description}" stopped (too many events (>${MONITOR_MAX_LINES_PER_WINDOW}/${MONITOR_RATE_WINDOW_MS / 1000}s) — restart with a tighter filter)` - ); - } - }); - - // job.stop: transient teardown invoked by the kill path. Tears down the - // source (closes the ws socket) — the follower is stopped *and flushed* by - // finishMonitor on the exit path (kill → process/socket close → exit → - // onExit), so a user-initiated kill stays lossless. - job.stop = source.stop; - - /** - * Emit exactly one terminal for the monitor. Sent - * before the job is marked terminal (onExit runs ahead of completeJob), so - * the status/summary are explicit and eviction is left to completeJob. - */ - const finishMonitor = (status: TerminalStatus, summary: string): void => { - if (terminalEmitted) return; - // Flush remaining lines first (while terminalEmitted is still false so - // the follower callback emits them), then the terminal notification. - finishing = true; - follower.stop(true); - terminalEmitted = true; - sendTaskNotification({ reg, pi, job, status, summary, evict: false }); - }; - - /** Forced stop (timeout / firehose). Emits a terminal event, then routes - * through the standard silent-kill path (which calls job.stop). */ - function stopMonitor(status: TerminalStatus, summary: string): void { - finishMonitor(status, summary); - terminateJobSilently(reg, job); - renderSidebar(reg, ctx); - } - - // Wire exit → terminal event. shouldNotify:false because the monitor owns - // its own terminal event (no double-fire from completeJob). Monitors stream their - // own output, so the prompt-stall heuristic is nonsensical here; the oversize - // cap is also suppressed for persistent watches (session-length log tails are - // expected to grow). - const jobAc = startBackgroundJob({ - reg, - pi, - ctx, - job, - exit: source.exit, - shouldNotify: false, - disablePromptStall: true, - disableOversizeKill: persistent, - onExit: ({ code, signal }) => { - // A signal death (external kill) is the "stopped" summary, never - // "stream ended". Natural exits reuse completionSummary — the job - // carries name: description, so the summary names the watch. - if (job.status === "killed" || signal !== null) { - finishMonitor("killed", completionSummary(job, "killed")); - } else if (code === 0) { - finishMonitor("completed", completionSummary(job, "completed")); - } else { - // completeJob marks terminal after onExit — set the exit code - // now so completionSummary can name it. - job.exitCode = code ?? undefined; - finishMonitor("failed", completionSummary(job, "failed")); - } - }, - }); - - // Deadline (skipped for persistent watches). Cleared when the job aborts - // (natural exit or kill) so a short monitor doesn't keep a live timer + - // closure alive for the whole timeout window. - if (!persistent) { - const deadline = setTimeout(() => { - stopMonitor( - "killed", - `Monitor "${description}" stopped (timeout after ${Math.round(timeoutMs / 1000)}s)` - ); - }, timeoutMs); - (deadline as NodeJS.Timeout).unref(); - jobAc.signal.addEventListener("abort", () => clearTimeout(deadline), { once: true }); - } -} diff --git a/src/monitor-source.ts b/src/monitor-source.ts deleted file mode 100644 index 87bb054..0000000 --- a/src/monitor-source.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * A monitor's event source, behind one seam. - * - * Both kinds of monitor produce the same thing the session needs: a log file - * whose appended lines are events, a promise that resolves when the source - * ends, and a teardown hook. Naming that contract lets the session (see - * monitor-session.ts) treat command and WebSocket monitors identically, and - * makes a third source (named pipe, file replay, …) a drop-in later. - */ - -import { spawnWithFileOutput, type SpawnExit } from "./spawn.ts"; -import { openWsSource, type WsSpec } from "./monitor-ws.ts"; - -export interface MonitorSource { - /** File the follower reads; appended lines become events. */ - logPath: string; - /** OS pid backing the source, or 0 when there is no process (ws). */ - pid: number; - /** Human-readable label shown in the sidebar / bash_async_list list. */ - label: string; - /** Resolves when the source ends (process exit, or ws close as code-only). */ - exit: Promise; - /** Teardown beyond the standard process kill (closes the ws socket). */ - stop: () => void; -} - -/** Command source: a shell child whose stdout is the event stream and whose - * stderr is captured separately (readable, never emitted). */ -export function spawnCommandSource(args: { - command: string; - cwd: string; - logPath: string; - errPath: string; -}): MonitorSource { - const spawned = spawnWithFileOutput({ - command: args.command, - cwd: args.cwd, - logPath: args.logPath, - errPath: args.errPath, - }); - return { - logPath: args.logPath, - pid: spawned.pid, - label: args.command, - exit: spawned.exit, - // The process group is killed by the standard kill path; nothing extra. - stop: () => {}, - }; -} - -/** WebSocket source: each text frame is appended to the log as a line. */ -export function openWsMonitorSource(spec: WsSpec, logPath: string): MonitorSource { - const ws = openWsSource(spec, logPath); - return { - logPath, - pid: 0, - label: `ws ${spec.url}`, - // A socket has no signal — the close code maps to the code half. - exit: ws.exit.then((code) => ({ code, signal: null })), - stop: ws.close, - }; -} diff --git a/src/monitor-ws.ts b/src/monitor-ws.ts deleted file mode 100644 index bc5346a..0000000 --- a/src/monitor-ws.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * WebSocket source for the bash_async_watch tool. - * - * A ws monitor has no child process. Instead of spawning, it opens a WebSocket - * and appends each incoming frame as a line to the same `.log` the - * line-follower reads — so ws and command monitors share one emitter path and - * the existing jobs-list / attach / Read surface works unchanged. - * - * Compatibility: uses the runtime's global `WebSocket` (stable since Node 22). - * When absent, isWsSupported() is false and the tool rejects the ws source with - * an actionable error rather than crashing. - */ - -import { closeSync, mkdirSync, openSync, writeSync } from "node:fs"; -import { dirname } from "node:path"; - -export interface WsSpec { - url: string; - protocols?: string[]; -} - -export interface WsSource { - /** Resolves when the socket closes (0 = clean close, 1 = error/abnormal). */ - exit: Promise; - /** Close the socket. Idempotent. */ - close: () => void; -} - -/** True when the runtime provides a global WebSocket constructor. */ -export function isWsSupported(): boolean { - return typeof (globalThis as { WebSocket?: unknown }).WebSocket === "function"; -} - -/** - * Open `spec.url`, appending each event as a line to `logPath`. The log file is - * created (truncated) up front so the follower has something to stat. Returns - * an exit promise (resolved on close) and a close handle. - * - * Throws synchronously if WebSocket is unsupported or construction fails — the - * caller surfaces that as a tool error. - */ -export function openWsSource(spec: WsSpec, logPath: string): WsSource { - if (!isWsSupported()) { - throw new Error( - "WebSocket is not available in this runtime (needs Node 22+). " + - "Use a command source instead, e.g. " + - `command: 'websocat ${spec.url}' or 'wscat -c ${spec.url}'.` - ); - } - - // Ensure the log dir exists (a ws monitor may be the first background job of - // the session, so LOG_DIR may not have been created by a spawn yet), then - // hold one appendable fd open for the socket's lifetime — one writeSync per - // frame instead of an open/write/close trio. - mkdirSync(dirname(logPath), { recursive: true }); - const logFd = openSync(logPath, "w"); - let fdClosed = false; - const closeFd = (): void => { - if (fdClosed) return; - fdClosed = true; - try { - closeSync(logFd); - } catch { - /* already closed */ - } - }; - - const append = (line: string): void => { - if (fdClosed) return; - try { - writeSync(logFd, line.endsWith("\n") ? line : `${line}\n`); - } catch { - /* best-effort: a vanished log dir shouldn't take down the socket */ - } - }; - - const WS = (globalThis as { WebSocket: typeof WebSocket }).WebSocket; - const ws = new WS(spec.url, spec.protocols); - - let settled = false; - let resolveExit!: (code: number) => void; - const exit = new Promise((resolve) => { - resolveExit = resolve; - }); - const settle = (code: number): void => { - if (settled) return; - settled = true; - closeFd(); - resolveExit(code); - }; - - ws.addEventListener("message", (ev: MessageEvent) => { - const data: unknown = ev.data; - if (typeof data === "string") { - append(data); - } else if (data instanceof ArrayBuffer) { - append(`[binary frame, ${data.byteLength} bytes]`); - } else if (data && typeof (data as { byteLength?: number }).byteLength === "number") { - append(`[binary frame, ${(data as { byteLength: number }).byteLength} bytes]`); - } else { - append("[non-text frame]"); - } - }); - - ws.addEventListener("error", () => { - append("[websocket error]"); - // Some implementations fire error without a following close. - try { - ws.close(); - } catch { - /* already closing */ - } - settle(1); - }); - - ws.addEventListener("close", (ev: CloseEvent) => { - append(`[socket closed: code ${ev.code}${ev.reason ? ` ${ev.reason}` : ""}]`); - settle(ev.code === 1000 || ev.code === 1005 ? 0 : 1); - }); - - return { - exit, - close() { - try { - ws.close(); - } catch { - /* already closed */ - } - settle(0); - }, - }; -} diff --git a/src/monitoring.ts b/src/monitoring.ts index 48cff2f..a933969 100644 --- a/src/monitoring.ts +++ b/src/monitoring.ts @@ -38,10 +38,6 @@ export function watchStalls(args: { logPath: string; pi: ExtensionAPI; onOversize?: () => void; - /** Skip the interactive-prompt stall heuristic (used for monitors). */ - disablePromptStall?: boolean; - /** Skip the oversize auto-kill (used for persistent monitors). */ - disableOversizeKill?: boolean; }): () => void { let lastSize = 0; let lastGrowth = Date.now(); @@ -53,7 +49,7 @@ export function watchStalls(args: { try { const { size } = fsStatSync(args.logPath); - if (size > MAX_LOG_BYTES && !args.disableOversizeKill) { + if (size > MAX_LOG_BYTES) { cancelled = true; if (args.onOversize) args.onOversize(); args.pi.sendMessage( @@ -72,7 +68,6 @@ export function watchStalls(args: { lastSize = size; lastGrowth = Date.now(); } else if ( - !args.disablePromptStall && Date.now() - lastGrowth >= STALL_THRESHOLD_MS && size !== lastPromptCheckSize ) { diff --git a/src/notify.ts b/src/notify.ts index cc5bdd3..5c32b7b 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -10,7 +10,7 @@ * * Exactly-once is enforced by the job's `notified` latch — a check-and-set * done BEFORE the send, so any path that already surfaced the outcome (a - * bash_async_list output/attach read, a deliberate kill) suppresses the notification. + * bash_async output/attach read, a deliberate kill) suppresses the notification. * A terminal job that has been notified is evicted from the live registry; * its output log stays on disk and the notification carries the path. */ @@ -53,19 +53,10 @@ export function buildTaskNotification(args: { return lines.join("\n"); } -/** - * Claude Code's exact completion summary for a terminal job. `status` - * overrides job.status for callers that notify before the job is marked - * terminal (the monitor exit path). - */ -export function completionSummary(job: Job, status?: TerminalStatus): string { - const s = status ?? (job.status as TerminalStatus); +/** Claude Code's exact completion summary for a terminal job. */ +export function completionSummary(job: Job): string { + const s = job.status as TerminalStatus; const desc = describeJob(job.name, job.command); - if (job.kind === "monitor") { - if (s === "killed") return `Monitor "${desc}" stopped`; - if (s === "failed") return `Monitor "${desc}" script failed (exit ${job.exitCode ?? "unknown"})`; - return `Monitor "${desc}" stream ended`; - } if (s === "killed") return `Async command "${desc}" was stopped`; if (s === "failed") return `Async command "${desc}" failed with exit code ${job.exitCode ?? "unknown"}`; return `Async command "${desc}" completed${job.exitCode != null ? ` (exit code ${job.exitCode})` : ""}`; @@ -75,7 +66,7 @@ export function completionSummary(job: Job, status?: TerminalStatus): string { * Set the notified latch (Claude Code's markTaskNotified). Idempotent. Called * by every path that surfaces a job's outcome WITHOUT the notification: kill * paths (before the kill, so the exit handler skips notifying) and terminal - * reads (bash_async_list output / attach). + * reads (bash_async output / attach). */ export function markNotified(job: Job): void { job.notified = true; @@ -86,11 +77,8 @@ export function markNotified(job: Job): void { * BEFORE the send, so a concurrent consumer can never produce a duplicate; * if the send itself throws, the notification is lost rather than retried * (exactly-once), and the terminal+notified job lingers until the lazy sweep - * in `bash_async_list list`. - * - * On success the job is evicted from the live registry (terminal + notified) - * unless `evict: false` — the monitor path sends before the job is marked - * terminal and lets completeJob evict instead. + * in `bash_async list`. On success the job is evicted from the live + * registry (terminal + notified). * * Returns true when the notification was sent. */ @@ -98,17 +86,12 @@ export function sendTaskNotification(args: { reg: BackgroundRegistry; pi: ExtensionAPI; job: Job; - /** Explicit terminal status when the job isn't marked terminal yet. */ - status?: TerminalStatus; - /** Explicit summary (monitors compose their own). */ - summary?: string; - evict?: boolean; }): boolean { const { reg, pi, job } = args; if (job.notified) return false; job.notified = true; - const status = args.status ?? (job.status as TerminalStatus); - const summary = args.summary ?? completionSummary(job, status); + const status = job.status as TerminalStatus; + const summary = completionSummary(job); try { pi.sendMessage( @@ -135,6 +118,6 @@ export function sendTaskNotification(args: { console.error("[bg-tasks] task notification failed:", err); return false; } - if (args.evict !== false) forget(reg, job); + forget(reg, job); return true; } diff --git a/src/output.ts b/src/output.ts index 36058e3..8788f8d 100644 --- a/src/output.ts +++ b/src/output.ts @@ -222,7 +222,7 @@ export type ToolTextUpdate = (update: { /** * Stream a log file's live tail into a tool's onUpdate callback — the shared * "show live output while a job runs" mechanic shared by `bash` and - * `bash_async_list attach`. Returns the poller's stop handle. + * `bash_async attach`. Returns the poller's stop handle. */ export function streamLog( logPath: string, diff --git a/src/registry.ts b/src/registry.ts index ea5d2fe..444e828 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -11,12 +11,10 @@ import { statSync, unlinkSync } from "node:fs"; import { formatDuration, jobLabel } from "./format.ts"; import { isTerminalStatus, - JOB_ID_PREFIX, MAX_CONCURRENT_JOBS, PREVIEW_CHARS, RECENT_TERMINAL_KEEP, type Job, - type JobKind, type UiContext, } from "./types.ts"; import type { BackgroundRegistry } from "./state.ts"; @@ -27,17 +25,17 @@ import { readBoundedTail, readLastLine } from "./output.ts"; const ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; /** - * Typed task ids: a one-letter kind prefix, spawning process identifier, and + * Typed task ids: a fixed `b` prefix, spawning process identifier, and * eight random base36 characters (for example, `b1234-7f3k9a2x`). */ -export function newJobId(kind: JobKind, reg?: BackgroundRegistry): string { +export function newJobId(reg?: BackgroundRegistry): string { let id: string; do { let suffix = ""; for (let i = 0; i < 8; i++) { suffix += ID_ALPHABET[randomInt(0, ID_ALPHABET.length)]; } - id = `${JOB_ID_PREFIX[kind]}${process.pid}-${suffix}`; + id = `b${process.pid}-${suffix}`; } while (reg?.jobs.has(id)); return id; } @@ -50,16 +48,9 @@ export function logPathFor(jobId: string): string { return `${LOG_DIR}/${jobId}.log`; } -/** Sibling stderr-capture path for a monitor's split output. Keeps the - * `.log`/`.err` naming convention in one place. */ -export function errPathFor(jobId: string): string { - return `${LOG_DIR}/${jobId}.err`; -} - /** - * Build a fresh running Job. Centralizes the Job shape so the new `kind`/`stop` - * fields (and any future additions) don't drift across the bash/ - * bash_async_watch construction sites. + * Build a fresh running Job. Centralizes the Job shape so the construction + * sites don't drift. */ export function createRunningJob(args: { id: string; @@ -68,7 +59,6 @@ export function createRunningJob(args: { logPath: string; toolCallId: string; name?: string; - kind?: JobKind; isBackgrounded?: boolean; }): Job { return { @@ -81,7 +71,6 @@ export function createRunningJob(args: { logPath: args.logPath, toolCallId: args.toolCallId, isBackgrounded: args.isBackgrounded ?? true, - kind: args.kind, }; } @@ -204,12 +193,11 @@ export function renderSidebar(reg: BackgroundRegistry, ctx: UiContext): void { runningCount++; runningLogs.add(job.logPath); const duration = formatDuration(Date.now() - job.startTime); - const glyph = job.kind === "monitor" ? "◉" : "▶"; + const progress = sidebarLastLine(job.logPath) || job.command; // Show the job's latest output line as live progress; fall back to the // command until there's any output. Re-read each tick by the ticker. - const progress = sidebarLastLine(job.logPath) || job.command; pills.push( - `${glyph} ${jobLabel(job)}: ${progress.slice(0, PREVIEW_CHARS.progress)} (${duration})` + `▶ ${jobLabel(job)}: ${progress.slice(0, PREVIEW_CHARS.progress)} (${duration})` ); } diff --git a/src/spawn.ts b/src/spawn.ts index df0f60a..3cd0258 100644 --- a/src/spawn.ts +++ b/src/spawn.ts @@ -32,37 +32,24 @@ export function spawnWithFileOutput(args: { fileArgs?: string[]; cwd: string; logPath: string; - /** When set, stderr is written here instead of merged into logPath. Used by - * the bash_async_watch tool so stdout is a clean event stream and stderr is captured - * separately (readable, but never emitted as an event). */ - errPath?: string; signal?: AbortSignal; }): SpawnResult { ensureLogDir(args.logPath); - const outFd = openSync(args.logPath, "w"); - let errFd: number; - try { - errFd = args.errPath ? openSync(args.errPath, "w") : outFd; - } catch (err) { - closeSync(outFd); - throw err; - } - const [bin, binArgs]: [string, string[]] = args.file ? [args.file, args.fileArgs ?? []] : ["bash", ["-c", args.command ?? ""]]; + const fd = openSync(args.logPath, "w"); let proc; try { proc = spawn(bin, binArgs, { - stdio: ["ignore", outFd, errFd], + stdio: ["ignore", fd, fd], cwd: args.cwd, detached: true, env: { ...process.env }, }); } finally { - closeSync(outFd); - if (errFd !== outFd) closeSync(errFd); + closeSync(fd); } // Build the exit promise and attach the 'error' listener BEFORE any throw, @@ -80,9 +67,6 @@ export function spawnWithFileOutput(args: { if (!proc.pid) { try { unlinkSync(args.logPath); } catch { /* best-effort */ } - if (args.errPath) { - try { unlinkSync(args.errPath); } catch { /* best-effort */ } - } throw new Error("Failed to spawn process"); } const pid = proc.pid; diff --git a/src/state.ts b/src/state.ts index c8af452..b5e0bab 100644 --- a/src/state.ts +++ b/src/state.ts @@ -9,9 +9,8 @@ import type { Job, ForegroundSlot } from "./types.ts"; export class BackgroundRegistry { jobs = new Map(); foreground = new Map(); - pendingDecisionJobId: string | undefined; - /** Per-job AbortController — abort() cancels all monitors/pollers for that job. */ + /** Per-job AbortController — abort() cancels all pollers/watchers for that job. */ jobAborts = new Map(); nonInteractive = false; diff --git a/src/tools/bash-params.ts b/src/tools/bash-params.ts index 5aaa7e5..3f28631 100644 --- a/src/tools/bash-params.ts +++ b/src/tools/bash-params.ts @@ -9,13 +9,10 @@ export const bashParamSchema = Type.Object({ timeout: Type.Optional( Type.Number({ description: - "Seconds. With run_async=true this is a DECISION timeout and has no default: " + - "when it expires you are notified and resolve the job with bash_async_decide, " + - "unless the command is not eligible for auto-backgrounding (for example a short " + - "fixed sleep), which is killed. Without run_async it auto-backgrounds a " + - "still-running command when it expires, unless the command is not eligible for " + - "auto-backgrounding, in which case it is killed (default: " + - "pi-async-bash.defaultTimeoutSeconds from settings.json, or 120).", + "Seconds. Without run_async, auto-backgrounds a still-running foreground command " + + "when it expires, or kills it if it is not eligible for auto-backgrounding " + + "(default: pi-async-bash.defaultTimeoutSeconds from settings.json, or 120). " + + "Ignored with run_async=true.", }) ), run_async: Type.Optional( @@ -23,7 +20,7 @@ export const bashParamSchema = Type.Object({ description: "Set to true to run this command in the background immediately and return its " + "job ID. Output is saved to /tmp/pi-bg/.log. description becomes the " + - "bash_async_list label.", + "bash_async label.", }) ), description: Type.Optional( @@ -35,7 +32,7 @@ export const bashParamSchema = Type.Object({ "Send a terminal notification when a run_async job finishes (default: true). " + "A command that starts in the foreground and is later backgrounded always notifies. " + "Set false to suppress the run_async notification; the finished job is then removed " + - "from the live bash_async_list set.", + "from the live bash_async set.", }) ), }); diff --git a/src/tools/bash.ts b/src/tools/bash.ts index c33cacc..84a1324 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -3,7 +3,6 @@ * * Single file-descriptor backend (no tmux): * - run_async=true starts asynchronous execution and returns a job handle - * - with run_async, an optional timeout is a decision timeout resolved by bash_async_decide * - run_async jobs send one terminal notification unless notify=false; * commands backgrounded from the foreground always notify * - foreground commands race completion against asynchronous handoff @@ -27,8 +26,6 @@ import { OUTPUT_PREVIEW_CHARS, QUICK_COMPLETION_MS, type ForegroundSlot, - isTerminalStatus, - type Job, type UiContext, } from "../types.ts"; import { spawnWithFileOutput, killProcessTree, type SpawnExit } from "../spawn.ts"; @@ -68,20 +65,18 @@ export function registerBashTool( name: "bash", description: "Run a Bash command. Long-running commands continue asynchronously after timeout. " + - "Set run_async=true to start asynchronously immediately; an optional timeout then acts " + - "as a decision timeout resolved by bash_async_decide. " + + "Set run_async=true to start asynchronously immediately. " + "Use /bash-async to hand off a running command.", promptSnippet: "Run shell commands; long-running commands continue asynchronously or use run_async=true", promptGuidelines: [ "Use bash with run_async=true when a command is expected to run for a long time.", - "With run_async=true, an optional timeout is a DECISION timeout: after it expires you are notified and bash_async_decide keeps, kills, or inspects the job. Without run_async, timeout auto-backgrounds a still-running command.", "run_async jobs send one terminal notification; pass notify=false to suppress it. A command that starts in the foreground and is later backgrounded always notifies.", - "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.", - "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.", - "Give the job a description when it will be easier to track with bash_async_list.", - "Check background job status with bash_async_list action='list'.", - "Read background output with bash_async_list action='output'.", + "run_async is for ONE notification (the command exits when done). For per-event streaming (watching logs, polling an API, file changes), poll with an `until` loop that exits when ready.", + "Never `sleep N` to wait for something — the job lingers for the full sleep. Wait on a background job with bash_async action='attach', or poll with an `until` loop that exits when ready.", + "Give the job a description when it will be easier to track with bash_async.", + "Check background job status with bash_async action='list'.", + "Read background output with bash_async action='output'.", ], parameters: bashParamSchema, @@ -115,7 +110,6 @@ export function registerBashTool( reg, pi, ctx: bashCtx, - timeout: p.timeout, shouldNotify: p.notify, }); } @@ -152,7 +146,7 @@ async function runForeground(args: { }): Promise> { const { toolCallId, command, timeoutMs, signal, onUpdate, ctx, reg, pi } = args; - const id = newJobId("shell", reg); + const id = newJobId(reg); const logPath = logPathFor(id); // Spawn WITHOUT wiring the turn signal to a process kill. Cooperative @@ -337,10 +331,9 @@ function spawnBackground(args: { reg: BackgroundRegistry; pi: ExtensionAPI; ctx: UiContext; - timeout?: number; shouldNotify?: boolean; }): AgentToolResult { - const id = newJobId("shell", args.reg); + const id = newJobId(args.reg); const logPath = logPathFor(id); const spawned = spawnWithFileOutput({ @@ -358,7 +351,7 @@ function spawnBackground(args: { toolCallId: args.toolCallId, }); add(args.reg, job); - const jobAbort = startBackgroundJob({ + startBackgroundJob({ reg: args.reg, pi: args.pi, ctx: args.ctx, @@ -366,14 +359,6 @@ function spawnBackground(args: { exit: spawned.exit, shouldNotify: args.shouldNotify, }); - scheduleDecisionTimeout({ - reg: args.reg, - ctx: args.ctx, - job, - timeout: args.timeout, - jobAbort, - logPath, - }); return { content: [ @@ -384,41 +369,3 @@ function spawnBackground(args: { details: undefined, }; } - -// --- Decision timeout (run_async path) ----------------------------------- - -/** - * After `timeout` seconds, hand the still-running async job to the model: - * non-eligible commands are killed loudly, eligible ones raise a warning the - * model resolves via `bash_async_decide`. - */ -function scheduleDecisionTimeout(args: { - reg: BackgroundRegistry; - ctx: UiContext; - job: Job; - timeout: number | undefined; - jobAbort: AbortController; - logPath: string; -}): void { - const { reg, ctx, job, timeout, jobAbort, logPath } = args; - if (!timeout) return; - const timer = setTimeout(() => { - if (isTerminalStatus(job.status) || reg.nonInteractive) return; - if (!isAutoBackgroundAllowed(job.command)) { - try { - appendFileSync(logPath, `Command timed out after ${timeout}s\n`); - } catch { - // Process termination remains correct if diagnostics cannot be written. - } - killProcessTree(job.pid, "SIGTERM"); - return; - } - reg.pendingDecisionJobId = job.id; - ctx.ui.notify( - `Async Bash job ${job.id} exceeded ${timeout}s. Use bash_async_decide to keep, stop, or inspect it.`, - "warning", - ); - }, timeout * 1000); - timer.unref(); - jobAbort.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); -} diff --git a/src/tools/job-decide.ts b/src/tools/job-decide.ts deleted file mode 100644 index 00660c8..0000000 --- a/src/tools/job-decide.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { StringEnum, Type } from "@earendil-works/pi-ai"; -import type { BackgroundRegistry } from "../state.ts"; -import { OUTPUT_PREVIEW_CHARS, type UiContext } from "../types.ts"; -import { findJob, readLogTail, renderSidebar } from "../registry.ts"; -import { terminateJobSilently } from "../lifecycle.ts"; -import { jobLabel, textBlock } from "../format.ts"; - -/** Register decisions for timed asynchronous Bash commands. */ -export function registerJobDecideTool(pi: ExtensionAPI, reg: BackgroundRegistry): void { - pi.registerTool({ - name: "bash_async_decide", - label: "Async Bash Decision", - description: "Keep, stop, or inspect an asynchronous Bash command that exceeded its timeout.", - promptSnippet: "Resolve a timed asynchronous Bash command", - promptGuidelines: [ - "Use bash_async_decide after a bash run_async timeout notification.", - "keep leaves the command running, kill stops it, and check shows its current output.", - ], - parameters: Type.Object({ - jobId: Type.String({ description: "Timed asynchronous Bash job ID" }), - decision: StringEnum(["keep", "kill", "check"] as const, { - description: "Action to take for the timed job", - }), - }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const { jobId, decision } = params as { - jobId: string; - decision: "keep" | "kill" | "check"; - }; - const job = findJob(reg, jobId); - if (!job) { - if (reg.pendingDecisionJobId === jobId) { - reg.pendingDecisionJobId = undefined; - } - return { content: [textBlock(`No async Bash job found with ID: ${jobId}.`)], details: undefined }; - } - - if (decision === "check") { - const output = readLogTail(job, OUTPUT_PREVIEW_CHARS); - return { - content: [textBlock(`Output for ${jobLabel(job)}:\n${output || "(no output yet)"}`)], - details: undefined, - }; - } - - reg.pendingDecisionJobId = undefined; - if (decision === "kill") { - terminateJobSilently(reg, job); - renderSidebar(reg, ctx as UiContext); - return { content: [textBlock(`Stopped ${jobLabel(job)}.`)], details: undefined }; - } - - return { - content: [textBlock(`Keeping ${jobLabel(job)} running. Use bash_async_list to inspect it later.`)], - details: undefined, - }; - }, - }); -} diff --git a/src/tools/jobs.ts b/src/tools/jobs.ts index d10f48f..8e79503 100644 --- a/src/tools/jobs.ts +++ b/src/tools/jobs.ts @@ -40,13 +40,13 @@ import { terminateJobSilently, } from "../lifecycle.ts"; -/** Register the `jobs` tool. */ +/** Register the `bash_async` tool. */ export function registerJobsTool( pi: ExtensionAPI, reg: BackgroundRegistry ): void { pi.registerTool({ - name: "bash_async_list", + name: "bash_async", label: "Background Jobs", description: "Manage background jobs: list, output, kill, attach, search, cleanup, and stats.", @@ -252,7 +252,7 @@ async function attachAction( return { content: [ textBlock( - `Stopped following ${label} — it's still running in the background. Use bash_async_list output to check on it.` + `Stopped following ${label} — it's still running in the background. Use bash_async output to check on it.` ), ], details: undefined, @@ -268,7 +268,7 @@ async function attachAction( // with the running-branch set above. markNotified(job); return { - content: [textBlock(`${message}. Use bash_async_list output for the full log.`)], + content: [textBlock(`${message}. Use bash_async output for the full log.`)], details: undefined, }; } diff --git a/src/tools/monitor.ts b/src/tools/monitor.ts deleted file mode 100644 index 643f52f..0000000 --- a/src/tools/monitor.ts +++ /dev/null @@ -1,165 +0,0 @@ -// src/tools/monitor.ts -// -// `monitor` tool — a streaming-event background watch. Each stdout line (or -// WebSocket text frame) becomes one notification delivered into the agent's -// turn. This is distinct from run_async (one notification on -// completion): monitor is for per-event streams (tail -f | grep, poll loops, -// file watches, ws feeds), not one-shot "wait until done". - -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { Type } from "@earendil-works/pi-ai"; -import type { BackgroundRegistry } from "../state.ts"; -import { - MONITOR_DEFAULT_TIMEOUT_MS, - MONITOR_MAX_TIMEOUT_MS, - type UiContext, -} from "../types.ts"; -import { add, createRunningJob, errPathFor, newJobId, logPathFor } from "../registry.ts"; -import { assertJobSlot, isBlankCommand, requireExistingCwd } from "../lifecycle.ts"; -import { isWsSupported, type WsSpec } from "../monitor-ws.ts"; -import { - openWsMonitorSource, - spawnCommandSource, - type MonitorSource, -} from "../monitor-source.ts"; -import { startMonitorSession } from "../monitor-session.ts"; -import { textBlock } from "../format.ts"; - -type MonitorCtx = UiContext & { cwd: string }; - -interface MonitorParams { - command?: string; - ws?: WsSpec; - description: string; - persistent?: boolean; - timeout_ms?: number; -} - -export function registerMonitorTool(pi: ExtensionAPI, reg: BackgroundRegistry): void { - pi.registerTool({ - name: "bash_async_watch", - label: "Monitor", - description: - "Stream events from a long-running process: each stdout line (or WebSocket " + - "text frame) becomes one notification, delivered while you keep working. " + - "Use this for per-event streams — NOT for one-shot 'wait until done' (use " + - "bash run_async for that). Exit ends the watch.", - promptSnippet: - "Stream per-event notifications from a process, log, poll loop, or WebSocket", - promptGuidelines: [ - "Pick by notification count: ONE ('tell me when done') → bash run_async with an `until` loop that exits; ONE-PER-EVENT → bash_async_watch.", - "Don't use an unbounded command (tail -f, while true, inotifywait -m) for a single notification — it never exits and stays armed until timeout.", - "Every pipe stage must flush per line: grep needs --line-buffered, awk needs fflush(); never pipe to `head` (it buffers until N matches).", - "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'.", - "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`.", - "Give a specific description — it is shown on every notification.", - "Use persistent:true for session-length watches (PR monitoring, log tails); stop it with the bash_async_list tool (action='kill').", - "Use the ws source for a WebSocket feed instead of `command: 'websocat …'` — each text frame becomes one event.", - ], - parameters: Type.Object({ - command: Type.Optional( - Type.String({ description: "Shell script; each stdout line is an event. Mutually exclusive with ws." }) - ), - ws: Type.Optional( - Type.Object( - { - url: Type.String({ description: "WebSocket URL (ws:// or wss://)" }), - protocols: Type.Optional(Type.Array(Type.String())), - }, - { description: "WebSocket source; each text frame is an event. Mutually exclusive with command." } - ) - ), - description: Type.String({ description: "Specific description, shown on every notification." }), - persistent: Type.Optional( - Type.Boolean({ description: "Run for the whole session (no timeout). Stop via bash_async_list action='kill'. Default false." }) - ), - timeout_ms: Type.Optional( - Type.Number({ description: "Kill after this deadline (default 300000, max 3600000). Ignored when persistent." }) - ), - }), - - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const p = params as MonitorParams; - const mctx = ctx as MonitorCtx; - - // --- Validation: command XOR ws ------------------------------- - const hasCommand = typeof p.command === "string" && !isBlankCommand(p.command); - const hasWs = !!p.ws && typeof p.ws.url === "string" && p.ws.url.length > 0; - if (hasCommand && hasWs) { - throw new Error("Provide either `command` or `ws`, not both."); - } - if (!hasCommand && !hasWs) { - throw new Error("A monitor needs a `command` or a `ws` source."); - } - if (!p.description || p.description.trim().length === 0) { - throw new Error("`description` is required (shown on every notification)."); - } - if (hasWs && !isWsSupported()) { - throw new Error( - "WebSocket is not available in this runtime (needs Node 22+). " + - `Use command: 'websocat ${p.ws!.url}' or 'wscat -c ${p.ws!.url}' instead.` - ); - } - if (hasCommand) requireExistingCwd(mctx.cwd); - assertJobSlot(reg); - - const description = p.description.trim(); - const persistent = p.persistent === true; - const timeoutMs = Math.min( - p.timeout_ms && p.timeout_ms > 0 ? p.timeout_ms : MONITOR_DEFAULT_TIMEOUT_MS, - MONITOR_MAX_TIMEOUT_MS - ); - - const id = newJobId("monitor", reg); - const logPath = logPathFor(id); - - // Build the event source (command or ws) behind one seam, then hand - // it to the session, which owns the streaming/terminal lifecycle. - const source: MonitorSource = hasWs - ? openWsMonitorSource(p.ws!, logPath) - : spawnCommandSource({ - command: p.command!, - cwd: mctx.cwd, - logPath, - errPath: errPathFor(id), - }); - - const job = createRunningJob({ - id, - name: description, - command: source.label, - pid: source.pid, - logPath, - toolCallId: _toolCallId, - kind: "monitor", - }); - add(reg, job); - - startMonitorSession({ - pi, - reg, - ctx: mctx, - job, - source, - description, - persistent, - timeoutMs, - }); - - const sourceDesc = hasWs ? `WebSocket ${p.ws!.url}` : "command"; - const deadlineDesc = persistent - ? "persistent (stop via bash_async_list action='kill')" - : `timeout ${Math.round(timeoutMs / 1000)}s`; - return { - content: [ - textBlock( - `Monitor ${id} started — ${sourceDesc}, ${deadlineDesc}. ` + - `Events ("${description}") will arrive as notifications. ` + - `Output: ${logPath}` - ), - ], - details: undefined, - }; - }, - }); -} diff --git a/src/types.ts b/src/types.ts index 11167db..00e8ff2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,19 +19,6 @@ export const OUTPUT_PREVIEW_CHARS = 12_000; export const RECENT_TERMINAL_KEEP = 20; export const MAX_CONCURRENT_JOBS = 16; -// --- Monitor (streaming-event) constants --- -/** Poll cadence for the line-accurate follower. Lines read within one tick are - * batched into a single event — so this doubles as the ~200ms batch window. */ -export const MONITOR_POLL_MS = 200; -/** Default streaming watch deadline (matches Claude Code's Monitor). */ -export const MONITOR_DEFAULT_TIMEOUT_MS = 300_000; -/** Hard ceiling on a monitor's deadline. */ -export const MONITOR_MAX_TIMEOUT_MS = 3_600_000; -/** Sliding window for firehose detection. */ -export const MONITOR_RATE_WINDOW_MS = 10_000; -/** Max emitted lines per window before a monitor is auto-stopped. */ -export const MONITOR_MAX_LINES_PER_WINDOW = 500; - export const PREVIEW_CHARS = { sidebar: 25, taskList: 40, @@ -51,17 +38,6 @@ export function isTerminalStatus(status: JobStatus): boolean { return status === "completed" || status === "failed" || status === "killed"; } -/** What kind of asynchronous job this is. "shell" is the default; "monitor" - * is a streaming-event watch from the bash_async_watch tool. */ -export type JobKind = "shell" | "monitor"; - -/** Typed job identifiers use a kind prefix, the spawning process identifier, and - * eight random base36 characters (for example, `b1234-7f3k9a2x`). */ -export const JOB_ID_PREFIX: Record = { - shell: "b", - monitor: "m", -}; - export interface Job { id: string; name?: string; @@ -77,14 +53,10 @@ export interface Job { resolveDone?: () => void; /** Exactly-once latch for the terminal (Claude Code's * `notified` flag). Set BEFORE the notification send, before a deliberate - * kill, and when the agent reads the outcome via bash_async_list output/attach — any + * kill, and when the agent reads the outcome via bash_async output/attach — any * path that already surfaced the result suppresses the notification. */ notified?: boolean; isBackgrounded: boolean; - /** Defaults to "shell" when absent. */ - kind?: JobKind; - /** Transient teardown hook (follower + ws socket). */ - stop?: () => void; } export type BackgroundReason = "manual" | "timeout"; @@ -100,7 +72,6 @@ export interface ForegroundSlot { export const EVENT = { stall: "bg-stall", taskNotification: "task-notification", - monitorEvent: "bg-monitor-event", } as const; export type EventName = (typeof EVENT)[keyof typeof EVENT]; @@ -110,13 +81,13 @@ export type EventName = (typeof EVENT)[keyof typeof EVENT]; * pi queues it while the agent is streaming and delivers it at the next * tool-call boundary — Claude Code's 'next' priority. Use when the message * IS something the agent must react to now: a background job's terminal - * , a stall warning, a deadline decision. */ + * , a stall warning. */ export const DELIVER_STEER = { deliverAs: "steer", triggerTurn: true } as const; /** Queue the message behind the current turn as a PASSIVE follow-up. The agent * picks it up on its next natural turn (when the user re-engages or the - * current turn ends) but it does NOT spawn a new turn on its own. Monitor - * stream events are informational and never force an unsolicited - * acknowledgment or starve user input. + * current turn ends) but it does NOT spawn a new turn on its own. The + * oversize auto-kill uses this: the job is already terminated, so the report + * never forces an unsolicited acknowledgment or starves user input. * NOTE: sendMessage-only — `pi.sendUserMessage` rejects `triggerTurn` and * takes just `{ deliverAs: "followUp" }`. */ export const DELIVER_FOLLOWUP = { deliverAs: "followUp", triggerTurn: false } as const; diff --git a/src/ui.ts b/src/ui.ts index 25bc0be..06a25eb 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,7 +1,7 @@ /** Async Bash task manager for `/bash-async-list`. */ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent"; -import { Container, Key, matchesKey, SelectList, Text, truncateToWidth, wrapTextWithAnsi, type Component, type KeybindingsManager, type SelectItem, type TUI } from "@earendil-works/pi-tui"; +import { Container, Key, matchesKey, SelectList, Text, truncateToWidth, wrapTextWithAnsi, type KeybindingsManager, type SelectItem, type TUI } from "@earendil-works/pi-tui"; import type { Job, UiContext } from "./types.ts"; import { MAX_LOG_BYTES, PREVIEW_CHARS } from "./types.ts"; import type { BackgroundRegistry } from "./state.ts";