Compare commits

...

9 Commits

17 changed files with 2631 additions and 1 deletions

View File

@@ -81,4 +81,5 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
- Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments. - Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments.
The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed. The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed.
- Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error. - Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error.
A copied or patched Pi launcher that only prepends Nix `fd`/`rg` to `PATH` may still break `@` autocomplete unless the local tool path is removed or Pi validates the local binary before using it. This flake patches Pi to validate local tool binaries before selecting them, so it falls back to usable `fd`/`rg` from `PATH` instead.
Stale unpatched launchers are the remaining failure mode for broken `@` autocomplete.

View File

@@ -0,0 +1,254 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
type QuotaState =
| { status: "idle" | "loading" }
| { status: "ok"; detail: string; refreshedAt: number; weeklyRemaining?: number; shortRemaining?: number }
| { status: "missing" | "error"; detail: string; refreshedAt?: number };
const CODEX_USAGE_ENDPOINTS = [
"https://chatgpt.com/backend-api/wham/usage",
"https://chatgpt.com/backend-api/codex/usage",
];
const QUOTA_REFRESH_MS = 5 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 10_000;
let quotaState: QuotaState = { status: "idle" };
let quotaRefreshPromise: Promise<void> | null = null;
function shortCwd(cwd: string): string {
const home = process.env.HOME;
if (home && cwd.startsWith(`${home}/`)) return `~/${basename(cwd)}`;
return basename(cwd) || cwd;
}
function gitBranch(cwd: string): string | null {
try {
const out = execFileSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out || null;
} catch {
return null;
}
}
function authPath(): string {
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json");
}
function readCodexCredentials(): { access: string; accountId?: string } | null {
const file = authPath();
if (!existsSync(file)) return null;
try {
const auth = JSON.parse(readFileSync(file, "utf8"));
const credential = auth?.["openai-codex"];
if (credential?.type !== "oauth" || typeof credential.access !== "string") return null;
if (typeof credential.expires === "number" && credential.expires <= Date.now() + 30_000) return null;
return {
access: credential.access,
accountId: typeof credential.accountId === "string" ? credential.accountId : undefined,
};
} catch {
return null;
}
}
function numberValue(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function objectValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function windowSeconds(raw: Record<string, unknown>): number | undefined {
const seconds = numberValue(raw.limit_window_seconds ?? raw.windowSeconds);
if (seconds !== undefined) return seconds;
const mins = numberValue(raw.windowDurationMins ?? raw.window_duration_mins);
return mins === undefined ? undefined : mins * 60;
}
function usedPercent(raw: Record<string, unknown>): number | undefined {
const value = numberValue(raw.used_percent ?? raw.usedPercent);
if (value === undefined) return undefined;
return Math.max(0, Math.min(100, value));
}
function collectWindows(raw: unknown, out: Array<{ seconds?: number; used: number; key: string }> = [], key = "root") {
if (Array.isArray(raw)) {
raw.forEach((item, index) => collectWindows(item, out, `${key}.${index}`));
return out;
}
const obj = objectValue(raw);
if (!obj) return out;
const used = usedPercent(obj);
if (used !== undefined) out.push({ seconds: windowSeconds(obj), used, key });
for (const [childKey, value] of Object.entries(obj)) {
if (value && typeof value === "object") collectWindows(value, out, `${key}.${childKey}`);
}
return out;
}
function pickQuotaWindows(raw: unknown): { weeklyRemaining?: number; shortRemaining?: number } | null {
const windows = collectWindows(raw);
if (windows.length === 0) return null;
const weekly = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 604_800) <= 60 * 60)
?? windows.find((window) => /week|weekly|secondary/i.test(window.key));
const short = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 18_000) <= 60 * 30)
?? windows.find((window) => /five|session|primary|short/i.test(window.key));
return {
weeklyRemaining: weekly ? Math.max(0, Math.min(100, 100 - weekly.used)) : undefined,
shortRemaining: short ? Math.max(0, Math.min(100, 100 - short.used)) : undefined,
};
}
function safeFg(theme: any, color: string, text: string): string {
try {
return theme.fg(color, text);
} catch {
return theme.fg("accent", text);
}
}
function contextColor(percent: number): string {
if (percent >= 90) return "error";
if (percent >= 70) return "warning";
return "success";
}
function quotaColor(percent: number): string {
if (percent >= 80) return "error";
if (percent >= 50) return "warning";
return "border";
}
function bar(theme: any, width: number, percent: number | null, glyph: string, colorForPercent: (percent: number) => string): string {
const barWidth = Math.max(12, width);
if (percent === null) return theme.fg("muted", glyph.repeat(barWidth));
const clamped = Math.max(0, Math.min(100, percent));
const filled = Math.max(0, Math.min(barWidth, Math.round((clamped / 100) * barWidth)));
const empty = Math.max(0, barWidth - filled);
return safeFg(theme, colorForPercent(clamped), glyph.repeat(filled)) + theme.fg("dim", glyph.repeat(empty));
}
async function fetchCodexQuota(force = false): Promise<void> {
const fresh = quotaState.status === "ok" && Date.now() - quotaState.refreshedAt < QUOTA_REFRESH_MS;
if (!force && fresh) return;
if (quotaRefreshPromise) return quotaRefreshPromise;
quotaState = { status: "loading" };
quotaRefreshPromise = (async () => {
const credentials = readCodexCredentials();
if (!credentials) {
quotaState = { status: "missing", detail: "OpenAI Codex OAuth credentials were not found or are expired" };
return;
}
let lastError = "quota unavailable";
for (const endpoint of CODEX_USAGE_ENDPOINTS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const headers: Record<string, string> = { Authorization: `Bearer ${credentials.access}` };
if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId;
const response = await fetch(endpoint, { headers, signal: controller.signal });
if (!response.ok) {
lastError = `${response.status} ${response.statusText}`;
continue;
}
const windows = pickQuotaWindows(await response.json());
if (!windows || (windows.weeklyRemaining === undefined && windows.shortRemaining === undefined)) {
lastError = "response had no recognized quota windows";
continue;
}
const details = [];
if (windows.weeklyRemaining !== undefined) details.push(`weekly ${Math.round(windows.weeklyRemaining)}%`);
if (windows.shortRemaining !== undefined) details.push(`short ${Math.round(windows.shortRemaining)}%`);
quotaState = {
status: "ok",
detail: `Codex quota remaining: ${details.join(", ")}`,
weeklyRemaining: windows.weeklyRemaining,
shortRemaining: windows.shortRemaining,
refreshedAt: Date.now(),
};
return;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
} finally {
clearTimeout(timeout);
}
}
quotaState = { status: "error", detail: `Codex quota failed: ${lastError}`, refreshedAt: Date.now() };
})().finally(() => {
quotaRefreshPromise = null;
});
return quotaRefreshPromise;
}
function statusLines(ctx: any, theme: any, width: number): string[] {
const cwd = ctx.sessionManager?.getCwd?.() ?? ctx.cwd ?? process.cwd();
const branch = gitBranch(cwd);
const where = branch ? `󰉋 ${shortCwd(cwd)} 󰘬 ${branch}` : `󰉋 ${shortCwd(cwd)}`;
const model = ctx.model?.id ?? process.env.PI_MODEL ?? "no-model";
const thinking = ctx.thinkingLevel ?? process.env.PI_REASONING_LEVEL ?? "off";
const left = theme.fg("accent", where);
const right = theme.fg("dim", `${model}${thinking}`);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
const contextPercentRaw = ctx.getContextUsage?.()?.percent;
const contextPercent = typeof contextPercentRaw === "number" && Number.isFinite(contextPercentRaw) ? contextPercentRaw : null;
const quotaConsumed = quotaState.status === "ok" && quotaState.weeklyRemaining !== undefined
? 100 - quotaState.weeklyRemaining
: null;
return [
truncateToWidth(left + pad + right, width),
bar(theme, width, contextPercent, "▃", contextColor),
bar(theme, width, quotaConsumed, "▔", quotaColor),
];
}
function setCompactStatusUi(ctx: any) {
if (!ctx.hasUI) return;
ctx.ui.setWidget("compact-status", (_tui: any, theme: any) => ({
invalidate() {},
render(width: number) {
return statusLines(ctx, theme, width);
},
}));
ctx.ui.setFooter(() => ({ invalidate() {}, render: () => [] }));
}
export default function compactStatus(pi: ExtensionAPI) {
function refreshUi(ctx: any) {
setCompactStatusUi(ctx);
}
pi.on("session_start", (_event, ctx) => {
refreshUi(ctx);
void fetchCodexQuota(false).then(() => refreshUi(ctx));
});
pi.on("model_select", (_event, ctx) => refreshUi(ctx));
pi.on("agent_settled", (_event, ctx) => refreshUi(ctx));
pi.registerCommand("codex-quota", {
description: "Refresh and show ChatGPT Codex quota",
handler: async (_args, ctx) => {
refreshUi(ctx);
await fetchCodexQuota(true);
refreshUi(ctx);
const level = quotaState.status === "ok" ? "info" : quotaState.status === "missing" ? "warning" : "error";
ctx.ui.notify(quotaState.status === "idle" || quotaState.status === "loading" ? "Codex quota refresh in progress" : quotaState.detail, level);
},
});
}

View File

