Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
18 changes: 17 additions & 1 deletion packages/coding-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1101,7 +1101,23 @@ export default function (pi: ExtensionAPI) {
pi.on("tool_result", async (event, ctx) => {
if (event.toolName === "read") {
// Redact secrets from file contents
return { modifiedResult: event.result.replace(/API_KEY=\w+/g, "API_KEY=***") };
return {
content: event.content.map((item) =>
item.type === "text"
? { ...item, text: item.text.replace(/API_KEY=\w+/g, "API_KEY=***") }
: item
),
};
}

if (event.isError) {
// Override the thrown error message (optional)
return { content: [{ type: "text", text: "Custom error message" }], isError: true };
}

if (event.toolName === "bash" && event.content.length > 0) {
// Force a successful tool to be treated as an error
return { content: [{ type: "text", text: "Tool output rejected by policy" }], isError: true };
}
});

Expand Down
31 changes: 29 additions & 2 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,34 @@ pi.on("tool_call", async (event, ctx) => {

**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts)

#### before_bash_exec
Copy link

@Mic92 Mic92 Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be very useful for direnv integration! I am currently doing this: https://github.com/Mic92/dotfiles/blob/main/home/.pi/agent/extensions/direnv.ts

One thing that is missing is knowing what directory the agent tries to access. Often the agent prepends cd but it feels a bit hacky. But maybe cd() could be replaced by a function with this.


Fired before a bash command executes (tool calls and user `!`/`!!`). Use it to rewrite commands or override execution settings. You can also block execution by returning `{ block: true, reason?: string }`. For follow-up hints based on output, pair this with `tool_result`.

```typescript
pi.on("before_bash_exec", async (event) => {
if (event.command.includes("rm -rf")) {
return { block: true, reason: "Blocked by policy" };
}

if (event.source === "tool") {
return {
cwd: "/tmp",
env: {
...event.env,
MY_VAR: "1",
PATH: undefined, // remove PATH
},
};
}
});
```

Return a `BashExecOverrides` object to override fields, or return `{ block: true, reason?: string }` to reject the command. Any field set to a non-undefined value replaces the original (`command`, `cwd`, `env`, `shell`, `args`, `timeout`). For `env`, set a key to `undefined` to remove it.

#### tool_result

Fired after tool executes. **Can modify result.**
Fired after tool executes. **Can modify result.** Use this to post-process outputs (for example, append hints or redact secrets) before the result is sent to the model.

```typescript
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
Expand All @@ -565,10 +590,12 @@ pi.on("tool_result", async (event, ctx) => {
}

// Modify result:
return { content: [...], details: {...}, isError: false };
return { content: [...], details: {...} };
});
```

If `event.isError` is true, return `{ content: [...], isError: true }` to override the thrown error message (the text content becomes the error string). Returning `isError: true` on a successful tool result forces the tool to be treated as an error.

**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)

### User Bash Events
Expand Down
74 changes: 74 additions & 0 deletions packages/coding-agent/examples/extensions/uv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* uv Python Interceptor
*
* Demonstrates before_bash_exec by redirecting python invocations through uv.
* This is a simple example that assumes basic whitespace-separated arguments.
*
* Usage:
* pi -e examples/extensions/uv.ts
*/

import { type ExtensionAPI, isBashToolResult } from "@mariozechner/pi-coding-agent";

const PYTHON_PREFIX = /^python3?(\s+|$)/;
const UV_RUN_PYTHON_PREFIX = /^uv\s+run\s+python3?(\s+|$)/;
const PIP_PREFIX = /^pip3?(\s+|$)/;
const PIP_MODULE_PATTERN = /\s-m\s+pip3?(\s|$)/;
const TRACEBACK_PATTERN = /Traceback \(most recent call last\):/;
const IMPORT_ERROR_PATTERN = /\b(ModuleNotFoundError|ImportError):/;
const MODULE_NOT_FOUND_PATTERN = /No module named ['"]([^'"]+)['"]/;

const PIP_BLOCK_REASON =
"pip is disabled. Use uv run instead, particularly --with and --script for throwaway work. Do not use uv pip!";

export default function (pi: ExtensionAPI) {
pi.on("before_bash_exec", (event) => {
const trimmed = event.originalCommand.trim();
const isPythonCommand = PYTHON_PREFIX.test(trimmed);
const isUvRunPythonCommand = UV_RUN_PYTHON_PREFIX.test(trimmed);
const isPipModule = PIP_MODULE_PATTERN.test(trimmed);

if (PIP_PREFIX.test(trimmed) || (isPipModule && (isPythonCommand || isUvRunPythonCommand))) {
return {
block: true,
reason: PIP_BLOCK_REASON,
};
}

if (!isPythonCommand) {
return;
}

const normalizedCommand = trimmed.replace(PYTHON_PREFIX, "python ").trimEnd();
const uvCommand = `uv run ${normalizedCommand}`;

return {
command: uvCommand,
};
});

pi.on("tool_result", (event) => {
if (!isBashToolResult(event)) return;

const text = event.content
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("");

if (!TRACEBACK_PATTERN.test(text) || !IMPORT_ERROR_PATTERN.test(text)) {
return;
}

const moduleMatch = text.match(MODULE_NOT_FOUND_PATTERN);
const moduleName = moduleMatch?.[1];
const hintTarget = moduleName ? ` --with ${moduleName}` : "";
const hint =
"\n\nHint: Python import failed. Use uv to fetch dependencies automatically without changing the system, " +
`e.g. \`uv run${hintTarget} python -c '...'\` or \`uv run --script\` for throwaway scripts.`;

return {
content: [...event.content, { type: "text", text: hint }],
isError: true,
};
});
}
55 changes: 47 additions & 8 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/
import { getAuthPath } from "../config.js";
import { theme } from "../modes/interactive/theme/theme.js";
import { stripFrontmatter } from "../utils/frontmatter.js";
import { getShellConfig, getShellEnv } from "../utils/shell.js";
import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js";
import {
type CompactionResult,
Expand All @@ -41,6 +42,7 @@ import {
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
import {
type BeforeBashExecEvent,
type ContextUsage,
type ExtensionCommandContextActions,
type ExtensionErrorListener,
Expand Down Expand Up @@ -2018,22 +2020,54 @@ export class AgentSession {
): Promise<BashResult> {
this._bashAbortController = new AbortController();

// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
const prefix = this.settingsManager.getShellCommandPrefix();
const resolvedCommand = prefix ? `${prefix}\n${command}` : command;

try {
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
const prefix = this.settingsManager.getShellCommandPrefix();
const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
const shellConfig = getShellConfig();
const baseEvent: BeforeBashExecEvent = {
type: "before_bash_exec",
source: "user_bash",
command: resolvedCommand,
originalCommand: command,
cwd: process.cwd(),
env: { ...getShellEnv() },
shell: shellConfig.shell,
args: [...shellConfig.args],
};
const execEvent = this._extensionRunner?.hasHandlers("before_bash_exec")
? await this._extensionRunner.emitBeforeBashExec(baseEvent)
: baseEvent;
const execCommand = execEvent.command;
const execCwd = execEvent.cwd;
const execEnv = execEvent.env;
const execShell = execEvent.shell;
const execArgs = execEvent.args;
const execTimeout = execEvent.timeout;

const result = options?.operations
? await executeBashWithOperations(resolvedCommand, process.cwd(), options.operations, {
? await executeBashWithOperations(execCommand, execCwd, options.operations, {
onChunk,
signal: this._bashAbortController.signal,
env: execEnv,
shell: execShell,
args: execArgs,
timeout: execTimeout,
})
: await executeBashCommand(resolvedCommand, {
: await executeBashCommand(execCommand, {
onChunk,
signal: this._bashAbortController.signal,
cwd: execCwd,
env: execEnv,
shell: execShell,
args: execArgs,
timeout: execTimeout,
});

this.recordBashResult(command, result, options);
this.recordBashResult(command, result, {
excludeFromContext: options?.excludeFromContext,
executedCommand: execCommand === command ? undefined : execCommand,
});
return result;
} finally {
this._bashAbortController = undefined;
Expand All @@ -2044,10 +2078,15 @@ export class AgentSession {
* Record a bash execution result in session history.
* Used by executeBash and by extensions that handle bash execution themselves.
*/
recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
recordBashResult(
command: string,
result: BashResult,
options?: { excludeFromContext?: boolean; executedCommand?: string },
): void {
const bashMessage: BashExecutionMessage = {
role: "bashExecution",
command,
executedCommand: options?.executedCommand,
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
Expand Down
51 changes: 45 additions & 6 deletions packages/coding-agent/src/core/bash-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { type ChildProcess, spawn } from "child_process";
import stripAnsi from "strip-ansi";
import { getShellConfig, getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
import { killProcessTree, resolveShellExecutionOptions, sanitizeBinaryOutput } from "../utils/shell.js";
import type { BashOperations } from "./tools/bash.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";

Expand All @@ -25,6 +25,16 @@ export interface BashExecutorOptions {
onChunk?: (chunk: string) => void;
/** AbortSignal for cancellation */
signal?: AbortSignal;
/** Working directory override */
cwd?: string;
/** Environment override */
env?: NodeJS.ProcessEnv;
/** Shell executable override */
shell?: string;
/** Shell argument override */
args?: string[];
/** Timeout in seconds */
timeout?: number;
}

export interface BashResult {
Expand Down Expand Up @@ -60,13 +70,30 @@ export interface BashResult {
*/
export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
const child: ChildProcess = spawn(shell, [...args, command], {
const resolvedCwd = options?.cwd ?? process.cwd();
const { resolvedShell, resolvedArgs, resolvedEnv } = resolveShellExecutionOptions({
shell: options?.shell,
args: options?.args,
env: options?.env,
});
const child: ChildProcess = spawn(resolvedShell, [...resolvedArgs, command], {
cwd: resolvedCwd,
env: resolvedEnv,
detached: true,
env: getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
});

let timedOut = false;
let timeoutHandle: NodeJS.Timeout | undefined;
if (options?.timeout !== undefined && options.timeout > 0) {
timeoutHandle = setTimeout(() => {
timedOut = true;
if (child.pid) {
killProcessTree(child.pid);
}
}, options.timeout * 1000);
}

// Track sanitized output for truncation
const outputChunks: string[] = [];
let outputBytes = 0;
Expand All @@ -88,6 +115,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
if (options.signal.aborted) {
// Already aborted, don't even start
child.kill();
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
resolve({
output: "",
exitCode: undefined,
Expand Down Expand Up @@ -144,6 +174,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
if (options?.signal) {
options.signal.removeEventListener("abort", abortHandler);
}
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}

if (tempFileStream) {
tempFileStream.end();
Expand All @@ -153,8 +186,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
const fullOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput);

// code === null means killed (cancelled)
const cancelled = code === null;
const cancelled = code === null || timedOut;

resolve({
output: truncationResult.truncated ? truncationResult.content : fullOutput,
Expand All @@ -170,6 +202,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
if (options?.signal) {
options.signal.removeEventListener("abort", abortHandler);
}
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}

if (tempFileStream) {
tempFileStream.end();
Expand Down Expand Up @@ -238,6 +273,10 @@ export async function executeBashWithOperations(
const result = await operations.exec(command, cwd, {
onData,
signal: options?.signal,
timeout: options?.timeout,
env: options?.env,
shell: options?.shell,
args: options?.args,
});

if (tempFileStream) {
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/src/core/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ export type {
// App keybindings (for custom editors)
AppAction,
AppendEntryHandler,
BashExecEvent,
BashExecOverrides,
BashExecSource,
BashToolResultEvent,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
BeforeBashExecEvent,
BeforeBashExecEventResult,
// Context
CompactOptions,
// Events - Agent
Expand Down
Loading
Loading