Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/bash-bg-fix-resume-cwd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"pi-bash-bg": patch
---

Stop overriding the built-in bash tool so resumed sessions run bash in the session's cwd.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ yarn lint # biome check
yarn lint:fix # biome check --write
```

After completing changes, include a changeset file for affected packages (`yarn changeset`). See `DEVELOPMENT.md` for the release workflow.
After completing changes, include a changeset file for affected packages (`yarn changeset`). Keep changeset summaries to a single line whenever possible; users see them rendered in the CHANGELOG, so be concise. See `DEVELOPMENT.md` for the release workflow.

## Gotchas

Expand Down
6 changes: 1 addition & 5 deletions packages/bash-bg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The bash tool pipes stdout/stderr from the spawned shell. When a command backgro

## Solution

This extension intercepts bash tool calls, parses the command with [@aliou/sh](https://github.com/nicolo-ribaudo/sh) to detect background processes (`stmt.background === true`), appends background-job guidance to the bash tool description, and rewrites the command to:
This extension intercepts bash tool calls, parses the command with [@aliou/sh](https://github.com/nicolo-ribaudo/sh) to detect background processes (`stmt.background === true`), and rewrites the command to:

1. **Redirect output** to temp log files with human-readable names based on the command label, so background processes release the pipes
2. **Add `disown`** to detach from job control (if not already present)
Expand Down Expand Up @@ -78,10 +78,6 @@ echo "[bg] pid=$! label=npm start log=/tmp/pi-bg-npm-start-3.log"

If the command already has `disown` after the `&`, no duplicate is added.

### Bash tool description

The extension appends a short background-job summary to the bash tool description, so the model sees it directly in tool metadata.

## Supported patterns

| Pattern | Handled |
Expand Down
11 changes: 1 addition & 10 deletions packages/bash-bg/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,14 @@
*/

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { createBashTool, isToolCallEventType } from "@mariozechner/pi-coding-agent";
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
import { detectBackground } from "./detect.js";
import { rewriteCommand } from "./rewrite.js";

export { detectBackground, type BgStatement, type DetectResult } from "./detect.js";
export { rewriteCommand, findBgOperatorPositions, type BgProcessInfo, type RewriteResult } from "./rewrite.js";

const BASH_DESCRIPTION_APPENDIX =
"Background jobs continue running after the command returns. Their output is captured to a log file even without explicit redirection. The PID and log path will be returned.";

export default function (pi: ExtensionAPI) {
const bashTool = createBashTool(process.cwd());
pi.registerTool({
...bashTool,
description: `${bashTool.description} ${BASH_DESCRIPTION_APPENDIX}`,
});

pi.on("tool_call", async (event) => {
if (!isToolCallEventType("bash", event)) return;

Expand Down
99 changes: 80 additions & 19 deletions packages/bash-bg/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,86 @@
import type { ExtensionAPI, ToolDefinition } from "@mariozechner/pi-coding-agent";
import type { ExtensionAPI, ToolCallEvent, ToolCallEventResult, ToolDefinition } from "@mariozechner/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import extension from "../src/index.js";

type ToolCallHandler = (
event: ToolCallEvent,
ctx: unknown,
) => Promise<ToolCallEventResult | undefined> | ToolCallEventResult | undefined;

function loadExtension() {
const tools: ToolDefinition[] = [];
let toolCallHandler: ToolCallHandler | undefined;
const pi = {
registerTool: vi.fn((tool: ToolDefinition) => {
tools.push(tool);
}),
on: vi.fn((event: string, handler: ToolCallHandler) => {
if (event === "tool_call") toolCallHandler = handler;
}),
} as unknown as ExtensionAPI;

extension(pi);

return { pi, tools, toolCallHandler };
}

describe("pi-bash-bg extension", () => {
it("appends background job guidance to the bash tool description", () => {
const tools: ToolDefinition[] = [];
const pi = {
registerTool(tool) {
tools.push(tool);
},
on: vi.fn(),
} as unknown as ExtensionAPI;

extension(pi);

const bashTool = tools.find((tool) => tool.name === "bash");
expect(bashTool).toBeDefined();
expect(bashTool?.description).toContain("Background jobs continue running after the command returns.");
expect(bashTool?.description).toContain(
"Their output is captured to a log file even without explicit redirection.",
);
expect(bashTool?.description).toContain("The PID and log path will be returned.");
it("does not override the built-in bash tool", () => {
// Replacing the built-in bash tool would force a fixed cwd captured at
// extension load time, which breaks the cwd of resumed sessions.
const { pi, tools } = loadExtension();
expect(tools).toHaveLength(0);
expect(pi.registerTool).not.toHaveBeenCalled();
});

it("registers a tool_call handler", () => {
const { pi, toolCallHandler } = loadExtension();
expect(pi.on).toHaveBeenCalledWith("tool_call", expect.any(Function));
expect(toolCallHandler).toBeDefined();
});

it("rewrites bash commands that contain background statements", async () => {
const { toolCallHandler } = loadExtension();
const event: ToolCallEvent = {
type: "tool_call",
toolName: "bash",
toolCallId: "id",
input: { command: "sleep 300 &" },
};

await toolCallHandler!(event, {});

const rewritten = (event.input as { command: string }).command;
expect(rewritten).not.toBe("sleep 300 &");
expect(rewritten).toContain("disown");
expect(rewritten).toContain("[bg]");
});

it("leaves non-background bash commands untouched", async () => {
const { toolCallHandler } = loadExtension();
const event: ToolCallEvent = {
type: "tool_call",
toolName: "bash",
toolCallId: "id",
input: { command: "echo hi && ls" },
};

await toolCallHandler!(event, {});

expect((event.input as { command: string }).command).toBe("echo hi && ls");
});

it("ignores non-bash tool calls", async () => {
const { toolCallHandler } = loadExtension();
const event: ToolCallEvent = {
type: "tool_call",
toolName: "read",
toolCallId: "id",
input: { path: "foo &" },
};

await toolCallHandler!(event, {});

expect((event.input as { path: string }).path).toBe("foo &");
});
});
Loading