@@ -0,0 +1,141 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import type { ContextMode } from "./types.ts";
import type { Diagnostics } from "./config.ts";
export interface AgentDefinition {
name: string;
description: string;
body: string;
context?: ContextMode;
model?: string;
thinking?: string;
tools?: string;
allowedContexts?: ContextMode[];
hidden?: boolean;
source: string;
}
export function loadAgents(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): Map<string, AgentDefinition> {
const user = loadTier(join(agentDir, "agents"), "user", diagnostics);
const project = projectTrusted ? loadTier(join(cwd, ".pi", "agents"), "project", diagnostics) : new Map<string, AgentDefinition>();
return new Map([...user, ...project]);
}
function loadTier(dir: string, tier: string, diagnostics: Diagnostics): Map<string, AgentDefinition> {
const agents = new Map<string, AgentDefinition>();
if (!existsSync(dir)) return agents;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const path = join(dir, entry.name);
const parsed = parseAgent(path, diagnostics);
if (!parsed) continue;
if (agents.has(parsed.name)) {
diagnostics.warnings.push(`Duplicate ${tier} agent '${parsed.name}' ignored at ${path}`);
continue;
}
const stem = basename(entry.name, ".md");
if (stem !== parsed.name) diagnostics.warnings.push(`${tier} agent file '${entry.name}' name '${parsed.name}' does not match filename`);
agents.set(parsed.name, parsed);
}
return agents;
}
export function parseAgent(path: string, diagnostics: Diagnostics): AgentDefinition | undefined {
try {
const text = readFileSync(path, "utf8");
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/u.exec(text);
if (!match) {
diagnostics.warnings.push(`Agent ${path} missing YAML frontmatter`);
return undefined;
}
const frontmatter = parseFrontmatter(match[1]);
const name = stringField(frontmatter, "name");
const description = stringField(frontmatter, "description");
if (!name || !/^[a-z0-9-]+$/.test(name)) {
diagnostics.warnings.push(`Agent ${path} has invalid name`);
return undefined;
}
if (!description) {
diagnostics.warnings.push(`Agent ${path} has invalid description`);
return undefined;
}
const context = contextField(frontmatter.context);
const allowedContexts = contextsField(frontmatter.allowedContexts);
if (frontmatter.context !== undefined && !context) diagnostics.warnings.push(`Agent ${path} has invalid context`);
if (frontmatter.allowedContexts !== undefined && !allowedContexts) diagnostics.warnings.push(`Agent ${path} has invalid allowedContexts`);
if (context && allowedContexts && !allowedContexts.includes(context)) diagnostics.warnings.push(`Agent ${path} context is outside allowedContexts`);
return {
name,
description,
body: match[2].trim(),
context,
model: stringField(frontmatter, "model"),
thinking: stringField(frontmatter, "thinking"),
tools: stringField(frontmatter, "tools"),
allowedContexts,
hidden: booleanField(frontmatter, "hidden"),
source: path,
};
} catch (error) {
diagnostics.warnings.push(`Failed to load agent ${path}: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
function parseFrontmatter(text: string): Record<string, unknown> {
const result: Record<string, unknown> = {};
const lines = text.split(/\r?\n/u);
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (!line.trim() || line.trimStart().startsWith("#")) continue;
const scalar = /^(\w+):\s*(.*?)\s*$/u.exec(line);
if (!scalar) continue;
const [, key, raw] = scalar;
if (raw !== "") {
result[key] = parseScalar(raw);
continue;
}
const values: string[] = [];
while (i + 1 < lines.length) {
const item = /^\s+-\s*(.*?)\s*$/u.exec(lines[i + 1]);
if (!item) break;
values.push(String(parseScalar(item[1])));
i += 1;
}
result[key] = values;
}
return result;
}
function parseScalar(raw: string): string | boolean {
const unquoted = raw.replace(/^['"]|['"]$/gu, "");
if (unquoted === "true") return true;
if (unquoted === "false") return false;
return unquoted;
}
function stringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function booleanField(record: Record<string, unknown>, key: string): boolean | undefined {
const value = record[key];
return typeof value === "boolean" ? value : undefined;
}
function contextField(value: unknown): ContextMode | undefined {
return value === "independent" || value === "fork" ? value : undefined;
}
function contextsField(value: unknown): ContextMode[] | undefined {
if (!Array.isArray(value)) return undefined;
const contexts = value.map(contextField);
return contexts.every(Boolean) ? (contexts as ContextMode[]) : undefined;
}
function defaultAgentDir(): string {
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
}

View File

@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { loadAgents } from "./agents.ts";
import { BUILT_IN_TOOL_PROFILES, loadConfig, resolveSpawn, type Diagnostics } from "./config.ts";
function fixture() {
const root = mkdtempSync(join(tmpdir(), "subagents-config-"));
const agentDir = join(root, "agent");
const cwd = join(root, "project");
mkdirSync(agentDir, { recursive: true });
mkdirSync(cwd, { recursive: true });
return { root, agentDir, cwd };
}
function diagnostics(): Diagnostics {
return { warnings: [] };
}
test("missing config files and agent directories are normal", () => {
const { cwd, agentDir } = fixture();
const diag = diagnostics();
const config = loadConfig(cwd, true, diag, agentDir);
const agents = loadAgents(cwd, true, diag, agentDir);
assert.equal(config.defaultContext, "independent");
assert.equal(config.defaultTools, "read-only");
assert.equal(config.recentTerminalTtlMs, 300000);
assert.equal(agents.size, 0);
assert.deepEqual(diag.warnings, []);
});
test("global and trusted project config merge in order", () => {
const { cwd, agentDir } = fixture();
mkdirSync(join(cwd, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "global-profile", recentTerminalTtlMs: 1000, toolProfiles: { "global-profile": { activeTools: ["read"] } } }));
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", recentTerminalTtlMs: 2000, toolProfiles: { "project-profile": { activeTools: ["ls"] } } }));
const config = loadConfig(cwd, true, diagnostics(), agentDir);
assert.equal(config.defaultTools, "project-profile");
assert.equal(config.recentTerminalTtlMs, 2000);
assert.deepEqual(config.toolProfiles["global-profile"].activeTools, ["read"]);
assert.deepEqual(config.toolProfiles["project-profile"].activeTools, ["ls"]);
});
test("recent terminal ttl preserves zero and rejects invalid values", () => {
const { cwd, agentDir } = fixture();
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: 0 }));
const zeroDiag = diagnostics();
const zeroConfig = loadConfig(cwd, true, zeroDiag, agentDir);
assert.equal(zeroConfig.recentTerminalTtlMs, 0);
assert.deepEqual(zeroDiag.warnings, []);
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: -1 }));
const invalidDiag = diagnostics();
const invalidConfig = loadConfig(cwd, true, invalidDiag, agentDir);
assert.equal(invalidConfig.recentTerminalTtlMs, 300000);
assert.ok(invalidDiag.warnings.some((warning) => warning.includes("Invalid global recentTerminalTtlMs ignored")));
});
test("project config is ignored when project is untrusted", () => {
const { cwd, agentDir } = fixture();
mkdirSync(join(cwd, ".pi"), { recursive: true });
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", toolProfiles: { "project-profile": { activeTools: ["ls"] } } }));
const config = loadConfig(cwd, false, diagnostics(), agentDir);
assert.equal(config.defaultTools, "read-only");
assert.equal(config.toolProfiles["project-profile"], undefined);
});
test("agents load with project precedence over user", () => {
const { cwd, agentDir } = fixture();
mkdirSync(join(agentDir, "agents"), { recursive: true });
mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: User review\ntools: read-only\n---\nuser body\n");
writeFileSync(join(cwd, ".pi", "agents", "review.md"), "---\nname: review\ndescription: Project review\ntools: full-tools\n---\nproject body\n");
const agents = loadAgents(cwd, true, diagnostics(), agentDir);
assert.equal(agents.get("review")?.description, "Project review");
assert.equal(agents.get("review")?.body, "project body");
});
test("duplicate same-tier definitions and invalid frontmatter produce diagnostics", () => {
const { cwd, agentDir } = fixture();
const dir = join(agentDir, "agents");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "one.md"), "---\nname: same\ndescription: One\n---\none\n");
writeFileSync(join(dir, "two.md"), "---\nname: same\ndescription: Two\n---\ntwo\n");
writeFileSync(join(dir, "bad.md"), "---\nname: Bad Name\n---\nbad\n");
const diag = diagnostics();
const agents = loadAgents(cwd, true, diag, agentDir);
assert.equal(agents.size, 1);
assert.ok(diag.warnings.some((warning) => warning.includes("Duplicate user agent 'same'")));
assert.ok(diag.warnings.some((warning) => warning.includes("invalid name")));
});
test("named spawn resolves overrides, frontmatter, config, and defaults", () => {
const { cwd, agentDir } = fixture();
mkdirSync(join(agentDir, "agents"), { recursive: true });
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "local-review", toolProfiles: { "local-review": { activeTools: ["read"] } } }));
writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: Review\ncontext: independent\nmodel: inherit\nthinking: high\ntools: local-review\n---\nagent body\n");
const diag = diagnostics();
const config = loadConfig(cwd, true, diag, agentDir);
const agents = loadAgents(cwd, true, diag, agentDir);
const resolved = resolveSpawn({ agent: "review", prompt: "check this", thinking: "low" }, config, agents);
assert.equal(resolved.prompt, "check this");
assert.equal(resolved.context, "independent");
assert.equal(resolved.model, "inherit");
assert.equal(resolved.thinking, "low");
assert.equal(resolved.tools, "local-review");
assert.deepEqual(resolved.toolProfile.activeTools, ["read"]);
assert.equal(resolved.agentBody, "agent body");
});
test("built-in tool profile names cannot be overridden", () => {
const { cwd, agentDir } = fixture();
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ toolProfiles: { "read-only": { activeTools: ["bash"] } } }));
const diag = diagnostics();
const config = loadConfig(cwd, true, diag, agentDir);
assert.deepEqual(config.toolProfiles["read-only"], BUILT_IN_TOOL_PROFILES["read-only"]);
assert.ok(diag.warnings.some((warning) => warning.includes("Ignoring global override for built-in tool profile 'read-only'")));
});

View File

@@ -0,0 +1,182 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { ContextMode, SpawnRequest, ToolProfile } from "./types.ts";
import type { AgentDefinition } from "./agents.ts";
export interface Diagnostics {
warnings: string[];
}
export interface SubagentsConfig {
defaultContext: ContextMode;
defaultTools: string;
maxConcurrent: number;
recentTerminalTtlMs: number;
ui: {
enabled: boolean;
defaultExpanded: boolean;
};
toolProfiles: Record<string, ToolProfile>;
}
export interface ResolvedSpawnRequest extends SpawnRequest {
prompt: string;
context: ContextMode;
tools: string;
toolProfile: ToolProfile;
agentBody?: string;
}
export const BUILT_IN_TOOL_PROFILES: Record<string, ToolProfile> = {
none: { activeTools: [] },
"read-only": { activeTools: ["read", "grep", "find", "ls"] },
"read-only-with-safe-bash": { activeTools: ["read", "grep", "find", "ls", "bash"] },
"full-tools": { activeTools: null },
};
const DEFAULT_CONFIG: SubagentsConfig = {
defaultContext: "independent",
defaultTools: "read-only",
maxConcurrent: 3,
recentTerminalTtlMs: 5 * 60 * 1000,
ui: { enabled: true, defaultExpanded: false },
toolProfiles: { ...BUILT_IN_TOOL_PROFILES },
};
export function loadConfig(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): SubagentsConfig {
let config = cloneConfig(DEFAULT_CONFIG);
config = mergeConfig(config, readConfig(join(agentDir, "subagents.json"), diagnostics, "global"), diagnostics, "global");
if (projectTrusted) {
config = mergeConfig(config, readConfig(join(cwd, ".pi", "subagents.json"), diagnostics, "project"), diagnostics, "project");
}
if (!config.toolProfiles[config.defaultTools]) {
diagnostics.warnings.push(`Unknown defaultTools profile '${config.defaultTools}', using read-only`);
config.defaultTools = "read-only";
}
return config;
}
export function resolveSpawn(request: SpawnRequest, config: SubagentsConfig, agents: Map<string, AgentDefinition>): ResolvedSpawnRequest {
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
if (!prompt) throw new Error("prompt is required");
const agent = request.agent ? agents.get(request.agent) : undefined;
if (request.agent && !agent) throw new Error(`unknown subagent agent: ${request.agent}`);
const context = request.context ?? agent?.context ?? config.defaultContext;
if (context !== "independent" && context !== "fork") throw new Error(`unsupported context: ${context}`);
if (agent?.allowedContexts && !agent.allowedContexts.includes(context)) {
throw new Error(`agent '${agent.name}' does not allow ${context} context`);
}
const tools = request.tools ?? agent?.tools ?? config.defaultTools;
const toolProfile = config.toolProfiles[tools];
if (!toolProfile) throw new Error(`unknown tool profile: ${tools}`);
return {
...request,
prompt,
agent: agent?.name ?? request.agent,
context,
model: request.model ?? agent?.model,
thinking: request.thinking ?? agent?.thinking,
tools,
toolProfile,
agentBody: agent?.body,
};
}
function readConfig(path: string, diagnostics: Diagnostics, label: string): Partial<SubagentsConfig> | undefined {
if (!existsSync(path)) return undefined;
try {
const parsed = JSON.parse(readFileSync(path, "utf8"));
return normalizeConfig(parsed, diagnostics, label);
} catch (error) {
diagnostics.warnings.push(`Invalid ${label} subagents.json: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
function normalizeConfig(raw: unknown, diagnostics: Diagnostics, label: string): Partial<SubagentsConfig> | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
diagnostics.warnings.push(`Invalid ${label} subagents.json: root must be an object`);
return undefined;
}
const input = raw as Record<string, unknown>;
const config: Partial<SubagentsConfig> = {};
if (input.defaultContext === "independent" || input.defaultContext === "fork") config.defaultContext = input.defaultContext;
else if (input.defaultContext !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultContext ignored`);
if (typeof input.defaultTools === "string") config.defaultTools = input.defaultTools;
else if (input.defaultTools !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultTools ignored`);
if (typeof input.maxConcurrent === "number" && Number.isInteger(input.maxConcurrent) && input.maxConcurrent > 0) config.maxConcurrent = input.maxConcurrent;
else if (input.maxConcurrent !== undefined) diagnostics.warnings.push(`Invalid ${label} maxConcurrent ignored`);
if (typeof input.recentTerminalTtlMs === "number" && Number.isInteger(input.recentTerminalTtlMs) && input.recentTerminalTtlMs >= 0) {
config.recentTerminalTtlMs = input.recentTerminalTtlMs;
} else if (input.recentTerminalTtlMs !== undefined) diagnostics.warnings.push(`Invalid ${label} recentTerminalTtlMs ignored`);
if (input.ui !== undefined) config.ui = normalizeUi(input.ui, diagnostics, label);
if (input.toolProfiles !== undefined) config.toolProfiles = normalizeProfiles(input.toolProfiles, diagnostics, label);
return config;
}
function normalizeUi(raw: unknown, diagnostics: Diagnostics, label: string): SubagentsConfig["ui"] | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
diagnostics.warnings.push(`Invalid ${label} ui ignored`);
return undefined;
}
const input = raw as Record<string, unknown>;
return {
enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.ui.enabled,
defaultExpanded: typeof input.defaultExpanded === "boolean" ? input.defaultExpanded : DEFAULT_CONFIG.ui.defaultExpanded,
};
}
function normalizeProfiles(raw: unknown, diagnostics: Diagnostics, label: string): Record<string, ToolProfile> {
const profiles: Record<string, ToolProfile> = {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
diagnostics.warnings.push(`Invalid ${label} toolProfiles ignored`);
return profiles;
}
for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
if (name in BUILT_IN_TOOL_PROFILES) {
diagnostics.warnings.push(`Ignoring ${label} override for built-in tool profile '${name}'`);
continue;
}
const profile = normalizeProfile(value);
if (!profile) {
diagnostics.warnings.push(`Invalid ${label} tool profile '${name}' ignored`);
continue;
}
profiles[name] = profile;
}
return profiles;
}
function normalizeProfile(raw: unknown): ToolProfile | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const activeTools = (raw as { activeTools?: unknown }).activeTools;
if (!Array.isArray(activeTools) || !activeTools.every((tool) => typeof tool === "string")) return undefined;
return { activeTools };
}
function mergeConfig(base: SubagentsConfig, override: Partial<SubagentsConfig> | undefined, diagnostics: Diagnostics, label: string): SubagentsConfig {
if (!override) return base;
const merged = cloneConfig(base);
if (override.defaultContext) merged.defaultContext = override.defaultContext;
if (override.defaultTools) merged.defaultTools = override.defaultTools;
if (override.maxConcurrent) merged.maxConcurrent = override.maxConcurrent;
if (override.recentTerminalTtlMs !== undefined) merged.recentTerminalTtlMs = override.recentTerminalTtlMs;
if (override.ui) merged.ui = { ...merged.ui, ...override.ui };
if (override.toolProfiles) merged.toolProfiles = { ...merged.toolProfiles, ...override.toolProfiles };
for (const key of Object.keys(merged.toolProfiles)) {
if (key in BUILT_IN_TOOL_PROFILES) merged.toolProfiles[key] = BUILT_IN_TOOL_PROFILES[key];
}
return merged;
}
function cloneConfig(config: SubagentsConfig): SubagentsConfig {
return { ...config, ui: { ...config.ui }, toolProfiles: { ...config.toolProfiles } };
}
function defaultAgentDir(): string {
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
}

