Refactor subagent tool, fix custom tool discovery, fix JSON mode stdout flush

Breaking changes:
- Custom tools now require index.ts entry point in subdirectory
  (e.g., tools/mytool/index.ts instead of tools/mytool.ts)

Subagent tool improvements:
- Refactored to use Message[] from ai package instead of custom types
- Extracted agent discovery to separate agents.ts module
- Added parallel mode streaming (shows progress from all tasks)
- Added turn count to usage stats footer
- Removed redundant Query section from scout output

Fixes:
- JSON mode stdout flush: Fixed race condition where pi --mode json
  could exit before all output was written, causing consumers to
  miss final events

Also:
- Added signal/timeout support to pi.exec() for custom tools and hooks
- Renamed pi-pods bin to avoid conflict with pi
This commit is contained in:
Mario Zechner 2025-12-19 04:10:09 +01:00
parent 1151975afe
commit 4fb3af93fb
15 changed files with 894 additions and 698 deletions

View file

@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
import type { LoadedHook, SendHandler } from "./loader.js";
import type {
BranchEventResult,
ExecOptions,
ExecResult,
HookError,
HookEvent,
@ -28,13 +29,45 @@ export type HookErrorListener = (error: HookError) => void;
/**
* Execute a command and return stdout/stderr/code.
* Supports cancellation via AbortSignal and timeout.
*/
async function exec(command: string, args: string[], cwd: string): Promise<ExecResult> {
async function exec(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, { cwd, shell: false });
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout?.on("data", (data) => {
stdout += data.toString();
@ -45,11 +78,19 @@ async function exec(command: string, args: string[], cwd: string): Promise<ExecR
});
proc.on("close", (code) => {
resolve({ stdout, stderr, code: code ?? 0 });
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: code ?? 0, killed });
});
proc.on("error", (_err) => {
resolve({ stdout, stderr, code: 1 });
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: 1, killed });
});
});
}
@ -166,7 +207,7 @@ export class HookRunner {
*/
private createContext(): HookEventContext {
return {
exec: (command: string, args: string[]) => exec(command, args, this.cwd),
exec: (command: string, args: string[], options?: ExecOptions) => exec(command, args, this.cwd, options),
ui: this.uiContext,
hasUI: this.hasUI,
cwd: this.cwd,