feat(coding-agent): add bash spawn hook

This commit is contained in:
Mario Zechner 2026-02-01 23:17:51 +01:00
parent 25fa1fafde
commit 86b43c8eac
6 changed files with 81 additions and 3 deletions

View file

@ -12,6 +12,7 @@
### Added
- Added Android/Termux support with graceful clipboard fallback ([#1164](https://github.com/badlogic/pi-mono/issues/1164))
- Added bash tool spawn hook support for adjusting command, cwd, and env before execution ([#1160](https://github.com/badlogic/pi-mono/pull/1160) by [@mitsuhiko](https://github.com/mitsuhiko))
- Added typed `ToolCallEvent.input` per tool with `isToolCallEventType()` type guard for narrowing built-in tool events ([#1147](https://github.com/badlogic/pi-mono/pull/1147) by [@giuseppeg](https://github.com/giuseppeg))
- Exported `discoverAndLoadExtensions` from package to enable extension testing without a local repo clone ([#1148](https://github.com/badlogic/pi-mono/issues/1148))
- Added Extension UI Protocol documentation to RPC docs covering all request/response types for extension dialogs and notifications ([#1144](https://github.com/badlogic/pi-mono/pull/1144) by [@aliou](https://github.com/aliou))

View file

@ -1237,6 +1237,20 @@ pi.registerTool({
**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution:
```typescript
import { createBashTool } from "@mariozechner/pi-coding-agent";
const bashTool = createBashTool(cwd, {
spawnHook: ({ command, cwd, env }) => ({
command: `source ~/.profile\n${command}`,
cwd: `/mnt/sandbox${cwd}`,
env: { ...env, CI: "1" },
}),
});
```
See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag.
### Output Truncation
@ -1777,4 +1791,5 @@ All examples in [examples/extensions/](../examples/extensions/).
| **Misc** |||
| `antigravity-image-gen.ts` | Image generation tool | `registerTool`, Google Antigravity |
| `inline-bash.ts` | Inline bash in tool calls | `on("tool_call")` |
| `bash-spawn-hook.ts` | Adjust bash command, cwd, and env before execution | `createBashTool`, `spawnHook` |
| `with-deps/` | Extension with npm dependencies | Package structure with `package.json` |

View file

@ -0,0 +1,30 @@
/**
* Bash Spawn Hook Example
*
* Adjusts command, cwd, and env before execution.
*
* Usage:
* pi -e ./bash-spawn-hook.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { createBashTool } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
const cwd = process.cwd();
const bashTool = createBashTool(cwd, {
spawnHook: ({ command, cwd, env }) => ({
command: `source ~/.profile\n${command}`,
cwd,
env: { ...env, PI_SPAWN_HOOK: "1" },
}),
});
pi.registerTool({
...bashTool,
execute: async (id, params, onUpdate, _ctx, signal) => {
return bashTool.execute(id, params, signal, onUpdate);
},
});
}

View file

@ -47,6 +47,7 @@ export interface BashOperations {
onData: (data: Buffer) => void;
signal?: AbortSignal;
timeout?: number;
env?: NodeJS.ProcessEnv;
},
) => Promise<{ exitCode: number | null }>;
}
@ -55,7 +56,7 @@ export interface BashOperations {
* Default bash operations using local shell
*/
const defaultBashOperations: BashOperations = {
exec: (command, cwd, { onData, signal, timeout }) => {
exec: (command, cwd, { onData, signal, timeout, env }) => {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
@ -67,7 +68,7 @@ const defaultBashOperations: BashOperations = {
const child = spawn(shell, [...args, command], {
cwd,
detached: true,
env: getShellEnv(),
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
@ -135,16 +136,37 @@ const defaultBashOperations: BashOperations = {
},
};
export interface BashSpawnContext {
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
}
export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext;
function resolveSpawnContext(command: string, cwd: string, spawnHook?: BashSpawnHook): BashSpawnContext {
const baseContext: BashSpawnContext = {
command,
cwd,
env: { ...getShellEnv() },
};
return spawnHook ? spawnHook(baseContext) : baseContext;
}
export interface BashToolOptions {
/** Custom operations for command execution. Default: local shell */
operations?: BashOperations;
/** Command prefix prepended to every command (e.g., "shopt -s expand_aliases" for alias support) */
commandPrefix?: string;
/** Hook to adjust command, cwd, or env before execution */
spawnHook?: BashSpawnHook;
}
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
const ops = options?.operations ?? defaultBashOperations;
const commandPrefix = options?.commandPrefix;
const spawnHook = options?.spawnHook;
return {
name: "bash",
@ -159,6 +181,7 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
) => {
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
return new Promise((resolve, reject) => {
// We'll stream to a temp file if output gets large
@ -215,7 +238,12 @@ export function createBashTool(cwd: string, options?: BashToolOptions): AgentToo
}
};
ops.exec(resolvedCommand, cwd, { onData: handleData, signal, timeout })
ops.exec(spawnContext.command, spawnContext.cwd, {
onData: handleData,
signal,
timeout,
env: spawnContext.env,
})
.then(({ exitCode }) => {
// Close temp file stream
if (tempFileStream) {

View file

@ -1,5 +1,7 @@
export {
type BashOperations,
type BashSpawnContext,
type BashSpawnHook,
type BashToolDetails,
type BashToolInput,
type BashToolOptions,

View file

@ -204,6 +204,8 @@ export {
// Tools
export {
type BashOperations,
type BashSpawnContext,
type BashSpawnHook,
type BashToolDetails,
type BashToolInput,
type BashToolOptions,