View File

@@ -0,0 +1,296 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { loadAgents } from "./agents.ts";
import { loadConfig, resolveSpawn, type Diagnostics } from "./config.ts";
import { SubprocessRpcRunner } from "./runner.ts";
import { Supervisor } from "./supervisor.ts";
import type { SpawnRequest, SubagentStatus } from "./types.ts";
import { widget } from "./ui.ts";
let supervisor: Supervisor | undefined;
let lastDiagnostics: Diagnostics = { warnings: [] };
let lastStatuses: SubagentStatus[] = [];
let uiExpanded = false;
export default function subagents(pi: ExtensionAPI) {
const getSupervisor = (ctx: ExtensionContext): Supervisor => {
if (supervisor) return supervisor;
const diagnostics: Diagnostics = { warnings: [] };
const cwd = cwdOf(ctx);
const config = loadConfig(cwd, isProjectTrusted(ctx), diagnostics);
lastDiagnostics = diagnostics;
uiExpanded = config.ui.defaultExpanded;
supervisor = new Supervisor(new SubprocessRpcRunner(), cwd, {
maxConcurrent: config.maxConcurrent,
recentTerminalTtlMs: config.recentTerminalTtlMs,
onMilestone: (status, event) => pi.appendEntry("subagent_milestone", { event, status }),
onChange: (statuses) => {
lastStatuses = statuses;
updateUi(ctx, config.ui.enabled);
},
});
updateUi(ctx, config.ui.enabled);
return supervisor;
};
const resolve = (ctx: ExtensionContext, request: SpawnRequest): SpawnRequest => {
const diagnostics: Diagnostics = { warnings: [] };
const cwd = cwdOf(ctx);
const trusted = isProjectTrusted(ctx);
const config = loadConfig(cwd, trusted, diagnostics);
const agents = loadAgents(cwd, trusted, diagnostics);
lastDiagnostics = diagnostics;
const resolved = resolveSpawn(request, config, agents);
if (resolved.context === "fork") resolved.parentSessionFile = ctx.sessionManager.getSessionFile();
return resolved;
};
pi.registerTool({
name: "subagent_spawn",
label: "Spawn subagent",
description: "Start one ad hoc independent subagent and return immediately with its child id",
parameters: Type.Object({
prompt: Type.String({ description: "Prompt for the delegated subagent" }),
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })),
tools: Type.Optional(Type.String({ description: "Tool profile name" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const accepted = getSupervisor(ctx).spawn(resolve(ctx, params as SpawnRequest));
ctx.ui?.notify?.(`Started subagent ${accepted.id}`, "info");
return textResult(accepted);
},
});
pi.registerTool({
name: "subagent_batch",
label: "Spawn subagent batch",
description: "Start multiple subagents and return immediately with accepted child ids and per-entry failures",
parameters: Type.Object({
subagents: Type.Array(
Type.Object({
prompt: Type.String({ description: "Prompt for the delegated subagent" }),
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })),
tools: Type.Optional(Type.String({ description: "Tool profile name" })),
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const requests = Array.isArray((params as { subagents?: unknown }).subagents) ? ((params as { subagents: SpawnRequest[] }).subagents) : [];
const accepted: SpawnRequest[] = [];
const failed: Array<{ index: number; error: string }> = [];
requests.forEach((request, index) => {
try {
accepted.push(resolve(ctx, request));
} catch (error) {
failed.push({ index, error: error instanceof Error ? error.message : String(error) });
}
});
const result = getSupervisor(ctx).spawnBatch(accepted);
return textResult({ accepted: result.accepted, failed: [...failed, ...result.failed] });
},
});
pi.registerTool({
name: "subagent_list",
label: "List subagents",
description: "List active and recent subagents for this parent session",
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
return textResult(getSupervisor(ctx).list());
},
});
pi.registerTool({
name: "subagent_status",
label: "Get subagent status",
description: "Get current lifecycle status for one subagent",
parameters: Type.Object({
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
return textResult(getSupervisor(ctx).status(String((params as { id: unknown }).id)));
},
});
pi.registerTool({
name: "subagent_result",
label: "Get subagent result",
description: "Return still-running before completion and the final answer after completion",
parameters: Type.Object({
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
return textResult(getSupervisor(ctx).result(String((params as { id: unknown }).id)));
},
});
pi.registerTool({
name: "subagent_wait",
label: "Wait for subagents",
description: "Block until multiple subagents are terminal or a timeout expires. Prefer setting timeoutMs so the parent turn cannot hang forever",
parameters: Type.Object({
ids: Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" })),
timeoutMs: Type.Optional(Type.Number({ description: "Maximum milliseconds to wait. Omit or use 0 to wait indefinitely" })),
mode: Type.Optional(Type.Union([Type.Literal("all"), Type.Literal("any")], { description: "Wait for all ids by default, or return after any id is terminal" })),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const input = params as { ids?: unknown; timeoutMs?: unknown; mode?: unknown };
const ids = Array.isArray(input.ids) ? input.ids.map(String) : [];
const timeoutMs = typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) ? input.timeoutMs : undefined;
const mode = input.mode === "any" ? "any" : "all";
return textResult(await getSupervisor(ctx).wait(ids, { timeoutMs, mode, signal }));
},
});
pi.registerTool({
name: "subagent_cancel",
label: "Cancel subagent",
description: "Cancel a running subagent",
parameters: Type.Object({
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
return textResult(await getSupervisor(ctx).cancel(String((params as { id: unknown }).id)));
},
});
pi.registerCommand("subagent-spawn", {
description: "Start an ad hoc independent subagent",
handler: async (args, ctx) => {
const accepted = getSupervisor(ctx).spawn(resolve(ctx, parseSpawnArgs(args)));
ctx.ui.notify(`Started subagent ${accepted.id}`, "info");
},
});
pi.registerCommand("subagent-batch", {
description: "Start ad hoc independent subagents split by |",
handler: async (args, ctx) => {
const requests = args
.split("|")
.map((prompt) => prompt.trim())
.filter(Boolean)
.map((prompt) => resolve(ctx, { prompt }));
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).spawnBatch(requests), null, 2), "info");
},
});
pi.registerCommand("subagent-list", {
description: "Show subagent status records",
handler: async (_args, ctx) => {
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).list(), null, 2), "info");
},
});
pi.registerCommand("subagent-status", {
description: "Show a subagent status by id",
handler: async (args, ctx) => {
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).status(args.trim()), null, 2), "info");
},
});
pi.registerCommand("subagent-result", {
description: "Show a subagent result by id",
handler: async (args, ctx) => {
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).result(args.trim()), null, 2), "info");
},
});
pi.registerCommand("subagent-wait", {
description: "Wait for subagent ids separated by spaces",
handler: async (args, ctx) => {
const { ids, timeoutMs, mode } = parseWaitArgs(args);
ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).wait(ids, { timeoutMs, mode }), null, 2), "info");
},
});
pi.registerCommand("subagent-ui", {
description: "Toggle the bundled subagent status inspector",
handler: async (_args, ctx) => {
uiExpanded = !uiExpanded;
updateUi(ctx, true);
ctx.ui.notify(`Subagent inspector ${uiExpanded ? "expanded" : "collapsed"}`, "info");
},
});
pi.registerCommand("subagent-diagnostics", {
description: "Show subagent configuration diagnostics from the last load",
handler: async (_args, ctx) => {
ctx.ui.notify(JSON.stringify(lastDiagnostics, null, 2), "info");
},
});
pi.registerCommand("subagent-cancel", {
description: "Cancel a running subagent by id",
handler: async (args, ctx) => {
ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).cancel(args.trim()), null, 2), "info");
},
});
pi.on("session_shutdown", async () => {
await supervisor?.shutdown();
supervisor = undefined;
});
}
function updateUi(ctx: ExtensionContext, enabled: boolean) {
if (!ctx.hasUI) return;
ctx.ui.setWidget("subagents", enabled ? widget(lastStatuses, uiExpanded) : undefined);
}
function parseSpawnArgs(args: string): SpawnRequest {
const parts = args.trim().split(/\s+/u);
const request: Partial<SpawnRequest> = {};
while (parts.length >= 2 && parts[0].startsWith("--")) {
const flag = parts.shift();
const value = parts.shift();
if (flag === "--agent") request.agent = value;
else if (flag === "--context" && (value === "independent" || value === "fork")) request.context = value;
else if (flag === "--tools") request.tools = value;
else if (flag === "--model") request.model = value;
else if (flag === "--thinking") request.thinking = value;
}
return { ...request, prompt: parts.join(" ") || args } as SpawnRequest;
}
function parseWaitArgs(args: string): { ids: string[]; timeoutMs?: number; mode?: "all" | "any" } {
const parts = args.trim().split(/\s+/u).filter(Boolean);
let timeoutMs: number | undefined;
let mode: "all" | "any" | undefined;
const ids: string[] = [];
while (parts.length > 0) {
const part = parts.shift();
if (!part) continue;
if (part === "--timeout-ms" && parts[0]) {
const parsed = Number(parts.shift());
if (Number.isFinite(parsed)) timeoutMs = parsed;
} else if (part === "--mode" && (parts[0] === "all" || parts[0] === "any")) {
mode = parts.shift() as "all" | "any";
} else {
ids.push(part);
}
}
return { ids, timeoutMs, mode };
}
function isProjectTrusted(ctx: ExtensionContext): boolean {
const value = (ctx as unknown as { isProjectTrusted?: () => boolean }).isProjectTrusted?.();
return value === true;
}
function cwdOf(ctx: ExtensionContext): string {
const sessionCwd = (ctx as unknown as { sessionManager?: { getCwd?: () => string }; cwd?: string }).sessionManager?.getCwd?.();
return sessionCwd ?? (ctx as unknown as { cwd?: string }).cwd ?? process.cwd();
}
function textResult(value: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
details: value,
};
}

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import childProcess from "node:child_process";
import { EventEmitter } from "node:events";
import { fileURLToPath } from "node:url";
import test from "node:test";
import type { RunnerEvents } from "./types.ts";
class FakeStream extends EventEmitter {
setEncoding(_encoding: BufferEncoding): void {}
write(_chunk: string, callback?: (error?: Error | null) => void): boolean {
callback?.();
return true;
}
end(): void {}
}
function events(): RunnerEvents {
return {
accepted: () => {},
running: () => {},
settling: () => {},
completed: () => {},
failed: () => {},
};
}
test("child RPC process disables discovery while explicitly loading subagents extension", async (t) => {
const calls: Array<{ command: string; args: string[] }> = [];
const fakeChild = new EventEmitter() as EventEmitter & {
stdout: FakeStream;
stderr: FakeStream;
stdin: FakeStream;
killed: boolean;
pid?: number;
kill(signal?: NodeJS.Signals): boolean;
};
fakeChild.stdout = new FakeStream();
fakeChild.stderr = new FakeStream();
fakeChild.stdin = new FakeStream();
fakeChild.killed = false;
fakeChild.kill = () => {
fakeChild.killed = true;
return true;
};
const spawn = t.mock.method(childProcess, "spawn", (command, args) => {
calls.push({ command: String(command), args: Array.isArray(args) ? args.map(String) : [] });
return fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams;
});
const { SubprocessRpcRunner } = await import("./runner.ts");
const runner = new SubprocessRpcRunner();
await runner.start("child-1", { prompt: "work" }, "/tmp", events());
assert.equal(spawn.mock.callCount(), 1);
const args = calls[0].args;
const noExtensionsIndex = args.indexOf("--no-extensions");
const extensionIndex = args.indexOf("--extension");
assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled");
assert.notEqual(extensionIndex, -1, "child args explicitly load the subagents extension entry");
assert.equal(args[extensionIndex + 1], fileURLToPath(new URL("./index.ts", import.meta.url)));
assert.ok(noExtensionsIndex < extensionIndex);
});

