Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
]
}
```
Expand All @@ -31,11 +31,8 @@ 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/<jobId>.log` path. It sends one terminal notification unless `notify: false`. |
| `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` | `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.

Expand All @@ -58,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 `bash_async`.
setting applies to foreground commands; it is ignored for a `run_async` command.

## Slash commands

Expand All @@ -76,25 +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` or
`bash_async` 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.

## 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.

| 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_async` |
| `jobs` | `bash_async_list` |
| `monitor` | `bash_async_watch` |
| `job_decide` | `bash_async_decide` |
| `bash_bg` | `bash` with `run_async` |
| `jobs` | `bash_async` |
| `monitor` | removed |
| `job_decide` | removed; use `bash_async` |
| `/bg` | `/bash-async` |
| `/bg-list` | `/bash-async-list` |
| `/bg-version` | removed |
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
86 changes: 86 additions & 0 deletions src/__tests__/bash-async-notify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* `bash run_async` — notify plumbing:
* - notify=false suppresses the terminal <task-notification> while still
* evicting the job from the live registry,
* - default notify sends exactly one terminal <task-notification>.
*/

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");
});
});
80 changes: 0 additions & 80 deletions src/__tests__/bash-bg.test.ts

This file was deleted.

68 changes: 66 additions & 2 deletions src/__tests__/bash-results.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
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 { EVENT, type Job } from "../types.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));

Expand Down Expand Up @@ -63,6 +64,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}`
Expand All @@ -89,6 +91,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;
Expand Down Expand Up @@ -156,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(
Expand Down
Loading
Loading