View File

@@ -0,0 +1,217 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
interface PendingResponse {
resolve(value: unknown): void;
reject(error: Error): void;
command: string;
}
interface RpcLine {
id?: string;
type?: string;
command?: string;
success?: boolean;
data?: unknown;
error?: string;
message?: string;
}
class RpcChildHandle implements ChildHandle {
private buffer = "";
private nextRequest = 0;
private settled = false;
private finishing = false;
private cancelling = false;
private killed = false;
private readonly pending = new Map<string, PendingResponse>();
constructor(
private readonly child: ChildProcessWithoutNullStreams,
private readonly events: RunnerEvents,
) {
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => this.onStdout(chunk));
child.stderr.on("data", (chunk) => this.events.running(`stderr: ${String(chunk).trim().slice(0, 200)}`));
child.on("error", (error) => this.fail(error.message));
child.on("close", (code, signal) => {
for (const pending of this.pending.values()) {
pending.reject(new Error(`RPC process closed before ${pending.command} response`));
}
this.pending.clear();
if (!this.settled) this.fail(`RPC process closed with code ${code ?? "null"} signal ${signal ?? "null"}`);
});
}
async prompt(message: string): Promise<void> {
await this.send("prompt", { message });
}
async cancel(): Promise<void> {
if (this.cancelling) return;
this.cancelling = true;
try {
await Promise.race([this.send("abort", {}), delay(200)]);
} catch {}
this.terminate();
}
private onStdout(chunk: string) {
this.buffer += chunk;
while (true) {
const newline = this.buffer.indexOf("\n");
if (newline === -1) return;
const line = this.buffer.slice(0, newline).replace(/\r$/, "");
this.buffer = this.buffer.slice(newline + 1);
if (line.trim() === "") continue;
this.onLine(line);
}
}
private onLine(line: string) {
let payload: RpcLine;
try {
payload = JSON.parse(line);
} catch {
this.events.running(`non-json rpc output: ${line.slice(0, 200)}`);
return;
}
if (payload.type === "response" && payload.id) {
const pending = this.pending.get(payload.id);
if (!pending) return;
this.pending.delete(payload.id);
if (payload.success) pending.resolve(payload.data);
else pending.reject(new Error(payload.error ?? payload.message ?? `${pending.command} failed`));
return;
}
if (payload.type === "agent_started") {
this.events.running("agent_started");
return;
}
if (payload.type === "agent_settled") {
this.finish().catch((error) => this.fail(error instanceof Error ? error.message : String(error)));
return;
}
if (payload.type) this.events.running(payload.type);
}
private async finish() {
if (this.settled || this.finishing) return;
this.finishing = true;
this.events.settling();
const result = await this.send("get_last_assistant_text", {});
const text = typeof result === "string" ? result : result && typeof result === "object" && "text" in result ? String((result as { text: unknown }).text) : "";
this.settled = true;
this.events.completed(text, "agent_settled");
this.terminate();
}
private terminate() {
if (this.killed) return;
this.killed = true;
this.child.stdin.end();
if (this.child.killed) return;
if (process.platform !== "win32" && this.child.pid) {
try {
process.kill(-this.child.pid, "SIGTERM");
} catch {
this.child.kill("SIGTERM");
}
setTimeout(() => {
if (this.child.killed || !this.child.pid) return;
try {
process.kill(-this.child.pid, "SIGKILL");
} catch {
this.child.kill("SIGKILL");
}
}, 2_000).unref();
return;
}
this.child.kill("SIGTERM");
}
private fail(error: string) {
if (this.settled) return;
this.settled = true;
this.events.failed(error);
}
private send(command: string, body: Record<string, unknown>): Promise<unknown> {
const id = `subagent-${++this.nextRequest}`;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject, command });
this.child.stdin.write(`${JSON.stringify({ id, type: command, ...body })}\n`, (error) => {
if (!error) return;
this.pending.delete(id);
reject(error);
});
});
}
}
export class SubprocessRpcRunner implements ChildRunner {
async start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle> {
const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)];
const child = spawn(process.execPath, args, {
cwd,
env: childEnvironment(),
stdio: ["pipe", "pipe", "pipe"],
detached: process.platform !== "win32",
});
const handle = new RpcChildHandle(child, events);
events.accepted();
void handle.prompt(independentPrompt(request)).catch((error) => events.failed(error instanceof Error ? error.message : String(error)));
return handle;
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function subagentsExtensionPath(): string {
return fileURLToPath(new URL("./index.ts", import.meta.url));
}
function contextArgs(request: SpawnRequest): string[] {
if (request.context !== "fork" || !request.parentSessionFile) return [];
return ["--fork", request.parentSessionFile];
}
function toolArgs(request: SpawnRequest): string[] {
const activeTools = request.toolProfile?.activeTools;
if (activeTools === undefined || activeTools === null) return [];
if (activeTools.length === 0) return ["--no-tools"];
return ["--tools", activeTools.join(",")];
}
function modelArgs(request: SpawnRequest): string[] {
const args: string[] = [];
if (request.model && request.model !== "inherit") args.push("--model", request.model);
if (request.thinking) args.push("--thinking", request.thinking);
return args;
}
function childEnvironment(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.PI_SESSION_ID;
delete env.PI_SESSION_FILE;
delete env.PI_PROVIDER;
delete env.PI_MODEL;
delete env.PI_REASONING_LEVEL;
return env;
}
function independentPrompt(request: SpawnRequest): string {
const base = request.agentBody ? `${request.agentBody}\n\n` : "";
if (request.context === "fork") {
return `${base}You are running as a delegated subagent in fork context.\nUse the inherited parent session context, then return a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`;
}
return `${base}You are running as a delegated subagent in independent context.\nDo not assume access to the parent conversation transcript.\nReturn a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`;
}

View File

@@ -0,0 +1,38 @@
import type { ChildRecord, SpawnAccepted, SubagentResult, SubagentStatus } from "./types.ts";
export function toAccepted(status: SubagentStatus): SpawnAccepted {
return {
id: status.id,
label: status.label,
context: status.context,
tools: status.tools,
state: status.state,
hint: `Use subagent_status or subagent_result with id ${status.id}`,
};
}
export function cloneStatus(status: SubagentStatus): SubagentStatus {
return { ...status, elapsedMs: elapsedMs(status) };
}
export function cloneResult(record: ChildRecord): SubagentResult {
const status = cloneStatus(record.status);
const terminal = ["completed", "failed", "cancelled", "timed_out", "orphaned"].includes(status.state);
return {
id: status.id,
state: status.state,
running: !terminal,
resultAvailable: status.resultAvailable,
result: record.result,
error: status.error,
completedAt: status.completedAt,
elapsedMs: status.elapsedMs,
};
}
export function elapsedMs(status: Pick<SubagentStatus, "startedAt" | "completedAt">): number {
const start = Date.parse(status.startedAt);
const end = status.completedAt ? Date.parse(status.completedAt) : Date.now();
if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
return Math.max(0, end - start);
}

View File

@@ -0,0 +1,306 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Supervisor } from "./supervisor.ts";
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
class FakeHandle implements ChildHandle {
cancelCalls = 0;
async cancel(): Promise<void> {
this.cancelCalls += 1;
}
}
class FakeRunner implements ChildRunner {
starts: Array<{ id: string; request: SpawnRequest; events: RunnerEvents; handle: FakeHandle }> = [];
autoAccept = true;
async start(id: string, request: SpawnRequest, _cwd: string, events: RunnerEvents): Promise<ChildHandle> {
const handle = new FakeHandle();
this.starts.push({ id, request, events, handle });
if (this.autoAccept) events.accepted(`session-${id}`);
return handle;
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function spawnStarted(supervisor: Supervisor, prompt = "work") {
const accepted = supervisor.spawn({ prompt });
await sleep(0);
return accepted;
}
test("cancel is idempotent and reaches cancelled", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const accepted = await spawnStarted(supervisor);
const first = await supervisor.cancel(accepted.id);
const second = await supervisor.cancel(accepted.id);
assert.equal(first.state, "cancelled");
assert.equal(second.state, "cancelled");
assert.equal(runner.starts[0].handle.cancelCalls, 1);
});
test("startup timeout reaches timed_out", async () => {
const runner = new FakeRunner();
runner.autoAccept = false;
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { startMs: 5 } });
const accepted = await spawnStarted(supervisor);
await sleep(20);
const status = supervisor.status(accepted.id);
assert.equal(status.state, "timed_out");
assert.equal(status.stopReason, "start_timeout");
assert.equal(runner.starts[0].handle.cancelCalls, 1);
});
test("runtime timeout reaches timed_out", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { runMs: 5 } });
const accepted = await spawnStarted(supervisor);
await sleep(20);
const status = supervisor.status(accepted.id);
assert.equal(status.state, "timed_out");
assert.equal(status.stopReason, "run_timeout");
assert.equal(runner.starts[0].handle.cancelCalls, 1);
});
test("process failure reaches failed with diagnostics", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const accepted = await spawnStarted(supervisor);
runner.starts[0].events.failed("process closed with code 1");
const status = supervisor.status(accepted.id);
assert.equal(status.state, "failed");
assert.equal(status.error, "process closed with code 1");
});
test("shutdown cancels running children", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const accepted = await spawnStarted(supervisor);
await supervisor.shutdown();
const status = supervisor.status(accepted.id);
assert.equal(status.state, "cancelled");
assert.equal(status.stopReason, "shutdown");
assert.equal(runner.starts[0].handle.cancelCalls, 1);
});
test("completed children ignore later cancel", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const accepted = await spawnStarted(supervisor);
runner.starts[0].events.completed("done", "agent_settled");
await supervisor.cancel(accepted.id);
const result = supervisor.result(accepted.id);
assert.equal(result.state, "completed");
assert.equal(result.result, "done");
assert.equal(runner.starts[0].handle.cancelCalls, 0);
});
test("shutdown clears recent terminal expiry timer", async () => {
const runner = new FakeRunner();
let changes = 0;
const supervisor = new Supervisor(runner, "/tmp", {
recentTerminalTtlMs: 5,
onChange: () => {
changes += 1;
},
});
await spawnStarted(supervisor);
await supervisor.shutdown();
const afterShutdown = changes;
await sleep(15);
assert.equal(changes, afterShutdown);
});
test("batch spawn returns accepted ids and per-entry failures", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const result = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "" }, { prompt: "two" }]);
await sleep(0);
assert.equal(result.accepted.length, 2);
assert.equal(result.failed.length, 1);
assert.equal(result.failed[0].index, 1);
assert.equal(runner.starts.length, 2);
});
test("maxConcurrent preserves queued records", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
const result = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
await sleep(0);
assert.equal(result.accepted.length, 2);
assert.equal(runner.starts.length, 1);
assert.equal(supervisor.status(result.accepted[1].id).state, "queued");
runner.starts[0].events.completed("done", "agent_settled");
await sleep(0);
assert.equal(runner.starts.length, 2);
});
test("recent terminal statuses expire from list by ttl", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 5 });
const accepted = await spawnStarted(supervisor);
runner.starts[0].events.completed("done", "agent_settled");
assert.equal(supervisor.list().some((status) => status.id === accepted.id), true);
await sleep(10);
assert.equal(supervisor.list().some((status) => status.id === accepted.id), false);
assert.equal(supervisor.result(accepted.id).result, "done");
});
test("recent terminal ttl does not hide active statuses", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 0 });
const accepted = await spawnStarted(supervisor);
assert.equal(supervisor.list().some((status) => status.id === accepted.id), true);
});
test("zero recent terminal ttl hides terminal statuses immediately", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 0 });
const accepted = await spawnStarted(supervisor);
runner.starts[0].events.completed("done", "agent_settled");
assert.equal(supervisor.list().some((status) => status.id === accepted.id), false);
assert.equal(supervisor.result(accepted.id).result, "done");
});
test("recent terminal ttl emits a change when an entry expires", async () => {
const runner = new FakeRunner();
let changes = 0;
const supervisor = new Supervisor(runner, "/tmp", {
recentTerminalTtlMs: 5,
onChange: () => {
changes += 1;
},
});
await spawnStarted(supervisor);
const beforeComplete = changes;
runner.starts[0].events.completed("done", "agent_settled");
await sleep(15);
assert.ok(changes > beforeComplete + 1);
assert.equal(supervisor.list().length, 0);
});
test("wait blocks until multiple subagents are terminal", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const first = await spawnStarted(supervisor, "one");
const second = await spawnStarted(supervisor, "two");
const waiting = supervisor.wait([first.id, second.id], { timeoutMs: 100 });
runner.starts[0].events.completed("one done", "agent_settled");
await sleep(0);
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
runner.starts[1].events.failed("two failed");
const result = await waiting;
assert.equal(result.timedOut, false);
assert.equal(result.ready, true);
assert.deepEqual(result.ids, [first.id, second.id]);
assert.equal(result.pending.length, 0);
assert.deepEqual(result.results.map((item) => item.state), ["completed", "failed"]);
assert.equal(result.results[0].result, "one done");
assert.equal(result.results[1].error, "two failed");
});
test("wait returns pending statuses on timeout", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const first = await spawnStarted(supervisor, "one");
const second = await spawnStarted(supervisor, "two");
runner.starts[0].events.completed("one done", "agent_settled");
const result = await supervisor.wait([first.id, second.id], { timeoutMs: 5 });
assert.equal(result.timedOut, true);
assert.equal(result.ready, false);
assert.deepEqual(result.results.map((item) => item.state), ["completed", "running"]);
assert.deepEqual(result.pending.map((item) => item.id), [second.id]);
});
test("wait any returns after the first terminal subagent", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const first = await spawnStarted(supervisor, "one");
const second = await spawnStarted(supervisor, "two");
const waiting = supervisor.wait([first.id, second.id], { mode: "any", timeoutMs: 100 });
runner.starts[1].events.completed("two done", "agent_settled");
const result = await waiting;
assert.equal(result.timedOut, false);
assert.equal(result.ready, true);
assert.deepEqual(result.results.map((item) => item.state), ["running", "completed"]);
assert.deepEqual(result.pending.map((item) => item.id), [first.id]);
});
test("wait rejects unknown and empty id sets", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
await assert.rejects(() => supervisor.wait([]), /at least one subagent id is required/);
await assert.rejects(() => supervisor.wait(["missing"]), /unknown subagent id: missing/);
});
test("wait abort rejects without cancelling child", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp");
const accepted = await spawnStarted(supervisor, "one");
const controller = new AbortController();
const waiting = supervisor.wait([accepted.id], { signal: controller.signal });
controller.abort();
await assert.rejects(waiting, /subagent wait aborted/);
assert.equal(runner.starts[0].handle.cancelCalls, 0);
});
test("wait follows queued subagents through queue start and completion", async () => {
const runner = new FakeRunner();
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
const batch = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
await sleep(0);
const waiting = supervisor.wait([batch.accepted[1].id], { timeoutMs: 100 });
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
runner.starts[0].events.completed("one done", "agent_settled");
await sleep(0);
runner.starts[1].events.completed("two done", "agent_settled");
const result = await waiting;
assert.equal(result.timedOut, false);
assert.equal(result.ready, true);
assert.deepEqual(result.results.map((item) => item.result), ["two done"]);
});

View File

@@ -0,0 +1,423 @@
import type {
ChildHandle,
ChildRecord,
ChildRunner,
ContextMode,
RunnerEvents,
SpawnAccepted,
SpawnRequest,
SubagentResult,
SubagentStatus,
SubagentWaitMode,
SubagentWaitResult,
} from "./types.ts";
import { cloneResult, cloneStatus, toAccepted } from "./status.ts";
interface RunningChild {
record: ChildRecord;
request: SpawnRequest;
handle?: ChildHandle;
startTimer?: ReturnType<typeof setTimeout>;
runTimer?: ReturnType<typeof setTimeout>;
}
interface SupervisorOptions {
maxConcurrent?: number;
recentTerminalLimit?: number;
recentTerminalTtlMs?: number;
timeouts?: {
startMs?: number;
runMs?: number;
};
onMilestone?: (status: SubagentStatus, event: string) => void;
onChange?: (statuses: SubagentStatus[]) => void;
}
export interface BatchSpawnResult {
accepted: SpawnAccepted[];
failed: Array<{ index: number; error: string }>;
}
const DEFAULT_TIMEOUTS = {
startMs: 30_000,
runMs: 0,
};
export class Supervisor {
private nextChild = 0;
private readonly children = new Map<string, RunningChild>();
private readonly queue: RunningChild[] = [];
private readonly waiters = new Set<() => void>();
private recentTerminalTimer?: ReturnType<typeof setTimeout>;
constructor(
private readonly runner: ChildRunner,
private readonly cwd: string,
private readonly options: SupervisorOptions = {},
) {}
spawn(request: SpawnRequest): SpawnAccepted {
return this.createChild(request);
}
spawnBatch(requests: SpawnRequest[]): BatchSpawnResult {
const accepted: SpawnAccepted[] = [];
const failed: Array<{ index: number; error: string }> = [];
requests.forEach((request, index) => {
try {
accepted.push(this.createChild(request));
} catch (error) {
failed.push({ index, error: error instanceof Error ? error.message : String(error) });
}
});
return { accepted, failed };
}
list(): SubagentStatus[] {
const statuses = [...this.children.values()].map((child) => cloneStatus(child.record.status));
const active = statuses.filter((status) => !isTerminal(status.state));
const terminal = statuses
.filter((status) => isTerminal(status.state))
.filter((status) => this.isRecentTerminal(status))
.sort((a, b) => Date.parse(b.completedAt ?? b.startedAt) - Date.parse(a.completedAt ?? a.startedAt))
.slice(0, this.options.recentTerminalLimit ?? 10);
return [...active, ...terminal];
}
status(id: string): SubagentStatus {
return cloneStatus(this.require(id).record.status);
}
result(id: string): SubagentResult {
return cloneResult(this.require(id).record);
}
async wait(
ids: string[],
options: { timeoutMs?: number; signal?: AbortSignal; mode?: SubagentWaitMode } = {},
): Promise<SubagentWaitResult> {
const uniqueIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
if (uniqueIds.length === 0) throw new Error("at least one subagent id is required");
for (const id of uniqueIds) this.require(id);
const startedAt = Date.now();
const mode = options.mode ?? "all";
if (mode !== "all" && mode !== "any") throw new Error(`unknown wait mode: ${mode}`);
const deadline = options.timeoutMs && options.timeoutMs > 0 ? startedAt + options.timeoutMs : undefined;
let timedOut = false;
while (!this.waitReady(uniqueIds, mode)) {
if (options.signal?.aborted) throw new Error("subagent wait aborted");
const remainingMs = deadline === undefined ? undefined : deadline - Date.now();
if (remainingMs !== undefined && remainingMs <= 0) {
timedOut = true;
break;
}
await this.nextChange(remainingMs, options.signal).catch((error) => {
if (error instanceof Error && error.message === "subagent wait timed out") timedOut = true;
else throw error;
});
if (timedOut) break;
}
const results = uniqueIds.map((id) => this.result(id));
const pending = uniqueIds
.map((id) => this.status(id))
.filter((status) => !isTerminal(status.state));
return { ids: uniqueIds, mode, ready: this.waitReady(uniqueIds, mode), results, pending, timedOut, elapsedMs: Date.now() - startedAt };
}
async cancel(id: string): Promise<SubagentStatus> {
const child = this.require(id);
if (isTerminal(child.record.status.state)) return cloneStatus(child.record.status);
await child.handle?.cancel();
this.completeWithoutResult(child, "cancelled", "cancelled");
this.pumpQueue();
return cloneStatus(child.record.status);
}
async shutdown(): Promise<void> {
this.clearRecentTerminalTimer();
await Promise.allSettled(
[...this.children.values()].map(async (child) => {
if (!isTerminal(child.record.status.state)) {
await child.handle?.cancel();
this.completeWithoutResult(child, "cancelled", "shutdown");
}
}),
);
this.clearRecentTerminalTimer();
}
private createChild(request: SpawnRequest): SpawnAccepted {
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
if (!prompt) throw new Error("prompt is required");
const id = this.allocateId();
const now = new Date().toISOString();
const status: SubagentStatus = {
id,
label: request.agent ?? `ad-hoc ${id}`,
agent: request.agent,
adHoc: !request.agent,
context: this.resolveContext(request.context),
state: "queued",
cwd: this.cwd,
model: request.model,
thinking: request.thinking,
tools: request.tools ?? "read-only",
startedAt: now,
elapsedMs: 0,
lastEvent: "queued",
lastEventAt: now,
resultAvailable: false,
};
const child: RunningChild = { record: { status }, request: { ...request, prompt, context: status.context, tools: status.tools } };
this.children.set(id, child);
this.emitMilestone(child, "accepted");
this.queue.push(child);
this.pumpQueue();
return toAccepted(cloneStatus(status));
}
private pumpQueue() {
while (this.runningCount() < this.maxConcurrent()) {
const child = this.queue.shift();
if (!child) break;
if (isTerminal(child.record.status.state)) continue;
this.start(child);
}
this.emitChange();
}
private start(child: RunningChild) {
this.setState(child.record.status, "starting", "starting");
this.armStartTimer(child);
setTimeout(() => {
if (isTerminal(child.record.status.state)) return;
void this.runner
.start(child.record.status.id, child.request, this.cwd, this.eventsFor(child.record))
.then((handle) => {
child.handle = handle;
if (isTerminal(child.record.status.state)) void handle.cancel();
})
.catch((error) => {
this.fail(child.record, error instanceof Error ? error.message : String(error));
});
}, 0);
}
private eventsFor(record: ChildRecord): RunnerEvents {
return {
accepted: (childSession) => {
const child = this.findChild(record);
if (child) {
this.clearTimer(child, "startTimer");
this.armRunTimer(child);
}
if (childSession) record.status.childSession = childSession;
this.setState(record.status, "running", "prompt accepted");
},
running: (event) => {
if (!isTerminal(record.status.state)) this.setState(record.status, "running", event);
},
settling: () => {
if (!isTerminal(record.status.state)) this.setState(record.status, "settling", "agent_settled");
},
completed: (result, stopReason) => {
const now = new Date().toISOString();
const child = this.findChild(record);
if (child) this.clearTimers(child);
record.result = result;
record.status.state = "completed";
record.status.completedAt = now;
record.status.lastEvent = "completed";
record.status.lastEventAt = now;
record.status.stopReason = stopReason;
record.status.resultAvailable = true;
if (child) this.emitMilestone(child, "completed");
this.pumpQueue();
},
failed: (error) => this.fail(record, error),
};
}
private fail(record: ChildRecord, error: string) {
if (isTerminal(record.status.state)) return;
const child = this.findChild(record);
if (child) this.clearTimers(child);
const now = new Date().toISOString();
record.status.state = "failed";
record.status.completedAt = now;
record.status.lastEvent = "failed";
record.status.lastEventAt = now;
record.status.error = error;
record.status.stopReason = "failed";
if (child) this.emitMilestone(child, "failed");
this.pumpQueue();
}
private completeWithoutResult(child: RunningChild, state: "cancelled" | "timed_out", reason: string) {
if (isTerminal(child.record.status.state)) return;
this.clearTimers(child);
const now = new Date().toISOString();
child.record.status.state = state;
child.record.status.completedAt = now;
child.record.status.lastEvent = state;
child.record.status.lastEventAt = now;
child.record.status.stopReason = reason;
this.emitMilestone(child, state);
}
private armStartTimer(child: RunningChild) {
const timeout = this.options.timeouts?.startMs ?? DEFAULT_TIMEOUTS.startMs;
if (timeout <= 0) return;
child.startTimer = setTimeout(() => {
this.timeout(child, "start_timeout");
}, timeout);
}
private armRunTimer(child: RunningChild) {
const timeout = this.options.timeouts?.runMs ?? DEFAULT_TIMEOUTS.runMs;
if (timeout <= 0) return;
child.runTimer = setTimeout(() => {
this.timeout(child, "run_timeout");
}, timeout);
}
private timeout(child: RunningChild, reason: string) {
if (isTerminal(child.record.status.state)) return;
void child.handle?.cancel();
this.completeWithoutResult(child, "timed_out", reason);
this.pumpQueue();
}
private clearTimers(child: RunningChild) {
this.clearTimer(child, "startTimer");
this.clearTimer(child, "runTimer");
}
private clearTimer(child: RunningChild, key: "startTimer" | "runTimer") {
const timer = child[key];
if (!timer) return;
clearTimeout(timer);
child[key] = undefined;
}
private findChild(record: ChildRecord): RunningChild | undefined {
return [...this.children.values()].find((child) => child.record === record);
}
private setState(status: SubagentStatus, state: SubagentStatus["state"], event: string) {
if (isTerminal(status.state)) return;
const now = new Date().toISOString();
status.state = state;
status.lastEvent = event;
status.lastEventAt = now;
this.emitChange();
}
private require(id: string): RunningChild {
const child = this.children.get(id);
if (!child) throw new Error(`unknown subagent id: ${id}`);
return child;
}
private resolveContext(context: ContextMode | undefined): ContextMode {
if (context === undefined) return "independent";
if (context !== "independent" && context !== "fork") throw new Error(`unknown context: ${context}`);
return context;
}
private maxConcurrent(): number {
return Math.max(1, this.options.maxConcurrent ?? 3);
}
private runningCount(): number {
return [...this.children.values()].filter((child) => ["starting", "running", "settling"].includes(child.record.status.state)).length;
}
private emitMilestone(child: RunningChild, event: string) {
this.options.onMilestone?.(cloneStatus(child.record.status), event);
this.emitChange();
}
private emitChange() {
this.options.onChange?.(this.list());
for (const waiter of this.waiters) waiter();
this.scheduleRecentTerminalExpiry();
}
private scheduleRecentTerminalExpiry() {
this.clearRecentTerminalTimer();
const ttl = this.options.recentTerminalTtlMs;
if (ttl === undefined || ttl <= 0) return;
const now = Date.now();
const nextExpiryMs = [...this.children.values()]
.map((child) => child.record.status)
.filter((status) => isTerminal(status.state))
.map((status) => Date.parse(status.completedAt ?? status.startedAt))
.filter((completed) => Number.isFinite(completed))
.map((completed) => completed + ttl - now)
.filter((remaining) => remaining > 0)
.sort((a, b) => a - b)[0];
if (nextExpiryMs === undefined) return;
this.recentTerminalTimer = setTimeout(() => this.emitChange(), nextExpiryMs + 1);
}
private clearRecentTerminalTimer() {
if (!this.recentTerminalTimer) return;
clearTimeout(this.recentTerminalTimer);
this.recentTerminalTimer = undefined;
}
private isRecentTerminal(status: SubagentStatus): boolean {
const ttl = this.options.recentTerminalTtlMs;
if (ttl === undefined) return true;
if (ttl <= 0) return false;
const completed = Date.parse(status.completedAt ?? status.startedAt);
if (!Number.isFinite(completed)) return true;
return Date.now() - completed <= ttl;
}
private waitReady(ids: string[], mode: SubagentWaitMode): boolean {
const terminal = (id: string) => isTerminal(this.require(id).record.status.state);
return mode === "all" ? ids.every(terminal) : ids.some(terminal);
}
private nextChange(timeoutMs: number | undefined, signal: AbortSignal | undefined): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
this.waiters.delete(resolveOnce);
if (timer) clearTimeout(timer);
signal?.removeEventListener("abort", abort);
};
const resolveOnce = () => {
cleanup();
resolve();
};
const abort = () => {
cleanup();
reject(new Error("subagent wait aborted"));
};
this.waiters.add(resolveOnce);
signal?.addEventListener("abort", abort, { once: true });
if (timeoutMs !== undefined) {
timer = setTimeout(() => {
cleanup();
reject(new Error("subagent wait timed out"));
}, timeoutMs);
}
});
}
private allocateId(): string {
this.nextChild += 1;
return `sg-${Date.now().toString(36)}-${this.nextChild.toString(36)}`;
}
}
function isTerminal(state: SubagentStatus["state"]): boolean {
return ["completed", "failed", "cancelled", "timed_out", "orphaned"].includes(state);
}

View File

@@ -0,0 +1,103 @@
export type ContextMode = "independent" | "fork";
export type SubagentState =
| "queued"
| "starting"
| "running"
| "settling"
| "completed"
| "failed"
| "cancelled"
| "timed_out"
| "orphaned";
export interface ToolProfile {
activeTools: string[] | null;
}
export interface SpawnRequest {
prompt: string;
context?: ContextMode;
agent?: string;
model?: string;
thinking?: string;
tools?: string;
toolProfile?: ToolProfile;
agentBody?: string;
parentSessionFile?: string;
}
export interface SpawnAccepted {
id: string;
label: string;
context: ContextMode;
tools: string;
state: SubagentState;
hint: string;
}
export interface SubagentStatus {
id: string;
label: string;
agent?: string;
adHoc: boolean;
context: ContextMode;
state: SubagentState;
cwd: string;
model?: string;
thinking?: string;
tools: string;
startedAt: string;
completedAt?: string;
elapsedMs: number;
lastEvent?: string;
lastEventAt?: string;
stopReason?: string;
resultAvailable: boolean;
childSession?: string;
error?: string;
}
export interface SubagentResult {
id: string;
state: SubagentState;
running: boolean;
resultAvailable: boolean;
result?: string;
error?: string;
completedAt?: string;
elapsedMs: number;
}
export type SubagentWaitMode = "all" | "any";
export interface SubagentWaitResult {
ids: string[];
mode: SubagentWaitMode;
ready: boolean;
results: SubagentResult[];
pending: SubagentStatus[];
timedOut: boolean;
elapsedMs: number;
}
export interface ChildRecord {
status: SubagentStatus;
result?: string;
}
export interface RunnerEvents {
accepted(childSession?: string): void;
running(event: string): void;
settling(): void;
completed(result: string, stopReason?: string): void;
failed(error: string): void;
}
export interface ChildHandle {
cancel(): Promise<void>;
}
export interface ChildRunner {
start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle>;
}

View File

@@ -0,0 +1,28 @@
import type { SubagentStatus } from "./types.ts";
export function renderSummary(statuses: SubagentStatus[]): string[] {
const running = statuses.filter((status) => ["starting", "running", "settling"].includes(status.state)).length;
const queued = statuses.filter((status) => status.state === "queued").length;
const terminal = statuses.filter((status) => ["completed", "failed", "cancelled", "timed_out", "orphaned"].includes(status.state)).length;
if (running === 0 && queued === 0 && terminal === 0) return [];
return [`subagents: ${running} running · ${queued} queued · ${terminal} recent`];
}
export function renderInspector(statuses: SubagentStatus[]): string[] {
const lines = renderSummary(statuses);
for (const status of statuses) {
lines.push(
`${status.id} ${status.label} ${status.context} ${status.state} ${Math.round(status.elapsedMs / 1000)}s ${status.model ?? "inherit"} ${status.tools} ${status.lastEvent ?? "none"} result:${status.resultAvailable ? "yes" : "no"}`,
);
}
return lines;
}
export function widget(statuses: SubagentStatus[], expanded: boolean) {
return () => ({
invalidate() {},
render(width: number) {
return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => (line.length > width ? line.slice(0, Math.max(0, width - 1)) : line));
},
});
}

View File

@@ -0,0 +1,169 @@
diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts
--- a/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
+++ b/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
@@ -310,7 +310,7 @@ export class TUI extends Container {
private cursorRow = 0; // Logical cursor row (end of rendered content)
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
- private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
+ private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK !== "0"; // Clear empty rows when content shrinks (default: on)
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
private fullRedrawCount = 0;
diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts
--- a/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
+++ b/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
@@ -1093,11 +1093,11 @@ export class SettingsManager {
}
getClearOnShrink(): boolean {
- // Settings takes precedence, then env var, then default false
+ // Settings takes precedence, then env var, then default true
if (this.settings.terminal?.clearOnShrink !== undefined) {
return this.settings.terminal.clearOnShrink;
}
- return process.env.PI_CLEAR_ON_SHRINK === "1";
+ return process.env.PI_CLEAR_ON_SHRINK !== "0";
}
setClearOnShrink(enabled: boolean): void {
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:41:36.963495957 -0400
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:43:04.876341236 -0400
@@ -210,6 +210,45 @@
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
}
+class FlexSpacerBottomLayout implements Component {
+ private readonly ui: TUI;
+ private readonly flowChildren: Component[];
+ private readonly pinnedChildren: Component[];
+
+ constructor(ui: TUI, flowChildren: Component[], pinnedChildren: Component[]) {
+ this.ui = ui;
+ this.flowChildren = flowChildren;
+ this.pinnedChildren = pinnedChildren;
+ }
+
+ invalidate(): void {
+ for (const child of [...this.flowChildren, ...this.pinnedChildren]) {
+ child.invalidate();
+ }
+ }
+
+ private renderGroup(children: Component[], width: number): string[] {
+ const lines: string[] = [];
+ for (const child of children) {
+ lines.push(...child.render(width));
+ }
+ return lines;
+ }
+
+ render(width: number): string[] {
+ const flowLines = this.renderGroup(this.flowChildren, width);
+ const pinnedLines = this.renderGroup(this.pinnedChildren, width);
+ const terminalRows = this.ui.terminal.rows;
+ const spacerRows = Math.max(0, terminalRows - flowLines.length - pinnedLines.length);
+
+ return [
+ ...flowLines,
+ ...Array.from({ length: spacerRows }, () => ""),
+ ...pinnedLines,
+ ];
+ }
+}
+
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
@@ -335,6 +374,7 @@
private fdPath: string | undefined;
private editorContainer: Container;
private footer: FooterComponent;
+ private footerContainer: Container;
private footerDataProvider: FooterDataProvider;
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
private keybindings: KeybindingsManager;
@@ -477,7 +517,9 @@
this.editorContainer = new Container();
this.editorContainer.addChild(this.editor as Component);
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
+ this.footerContainer = new Container();
this.footer = new FooterComponent(this.session, this.footerDataProvider);
+ this.footerContainer.addChild(this.footer);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
// Load hide thinking block setting
@@ -704,19 +746,25 @@
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
}
- // Add header container as first child. Populate it after applying theme settings.
- // Keep loaded resources before chat so restored session messages never precede them.
- this.ui.addChild(this.headerContainer);
- this.ui.addChild(this.loadedResourcesContainer);
-
- this.ui.addChild(this.chatContainer);
- this.ui.addChild(this.pendingMessagesContainer);
- this.ui.addChild(this.statusContainer);
this.renderWidgets(); // Initialize with default spacer
- this.ui.addChild(this.widgetContainerAbove);
- this.ui.addChild(this.editorContainer);
- this.ui.addChild(this.widgetContainerBelow);
- this.ui.addChild(this.footer);
+ this.ui.addChild(
+ new FlexSpacerBottomLayout(
+ this.ui,
+ [
+ this.headerContainer,
+ this.loadedResourcesContainer,
+ this.chatContainer,
+ ],
+ [
+ this.pendingMessagesContainer,
+ this.statusContainer,
+ this.widgetContainerAbove,
+ this.editorContainer,
+ this.widgetContainerBelow,
+ this.footerContainer,
+ ],
+ ),
+ );
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
@@ -2033,25 +2081,25 @@
| ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })
| undefined,
): void {
- // Dispose existing custom footer
+ // Dispose existing custom footer
if (this.customFooter?.dispose) {
this.customFooter.dispose();
}
- // Remove current footer from UI
+ // Remove current footer from its pinned layout slot.
if (this.customFooter) {
- this.ui.removeChild(this.customFooter);
+ this.footerContainer.removeChild(this.customFooter);
} else {
- this.ui.removeChild(this.footer);
+ this.footerContainer.removeChild(this.footer);
}
if (factory) {
// Create and add custom footer, passing the data provider
this.customFooter = factory(this.ui, theme, this.footerDataProvider);
- this.ui.addChild(this.customFooter);
+ this.footerContainer.addChild(this.customFooter);
} else {
// Restore built-in footer
this.customFooter = undefined;
- this.ui.addChild(this.footer);
+ this.footerContainer.addChild(this.footer);
}
this.ui.requestRender();

View File

@@ -0,0 +1,22 @@
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400
+++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400
@@ -74,8 +74,7 @@
function commandExists(cmd: string): boolean {
try {
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
- // Check for ENOENT error (command not found)
- return result.error === undefined || result.error === null;
+ return (result.error === undefined || result.error === null) && result.status === 0;
} catch {
return false;
}
@@ -88,7 +87,7 @@
// Check our tools directory first
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
- if (existsSync(localPath)) {
+ if (existsSync(localPath) && commandExists(localPath)) {
return localPath;
}

View File

@@ -11,6 +11,12 @@ let
cfg = config.modules.agents.pi; cfg = config.modules.agents.pi;
user = config.user.name; user = config.user.name;
piDir = "${config.users.users.${user}.home}/.pi/agent"; piDir = "${config.users.users.${user}.home}/.pi/agent";
patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [
./patches/pi-flex-spacer.patch
./patches/pi-tool-lookup-validation.patch
];
});
herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation { herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation {
name = "herdr-pi-integration"; name = "herdr-pi-integration";
nativeBuildInputs = [ pkgs.herdr ]; nativeBuildInputs = [ pkgs.herdr ];
@@ -40,6 +46,7 @@ in
home-manager.users.${user} = { home-manager.users.${user} = {
programs.pi-coding-agent = { programs.pi-coding-agent = {
enable = true; enable = true;
package = patchedPi;
settings = { settings = {
defaultProvider = "openai-codex"; defaultProvider = "openai-codex";

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
pi_package=$(nix build --no-link --print-out-paths .#nixosConfigurations.neogaia.config.home-manager.users.alexion.programs.pi-coding-agent.package)
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
cat > "$tmpdir/autocomplete-bottom-alignment.mjs" <<'JS'
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
const piPackage = process.env.PI_PACKAGE;
const tuiModuleUrl = pathToFileURL(
`${piPackage}/lib/node_modules/pi-monorepo/node_modules/@earendil-works/pi-tui/dist/index.js`,
).href;
const settingsModuleUrl = pathToFileURL(
`${piPackage}/lib/node_modules/pi-monorepo/dist/core/settings-manager.js`,
).href;
const { Editor, TUI } = await import(tuiModuleUrl);
const { SettingsManager } = await import(settingsModuleUrl);
if (process.env.PI_CLEAR_ON_SHRINK !== "0") {
assert.equal(SettingsManager.inMemory().getClearOnShrink(), true, "interactive sessions should enable shrink clearing by default");
}
class VirtualTerminal {
constructor(columns, rows) {
this._columns = columns;
this._rows = rows;
this.cursorRow = 0;
this.cursorCol = 0;
this.screen = Array.from({ length: rows }, () => Array(columns).fill(" "));
}
start(onInput, onResize) {
this.inputHandler = onInput;
this.resizeHandler = onResize;
}
async drainInput() {}
stop() {}
write(data) { this.applyOutput(data); }
get columns() { return this._columns; }
get rows() { return this._rows; }
get kittyProtocolActive() { return false; }
moveBy(lines) { this.moveCursor(lines, 0); }
hideCursor() {}
showCursor() {}
clearLine() { this.clearLineFromCursor(); }
setTitle() {}
setProgress() {}
sendInput(data) { this.inputHandler?.(data); }
async waitForRender() {
await new Promise((resolve) => process.nextTick(resolve));
await new Promise((resolve) => setTimeout(resolve, 30));
}
getViewport() {
return this.screen.map((line) => line.join(""));
}
applyOutput(data) {
for (let i = 0; i < data.length; i += 1) {
const char = data[i];
if (char === "\x1b") {
i = this.consumeEscape(data, i);
} else if (char === "\r") {
this.cursorCol = 0;
} else if (char === "\n") {
this.newline();
} else if (char >= " ") {
this.putChar(char);
}
}
}
consumeEscape(data, index) {
const next = data[index + 1];
if (next === "[") {
let end = index + 2;
while (end < data.length && !/[A-Za-z]/.test(data[end])) end += 1;
if (end < data.length) this.applyCsi(data.slice(index + 2, end), data[end]);
return end;
}
if (next === "]" || next === "_") {
const end = data.indexOf("\x07", index + 2);
return end === -1 ? data.length - 1 : end;
}
return index + 1;
}
applyCsi(params, command) {
const cleanParams = params.replace(/^\?/, "");
const values = cleanParams.length === 0 ? [] : cleanParams.split(";").map((value) => Number(value) || 0);
const first = values[0] || 1;
if (command === "A") this.moveCursor(-first, 0);
else if (command === "B") this.moveCursor(first, 0);
else if (command === "G") this.cursorCol = this.clamp(first - 1, 0, this.columns - 1);
else if (command === "H") {
this.cursorRow = this.clamp((values[0] || 1) - 1, 0, this.rows - 1);
this.cursorCol = this.clamp((values[1] || 1) - 1, 0, this.columns - 1);
} else if (command === "K") {
if (values[0] === 2) this.screen[this.cursorRow].fill(" ");
else this.clearLineFromCursor();
} else if (command === "J") {
if (values[0] === 2 || values[0] === 3) this.clearScreen();
else this.clearFromCursor();
}
}
putChar(char) {
this.screen[this.cursorRow][this.cursorCol] = char;
if (this.cursorCol < this.columns - 1) this.cursorCol += 1;
}
newline() {
if (this.cursorRow === this.rows - 1) {
this.screen.shift();
this.screen.push(Array(this.columns).fill(" "));
} else {
this.cursorRow += 1;
}
}
moveCursor(rowDelta, colDelta) {
this.cursorRow = this.clamp(this.cursorRow + rowDelta, 0, this.rows - 1);
this.cursorCol = this.clamp(this.cursorCol + colDelta, 0, this.columns - 1);
}
clearLineFromCursor() {
this.screen[this.cursorRow].fill(" ", this.cursorCol);
}
clearFromCursor() {
this.clearLineFromCursor();
for (let row = this.cursorRow + 1; row < this.rows; row += 1) {
this.screen[row].fill(" ");
}
}
clearScreen() {
for (const line of this.screen) line.fill(" ");
this.cursorRow = 0;
this.cursorCol = 0;
}
clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
}
class Lines {
constructor(lines) { this.lines = lines; }
render() { return this.lines; }
invalidate() {}
}
class BottomLayout {
constructor(tui, flowChildren, pinnedChildren) {
this.tui = tui;
this.flowChildren = flowChildren;
this.pinnedChildren = pinnedChildren;
}
invalidate() {}
render(width) {
const flowLines = this.flowChildren.flatMap((child) => child.render(width));
const pinnedLines = this.pinnedChildren.flatMap((child) => child.render(width));
const spacerRows = Math.max(0, this.tui.terminal.rows - flowLines.length - pinnedLines.length);
return [...flowLines, ...Array.from({ length: spacerRows }, () => ""), ...pinnedLines];
}
}
const plain = (value) => value;
const theme = {
borderColor: plain,
selectList: {
selectedPrefix: plain,
selectedText: plain,
description: plain,
scrollInfo: plain,
noMatch: plain,
},
};
const provider = {
triggerCharacters: ["/"],
async getSuggestions() {
return {
prefix: "/",
items: Array.from({ length: 8 }, (_, index) => ({
value: `cmd${index}`,
label: `/cmd${index}`,
description: `description ${index}`,
})),
};
},
applyCompletion(_lines, _line, _col, item) {
return { lines: [item.value], cursorLine: 0, cursorCol: item.value.length };
},
};
async function waitUntil(predicate, description) {
const deadline = Date.now() + 1000;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
if (predicate()) return;
}
assert.fail(`timed out waiting for ${description}`);
}
const terminal = new VirtualTerminal(50, 10);
const tui = new TUI(terminal);
const editor = new Editor(tui, theme, { autocompleteMaxVisible: 5 });
editor.setAutocompleteProvider(provider);
tui.addChild(new BottomLayout(tui, [new Lines(["chat"])], [editor, new Lines(["footer"])]));
tui.setFocus(editor);
tui.start();
await terminal.waitForRender();
terminal.sendInput("/");
await waitUntil(() => editor.autocompleteState !== null, "autocomplete to open");
await waitUntil(() => tui.previousLines.length === 11, "open autocomplete render");
await terminal.waitForRender();
terminal.sendInput("\x1b");
await waitUntil(() => editor.autocompleteState === null, "autocomplete to close");
await waitUntil(() => tui.previousLines.length === 10, "closed autocomplete render");
await terminal.waitForRender();
const viewport = terminal.getViewport();
tui.stop();
const trimmed = viewport.map((line) => line.trimEnd());
assert.equal(trimmed.at(-1), "footer", `footer should return to the bottom row after autocomplete closes\n${trimmed.map((line, index) => `${index}: ${JSON.stringify(line)}`).join("\n")}`);
assert.equal(trimmed[0], "chat", `chat line should be visible at the top of the bottom-aligned layout\n${trimmed.map((line, index) => `${index}: ${JSON.stringify(line)}`).join("\n")}`);
JS
PI_PACKAGE="$pi_package" nix shell nixpkgs#nodejs_22 -c node "$tmpdir/autocomplete-bottom-alignment.mjs"