feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export type SubagentActivityPhase = "starting" | "active" | "waiting" | "done";
|
||||
export type SubagentActivityScope = "agent" | "turn" | "provider" | "streaming" | "tool";
|
||||
|
||||
export type SubagentActivityEvent =
|
||||
| "session_start"
|
||||
| "input"
|
||||
| "before_agent_start"
|
||||
| "agent_start"
|
||||
| "agent_end"
|
||||
| "turn_start"
|
||||
| "turn_end"
|
||||
| "before_provider_request"
|
||||
| "after_provider_response"
|
||||
| "message_update"
|
||||
| "tool_execution_start"
|
||||
| "tool_call"
|
||||
| "tool_execution_update"
|
||||
| "tool_result"
|
||||
| "tool_execution_end"
|
||||
| "ask_question"
|
||||
| "session_shutdown";
|
||||
|
||||
export interface SubagentActivityState {
|
||||
version: 1;
|
||||
runningChildId: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
sequence: number;
|
||||
latestEvent: SubagentActivityEvent;
|
||||
phase: SubagentActivityPhase;
|
||||
agentActive: boolean;
|
||||
turnActive: boolean;
|
||||
providerActive: boolean;
|
||||
toolActive: boolean;
|
||||
activeScope?: SubagentActivityScope;
|
||||
activeSince?: number;
|
||||
waitingSince?: number;
|
||||
turnIndex?: number;
|
||||
messageEventType?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolStartedAt?: number;
|
||||
toolEndedAt?: number;
|
||||
}
|
||||
|
||||
export type ActivityReadResult =
|
||||
| { ok: true; activity: SubagentActivityState }
|
||||
| { ok: false; reason: "missing" | "invalid" | "wrong-id"; error?: string };
|
||||
|
||||
export type SubagentShutdownReason = "quit" | "reload" | "new" | "resume" | "fork";
|
||||
|
||||
export interface SubagentActivityRecorder {
|
||||
sessionStart(): void;
|
||||
input(): void;
|
||||
beforeAgentStart(): void;
|
||||
agentStart(): void;
|
||||
agentEndWaiting(): void;
|
||||
agentEndDone(): void;
|
||||
turnStart(turnIndex?: number): void;
|
||||
turnEnd(turnIndex?: number): void;
|
||||
beforeProviderRequest(): void;
|
||||
afterProviderResponse(): void;
|
||||
messageUpdate(messageEventType?: string): void;
|
||||
toolExecutionStart(toolCallId?: string, toolName?: string): void;
|
||||
toolCall(toolCallId?: string, toolName?: string): void;
|
||||
toolExecutionUpdate(toolCallId?: string, toolName?: string): void;
|
||||
toolResult(toolCallId?: string, toolName?: string): void;
|
||||
toolExecutionEnd(toolCallId?: string, toolName?: string): void;
|
||||
askQuestion(): void;
|
||||
sessionShutdown(reason: SubagentShutdownReason): void;
|
||||
}
|
||||
|
||||
const ACTIVITY_UPDATE_THROTTLE_MS = 500;
|
||||
const MAX_WRITE_FAILURES = 3;
|
||||
const KNOWN_PHASES = new Set<SubagentActivityPhase>(["starting", "active", "waiting", "done"]);
|
||||
const KNOWN_SCOPES = new Set<SubagentActivityScope>(["agent", "turn", "provider", "streaming", "tool"]);
|
||||
const KNOWN_EVENTS = new Set<SubagentActivityEvent>([
|
||||
"session_start",
|
||||
"input",
|
||||
"before_agent_start",
|
||||
"agent_start",
|
||||
"agent_end",
|
||||
"turn_start",
|
||||
"turn_end",
|
||||
"before_provider_request",
|
||||
"after_provider_response",
|
||||
"message_update",
|
||||
"tool_execution_start",
|
||||
"tool_call",
|
||||
"tool_execution_update",
|
||||
"tool_result",
|
||||
"tool_execution_end",
|
||||
"ask_question",
|
||||
"session_shutdown",
|
||||
]);
|
||||
const MAX_ACTIVITY_STRING_LENGTH = 200;
|
||||
|
||||
export function getSubagentActivityFile(artifactDir: string, runningChildId: string): string {
|
||||
return join(artifactDir, "subagent-activity", `${runningChildId}.json`);
|
||||
}
|
||||
|
||||
function requireObject(value: unknown): Record<string, unknown> | null {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function validateFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
return Number.isFinite(object[fieldName]) ? null : `${fieldName} must be finite`;
|
||||
}
|
||||
|
||||
function validateOptionalFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
const value = object[fieldName];
|
||||
return value == null || Number.isFinite(value) ? null : `${fieldName} must be finite when present`;
|
||||
}
|
||||
|
||||
function validateInteger(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
return Number.isInteger(object[fieldName]) ? null : `${fieldName} must be an integer`;
|
||||
}
|
||||
|
||||
function validateOptionalInteger(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
const value = object[fieldName];
|
||||
return value == null || Number.isInteger(value) ? null : `${fieldName} must be an integer when present`;
|
||||
}
|
||||
|
||||
function validateBoolean(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
return typeof object[fieldName] === "boolean" ? null : `${fieldName} must be a boolean`;
|
||||
}
|
||||
|
||||
function validateOptionalActivityString(object: Record<string, unknown>, fieldName: string): string | null {
|
||||
const value = object[fieldName];
|
||||
if (value == null) return null;
|
||||
if (typeof value !== "string") return `${fieldName} must be a string when present`;
|
||||
if (/\r|\n/.test(value)) return `${fieldName} must not contain newlines`;
|
||||
return value.length <= MAX_ACTIVITY_STRING_LENGTH ? null : `${fieldName} is too long`;
|
||||
}
|
||||
|
||||
function invalidActivity(error: string): ActivityReadResult {
|
||||
return { ok: false, reason: "invalid", error };
|
||||
}
|
||||
|
||||
function validateActivity(value: unknown, expectedRunningChildId: string): ActivityReadResult {
|
||||
const object = requireObject(value);
|
||||
if (!object) return invalidActivity("activity must be an object");
|
||||
if (object.version !== 1) return invalidActivity("unsupported activity version");
|
||||
if (typeof object.runningChildId !== "string") return invalidActivity("runningChildId must be a string");
|
||||
if (object.runningChildId !== expectedRunningChildId) return { ok: false, reason: "wrong-id" };
|
||||
if (typeof object.latestEvent !== "string" || !KNOWN_EVENTS.has(object.latestEvent as SubagentActivityEvent)) {
|
||||
return invalidActivity("unknown latestEvent");
|
||||
}
|
||||
if (typeof object.phase !== "string" || !KNOWN_PHASES.has(object.phase as SubagentActivityPhase)) {
|
||||
return invalidActivity("unknown activity phase");
|
||||
}
|
||||
if (
|
||||
object.activeScope != null &&
|
||||
(typeof object.activeScope !== "string" || !KNOWN_SCOPES.has(object.activeScope as SubagentActivityScope))
|
||||
) {
|
||||
return invalidActivity("unknown activeScope");
|
||||
}
|
||||
|
||||
const validationError = [
|
||||
validateFiniteNumber(object, "createdAt"),
|
||||
validateFiniteNumber(object, "updatedAt"),
|
||||
validateInteger(object, "sequence"),
|
||||
validateBoolean(object, "agentActive"),
|
||||
validateBoolean(object, "turnActive"),
|
||||
validateBoolean(object, "providerActive"),
|
||||
validateBoolean(object, "toolActive"),
|
||||
validateOptionalFiniteNumber(object, "activeSince"),
|
||||
validateOptionalFiniteNumber(object, "waitingSince"),
|
||||
validateOptionalInteger(object, "turnIndex"),
|
||||
validateOptionalFiniteNumber(object, "toolStartedAt"),
|
||||
validateOptionalFiniteNumber(object, "toolEndedAt"),
|
||||
validateOptionalActivityString(object, "messageEventType"),
|
||||
validateOptionalActivityString(object, "toolCallId"),
|
||||
validateOptionalActivityString(object, "toolName"),
|
||||
].find((error) => error != null);
|
||||
if (validationError) return invalidActivity(validationError);
|
||||
|
||||
return { ok: true, activity: object as unknown as SubagentActivityState };
|
||||
}
|
||||
|
||||
export function readSubagentActivityFile(
|
||||
activityFile: string,
|
||||
expectedRunningChildId: string,
|
||||
): ActivityReadResult {
|
||||
if (!existsSync(activityFile)) return { ok: false, reason: "missing" };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(activityFile, "utf8"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { ok: false, reason: "invalid", error: message };
|
||||
}
|
||||
|
||||
return validateActivity(parsed, expectedRunningChildId);
|
||||
}
|
||||
|
||||
export function writeSubagentActivityFile(activityFile: string, activity: SubagentActivityState): void {
|
||||
const dir = dirname(activityFile);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tempFile = join(dir, `${activity.runningChildId}.json.${process.pid}.${activity.sequence}.tmp`);
|
||||
|
||||
try {
|
||||
writeFileSync(tempFile, `${JSON.stringify(activity)}\n`, "utf8");
|
||||
renameSync(tempFile, activityFile);
|
||||
} catch (error) {
|
||||
try {
|
||||
unlinkSync(tempFile);
|
||||
} catch (cleanupError) {
|
||||
// Temp cleanup is best effort; preserve the original write/rename failure
|
||||
void cleanupError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createNoopRecorder(): SubagentActivityRecorder {
|
||||
return {
|
||||
sessionStart() {},
|
||||
input() {},
|
||||
beforeAgentStart() {},
|
||||
agentStart() {},
|
||||
agentEndWaiting() {},
|
||||
agentEndDone() {},
|
||||
turnStart() {},
|
||||
turnEnd() {},
|
||||
beforeProviderRequest() {},
|
||||
afterProviderResponse() {},
|
||||
messageUpdate() {},
|
||||
toolExecutionStart() {},
|
||||
toolCall() {},
|
||||
toolExecutionUpdate() {},
|
||||
toolResult() {},
|
||||
toolExecutionEnd() {},
|
||||
askQuestion() {},
|
||||
sessionShutdown() {},
|
||||
};
|
||||
}
|
||||
|
||||
function clearActiveState(activity: SubagentActivityState): void {
|
||||
activity.agentActive = false;
|
||||
activity.turnActive = false;
|
||||
activity.providerActive = false;
|
||||
activity.toolActive = false;
|
||||
delete activity.activeScope;
|
||||
delete activity.activeSince;
|
||||
}
|
||||
|
||||
function refreshActiveScope(activity: SubagentActivityState): void {
|
||||
if (activity.toolActive) {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = "tool";
|
||||
return;
|
||||
}
|
||||
if (activity.providerActive) {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = "provider";
|
||||
return;
|
||||
}
|
||||
if (activity.turnActive) {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = "turn";
|
||||
return;
|
||||
}
|
||||
if (activity.agentActive) {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = "agent";
|
||||
return;
|
||||
}
|
||||
delete activity.activeScope;
|
||||
delete activity.activeSince;
|
||||
}
|
||||
|
||||
function markActive(
|
||||
activity: SubagentActivityState,
|
||||
scope: SubagentActivityScope,
|
||||
now: number,
|
||||
resetActiveSince = false,
|
||||
): void {
|
||||
activity.phase = "active";
|
||||
activity.activeScope = scope;
|
||||
if (activity.activeSince == null || resetActiveSince) activity.activeSince = now;
|
||||
delete activity.waitingSince;
|
||||
}
|
||||
|
||||
export function createSubagentActivityRecorder(params: {
|
||||
runningChildId?: string;
|
||||
activityFile?: string;
|
||||
now?: () => number;
|
||||
}): SubagentActivityRecorder {
|
||||
const candidateRunningChildId = params.runningChildId?.trim();
|
||||
const candidateActivityFile = params.activityFile?.trim();
|
||||
if (!candidateRunningChildId || !candidateActivityFile) return createNoopRecorder();
|
||||
const runningChildId: string = candidateRunningChildId;
|
||||
const activityFile: string = candidateActivityFile;
|
||||
|
||||
const now = params.now ?? (() => Date.now());
|
||||
const createdAt = now();
|
||||
const activity: SubagentActivityState = {
|
||||
version: 1,
|
||||
runningChildId,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
sequence: 0,
|
||||
latestEvent: "session_start",
|
||||
phase: "starting",
|
||||
agentActive: false,
|
||||
turnActive: false,
|
||||
providerActive: false,
|
||||
toolActive: false,
|
||||
};
|
||||
|
||||
let disabled = false;
|
||||
let failureCount = 0;
|
||||
let lastFlushAt = 0;
|
||||
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearPendingFlush(): void {
|
||||
if (!pendingFlush) return;
|
||||
clearTimeout(pendingFlush);
|
||||
pendingFlush = null;
|
||||
}
|
||||
|
||||
function disable(): void {
|
||||
disabled = true;
|
||||
clearPendingFlush();
|
||||
}
|
||||
|
||||
function flushNow(): void {
|
||||
if (disabled) return;
|
||||
try {
|
||||
writeSubagentActivityFile(activityFile, activity);
|
||||
lastFlushAt = now();
|
||||
failureCount = 0;
|
||||
} catch {
|
||||
failureCount += 1;
|
||||
if (failureCount >= MAX_WRITE_FAILURES) disable();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (disabled || pendingFlush) return;
|
||||
|
||||
const remainingMs = Math.max(0, ACTIVITY_UPDATE_THROTTLE_MS - (now() - lastFlushAt));
|
||||
if (remainingMs === 0) {
|
||||
flushNow();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingFlush = setTimeout(() => {
|
||||
pendingFlush = null;
|
||||
flushNow();
|
||||
}, remainingMs);
|
||||
}
|
||||
|
||||
function record(
|
||||
latestEvent: SubagentActivityEvent,
|
||||
update: (current: SubagentActivityState, now: number) => void,
|
||||
flush: "immediate" | "throttled",
|
||||
): void {
|
||||
if (disabled) return;
|
||||
if (flush === "immediate") clearPendingFlush();
|
||||
|
||||
const observedAt = now();
|
||||
activity.latestEvent = latestEvent;
|
||||
activity.updatedAt = observedAt;
|
||||
activity.sequence += 1;
|
||||
update(activity, observedAt);
|
||||
|
||||
if (flush === "immediate") flushNow();
|
||||
else scheduleFlush();
|
||||
}
|
||||
|
||||
function markDone(latestEvent: SubagentActivityEvent): void {
|
||||
record(latestEvent, (current) => {
|
||||
current.phase = "done";
|
||||
clearActiveState(current);
|
||||
delete current.waitingSince;
|
||||
}, "immediate");
|
||||
disable();
|
||||
}
|
||||
|
||||
return {
|
||||
sessionStart() {
|
||||
record("session_start", (current) => {
|
||||
current.phase = "starting";
|
||||
clearActiveState(current);
|
||||
delete current.waitingSince;
|
||||
}, "immediate");
|
||||
},
|
||||
input() {
|
||||
record("input", () => {}, "immediate");
|
||||
},
|
||||
beforeAgentStart() {
|
||||
record("before_agent_start", (current, observedAt) => {
|
||||
current.agentActive = true;
|
||||
markActive(current, "agent", observedAt);
|
||||
}, "immediate");
|
||||
},
|
||||
agentStart() {
|
||||
record("agent_start", (current, observedAt) => {
|
||||
current.agentActive = true;
|
||||
markActive(current, "agent", observedAt);
|
||||
}, "immediate");
|
||||
},
|
||||
agentEndWaiting() {
|
||||
record("agent_end", (current, observedAt) => {
|
||||
clearActiveState(current);
|
||||
current.phase = "waiting";
|
||||
current.waitingSince = observedAt;
|
||||
}, "immediate");
|
||||
},
|
||||
agentEndDone() {
|
||||
markDone("agent_end");
|
||||
},
|
||||
turnStart(turnIndex) {
|
||||
record("turn_start", (current, observedAt) => {
|
||||
current.agentActive = true;
|
||||
current.turnActive = true;
|
||||
if (turnIndex != null) current.turnIndex = turnIndex;
|
||||
markActive(current, current.toolActive || current.providerActive ? current.activeScope ?? "turn" : "turn", observedAt);
|
||||
}, "immediate");
|
||||
},
|
||||
turnEnd(turnIndex) {
|
||||
record("turn_end", (current) => {
|
||||
current.turnActive = false;
|
||||
current.providerActive = false;
|
||||
current.toolActive = false;
|
||||
if (turnIndex != null) current.turnIndex = turnIndex;
|
||||
refreshActiveScope(current);
|
||||
}, "immediate");
|
||||
},
|
||||
beforeProviderRequest() {
|
||||
record("before_provider_request", (current, observedAt) => {
|
||||
current.providerActive = true;
|
||||
markActive(current, "provider", observedAt, true);
|
||||
}, "immediate");
|
||||
},
|
||||
afterProviderResponse() {
|
||||
record("after_provider_response", (current) => {
|
||||
current.providerActive = false;
|
||||
refreshActiveScope(current);
|
||||
}, "immediate");
|
||||
},
|
||||
messageUpdate(messageEventType) {
|
||||
record("message_update", (current, observedAt) => {
|
||||
current.agentActive = true;
|
||||
current.turnActive = true;
|
||||
current.messageEventType = messageEventType;
|
||||
if (!current.toolActive) markActive(current, "streaming", observedAt);
|
||||
}, "throttled");
|
||||
},
|
||||
toolExecutionStart(toolCallId, toolName) {
|
||||
record("tool_execution_start", (current, observedAt) => {
|
||||
current.toolActive = true;
|
||||
current.toolCallId = toolCallId;
|
||||
current.toolName = toolName;
|
||||
current.toolStartedAt = observedAt;
|
||||
markActive(current, "tool", observedAt, true);
|
||||
}, "immediate");
|
||||
},
|
||||
toolCall(toolCallId, toolName) {
|
||||
record("tool_call", (current, observedAt) => {
|
||||
current.toolActive = true;
|
||||
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||
current.toolName = toolName ?? current.toolName;
|
||||
markActive(current, "tool", observedAt);
|
||||
}, "immediate");
|
||||
},
|
||||
toolExecutionUpdate(toolCallId, toolName) {
|
||||
record("tool_execution_update", (current, observedAt) => {
|
||||
current.toolActive = true;
|
||||
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||
current.toolName = toolName ?? current.toolName;
|
||||
markActive(current, "tool", observedAt);
|
||||
}, "throttled");
|
||||
},
|
||||
toolResult(toolCallId, toolName) {
|
||||
record("tool_result", (current) => {
|
||||
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||
current.toolName = toolName ?? current.toolName;
|
||||
refreshActiveScope(current);
|
||||
}, "immediate");
|
||||
},
|
||||
toolExecutionEnd(toolCallId, toolName) {
|
||||
record("tool_execution_end", (current, observedAt) => {
|
||||
current.toolActive = false;
|
||||
current.toolCallId = toolCallId ?? current.toolCallId;
|
||||
current.toolName = toolName ?? current.toolName;
|
||||
current.toolEndedAt = observedAt;
|
||||
refreshActiveScope(current);
|
||||
}, "immediate");
|
||||
},
|
||||
askQuestion() {
|
||||
// The subagent paused to ask the orchestrator a question. Park it in the
|
||||
// "waiting" phase (do NOT disable the recorder) so the status widget shows
|
||||
// it as waiting and recording resumes when the answer arrives.
|
||||
record("ask_question", (current, observedAt) => {
|
||||
clearActiveState(current);
|
||||
current.phase = "waiting";
|
||||
current.waitingSince = observedAt;
|
||||
}, "immediate");
|
||||
},
|
||||
sessionShutdown(reason) {
|
||||
if (reason === "quit") markDone("session_shutdown");
|
||||
else disable();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/** Herdr surface backend. Subagents run in unfocused tabs in the current workspace. */
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const commandAvailability = new Map<string, boolean>();
|
||||
const tabByPane = new Map<string, string>();
|
||||
|
||||
interface HerdrTabCreated {
|
||||
result?: {
|
||||
root_pane?: { pane_id?: unknown };
|
||||
tab?: { tab_id?: unknown };
|
||||
};
|
||||
}
|
||||
|
||||
interface HerdrPaneCreated {
|
||||
result?: {
|
||||
pane?: { pane_id?: unknown };
|
||||
};
|
||||
}
|
||||
|
||||
function hasCommand(command: string): boolean {
|
||||
const cached = commandAvailability.get(command);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let available = false;
|
||||
try {
|
||||
execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
|
||||
available = true;
|
||||
} catch {
|
||||
available = false;
|
||||
}
|
||||
|
||||
commandAvailability.set(command, available);
|
||||
return available;
|
||||
}
|
||||
|
||||
export function isAvailable(): boolean {
|
||||
return (
|
||||
process.env.HERDR_ENV === "1" &&
|
||||
!!process.env.HERDR_WORKSPACE_ID &&
|
||||
!!process.env.HERDR_PANE_ID &&
|
||||
hasCommand("herdr")
|
||||
);
|
||||
}
|
||||
|
||||
export function setupHint(): string {
|
||||
return "Start pi inside a Herdr-managed pane with the `herdr` CLI on PATH.";
|
||||
}
|
||||
|
||||
function requireHerdr(): void {
|
||||
if (!isAvailable()) {
|
||||
throw new Error(`Herdr is required for this surface. ${setupHint()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson<T>(command: string, output: string): T {
|
||||
try {
|
||||
return JSON.parse(output) as T;
|
||||
} catch {
|
||||
throw new Error(`Unexpected Herdr ${command} output: ${output.trim() || "(empty)"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireId(value: unknown, command: string, field: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Herdr ${command} did not return ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseCreatedTab(output: string): { paneId: string; tabId: string } {
|
||||
const response = parseJson<HerdrTabCreated>("tab create", output);
|
||||
return {
|
||||
paneId: requireId(response.result?.root_pane?.pane_id, "tab create", "result.root_pane.pane_id"),
|
||||
tabId: requireId(response.result?.tab?.tab_id, "tab create", "result.tab.tab_id"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCreatedPane(output: string): string {
|
||||
const response = parseJson<HerdrPaneCreated>("pane split", output);
|
||||
return requireId(response.result?.pane?.pane_id, "pane split", "result.pane.pane_id");
|
||||
}
|
||||
|
||||
export function createSurface(name: string): string {
|
||||
requireHerdr();
|
||||
const workspace = process.env.HERDR_WORKSPACE_ID!;
|
||||
const output = execFileSync(
|
||||
"herdr",
|
||||
[
|
||||
"tab",
|
||||
"create",
|
||||
"--workspace",
|
||||
workspace,
|
||||
"--cwd",
|
||||
process.cwd(),
|
||||
"--label",
|
||||
name,
|
||||
"--no-focus",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const { paneId, tabId } = parseCreatedTab(output);
|
||||
tabByPane.set(paneId, tabId);
|
||||
return paneId;
|
||||
}
|
||||
|
||||
export function createSurfaceSplit(
|
||||
name: string,
|
||||
direction: "left" | "right" | "up" | "down",
|
||||
fromSurface?: string,
|
||||
): string {
|
||||
requireHerdr();
|
||||
if (direction === "left" || direction === "up") {
|
||||
throw new Error(`Herdr pane splits only support right or down, not ${direction}`);
|
||||
}
|
||||
|
||||
const source = fromSurface ?? process.env.HERDR_PANE_ID!;
|
||||
const output = execFileSync(
|
||||
"herdr",
|
||||
[
|
||||
"pane",
|
||||
"split",
|
||||
"--pane",
|
||||
source,
|
||||
"--direction",
|
||||
direction,
|
||||
"--cwd",
|
||||
process.cwd(),
|
||||
"--no-focus",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const paneId = parseCreatedPane(output);
|
||||
|
||||
try {
|
||||
execFileSync("herdr", ["pane", "rename", paneId, name], { encoding: "utf8" });
|
||||
} catch {
|
||||
// A cosmetic label must not break a successful split.
|
||||
}
|
||||
|
||||
return paneId;
|
||||
}
|
||||
|
||||
export function sendCommand(surface: string, command: string): void {
|
||||
requireHerdr();
|
||||
execFileSync("herdr", ["pane", "run", surface, command], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
export function readScreen(surface: string, lines = 50): string {
|
||||
requireHerdr();
|
||||
return execFileSync(
|
||||
"herdr",
|
||||
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||
requireHerdr();
|
||||
const { stdout } = await execFileAsync(
|
||||
"herdr",
|
||||
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export function closeSurface(surface: string): void {
|
||||
requireHerdr();
|
||||
const tabId = tabByPane.get(surface);
|
||||
if (tabId) {
|
||||
execFileSync("herdr", ["tab", "close", tabId], { encoding: "utf8" });
|
||||
tabByPane.delete(surface);
|
||||
return;
|
||||
}
|
||||
execFileSync("herdr", ["pane", "close", surface], { encoding: "utf8" });
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Orca surface backend. Subagents run in unfocused terminal tabs in the
|
||||
* current Orca worktree.
|
||||
*
|
||||
* Detection relies on ORCA_TERMINAL_HANDLE, which Orca exports inside its
|
||||
* managed terminals. Inside those terminals `orca` resolves to the Orca CLI
|
||||
* shim; outside them bare `orca` on Linux is usually the GNOME screen reader,
|
||||
* so resolution explicitly rejects /usr/bin/orca.
|
||||
*/
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** GNOME screen reader path — never a valid Orca CLI. */
|
||||
const GNOME_SCREEN_READER = "/usr/bin/orca";
|
||||
|
||||
let cachedCli: string | null | undefined;
|
||||
|
||||
/** Terminal handles created as whole tabs (vs splits), mapped for tab close. */
|
||||
const tabByHandle = new Map<string, string>();
|
||||
|
||||
interface OrcaResponse {
|
||||
ok?: boolean;
|
||||
error?: { message?: unknown };
|
||||
}
|
||||
|
||||
interface OrcaTerminalCreated extends OrcaResponse {
|
||||
result?: {
|
||||
terminal?: { handle?: unknown; tabId?: unknown };
|
||||
};
|
||||
}
|
||||
|
||||
interface OrcaSplitCreated extends OrcaResponse {
|
||||
result?: {
|
||||
split?: { handle?: unknown };
|
||||
};
|
||||
}
|
||||
|
||||
interface OrcaTerminalRead extends OrcaResponse {
|
||||
result?: {
|
||||
terminal?: { tail?: unknown; status?: unknown };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker echoed by the startup command once the new terminal has cd'd into
|
||||
* the target directory and is about to exec bash. Written with a quote split
|
||||
* (`__PI_SURFACE_''READY__`) so the typed command line on screen never
|
||||
* matches, only the echoed output does.
|
||||
*/
|
||||
const READY_MARKER = "__PI_SURFACE_READY__";
|
||||
|
||||
function resolveCli(): string | null {
|
||||
if (cachedCli !== undefined) return cachedCli;
|
||||
|
||||
const override = process.env.ORCA_CLI_COMMAND?.trim();
|
||||
if (override) {
|
||||
cachedCli = override;
|
||||
return cachedCli;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = execFileSync("sh", ["-c", "command -v orca"], { encoding: "utf8" }).trim();
|
||||
cachedCli = resolved && resolved !== GNOME_SCREEN_READER ? "orca" : null;
|
||||
} catch {
|
||||
cachedCli = null;
|
||||
}
|
||||
return cachedCli;
|
||||
}
|
||||
|
||||
export function isAvailable(): boolean {
|
||||
return !!process.env.ORCA_TERMINAL_HANDLE && resolveCli() !== null;
|
||||
}
|
||||
|
||||
export function setupHint(): string {
|
||||
return "Start pi inside an Orca-managed terminal with the `orca` CLI on PATH.";
|
||||
}
|
||||
|
||||
function requireOrca(): string {
|
||||
const cli = resolveCli();
|
||||
if (!isAvailable() || !cli) {
|
||||
throw new Error(`Orca is required for this surface. ${setupHint()}`);
|
||||
}
|
||||
return cli;
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
/** Startup command that lands the new terminal in pi's cwd with a bash shell. */
|
||||
function startupCommand(): string {
|
||||
return `cd ${shellEscape(process.cwd())} && echo __PI_SURFACE_''READY__ && exec bash`;
|
||||
}
|
||||
|
||||
function sleepSync(seconds: number): void {
|
||||
try {
|
||||
execFileSync("sleep", [String(seconds)], { stdio: "ignore" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Probe marker proving the shell reads and executes typed input. */
|
||||
const SHELL_UP_MARKER = "__PI_SHELL_UP__";
|
||||
|
||||
/**
|
||||
* Block until the new terminal's shell is genuinely interactive.
|
||||
*
|
||||
* Two races exist at startup: input typed before the initial shell execs bash
|
||||
* is swallowed with it, and input typed while bash itself initializes can be
|
||||
* flushed (observed as stray kitty-keyboard residue like `0u` at the prompt).
|
||||
* So first wait for the startup command's ready marker, then repeatedly send a
|
||||
* probe echo until its output appears — an executed probe also clears any
|
||||
* residue from the readline buffer. Falls through after the deadline as
|
||||
* best-effort.
|
||||
*/
|
||||
function waitForSurfaceReady(surface: string, timeoutMs = 15_000): void {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (readScreen(surface, 40).includes(READY_MARKER)) break;
|
||||
} catch {}
|
||||
sleepSync(0.25);
|
||||
}
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
sendCommand(surface, "echo __PI_SHELL_''UP__");
|
||||
} catch {
|
||||
sleepSync(0.25);
|
||||
continue;
|
||||
}
|
||||
const probeDeadline = Math.min(Date.now() + 2_000, deadline);
|
||||
while (Date.now() < probeDeadline) {
|
||||
sleepSync(0.25);
|
||||
try {
|
||||
if (readScreen(surface, 40).includes(SHELL_UP_MARKER)) return;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseResponse<T extends OrcaResponse>(command: string, output: string): T {
|
||||
let response: T;
|
||||
try {
|
||||
response = JSON.parse(output) as T;
|
||||
} catch {
|
||||
throw new Error(`Unexpected Orca ${command} output: ${output.trim() || "(empty)"}`);
|
||||
}
|
||||
if (response.ok === false) {
|
||||
const message =
|
||||
typeof response.error?.message === "string" ? response.error.message : "unknown error";
|
||||
throw new Error(`Orca ${command} failed: ${message}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function requireId(value: unknown, command: string, field: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Orca ${command} did not return ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseCreatedTerminal(output: string): { handle: string; tabId: string } {
|
||||
const response = parseResponse<OrcaTerminalCreated>("terminal create", output);
|
||||
return {
|
||||
handle: requireId(response.result?.terminal?.handle, "terminal create", "result.terminal.handle"),
|
||||
tabId: requireId(response.result?.terminal?.tabId, "terminal create", "result.terminal.tabId"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCreatedSplit(output: string): string {
|
||||
const response = parseResponse<OrcaSplitCreated>("terminal split", output);
|
||||
return requireId(response.result?.split?.handle, "terminal split", "result.split.handle");
|
||||
}
|
||||
|
||||
export function parseReadTail(output: string): string {
|
||||
const response = parseResponse<OrcaTerminalRead>("terminal read", output);
|
||||
const status = response.result?.terminal?.status;
|
||||
if (typeof status === "string" && status !== "running") {
|
||||
throw new Error(`Orca terminal read: terminal is not running (status: ${status})`);
|
||||
}
|
||||
const tail = response.result?.terminal?.tail;
|
||||
if (!Array.isArray(tail)) {
|
||||
throw new Error("Orca terminal read did not return result.terminal.tail");
|
||||
}
|
||||
return tail.filter((line): line is string => typeof line === "string").join("\n");
|
||||
}
|
||||
|
||||
export function createSurface(name: string): string {
|
||||
const cli = requireOrca();
|
||||
// Target the enclosing terminal's worktree explicitly: pi's cwd (which the
|
||||
// CLI would otherwise resolve against) may live outside any Orca worktree.
|
||||
const worktreeId = process.env.ORCA_WORKTREE_ID;
|
||||
const output = execFileSync(
|
||||
cli,
|
||||
[
|
||||
"terminal",
|
||||
"create",
|
||||
...(worktreeId ? ["--worktree", `id:${worktreeId}`] : []),
|
||||
"--title",
|
||||
name,
|
||||
"--command",
|
||||
startupCommand(),
|
||||
"--json",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const { handle, tabId } = parseCreatedTerminal(output);
|
||||
tabByHandle.set(handle, tabId);
|
||||
waitForSurfaceReady(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function createSurfaceSplit(
|
||||
name: string,
|
||||
direction: "left" | "right" | "up" | "down",
|
||||
fromSurface?: string,
|
||||
): string {
|
||||
void name;
|
||||
const cli = requireOrca();
|
||||
if (direction === "left" || direction === "up") {
|
||||
throw new Error(`Orca terminal splits only support right or down, not ${direction}`);
|
||||
}
|
||||
|
||||
const source = fromSurface ?? process.env.ORCA_TERMINAL_HANDLE!;
|
||||
const output = execFileSync(
|
||||
cli,
|
||||
[
|
||||
"terminal",
|
||||
"split",
|
||||
"--terminal",
|
||||
source,
|
||||
"--direction",
|
||||
direction === "right" ? "horizontal" : "vertical",
|
||||
"--command",
|
||||
startupCommand(),
|
||||
"--json",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const handle = parseCreatedSplit(output);
|
||||
waitForSurfaceReady(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function sendCommand(surface: string, command: string): void {
|
||||
const cli = requireOrca();
|
||||
const output = execFileSync(
|
||||
cli,
|
||||
["terminal", "send", "--terminal", surface, "--text", command, "--enter", "--json"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
parseResponse("terminal send", output);
|
||||
}
|
||||
|
||||
function readScreenArgs(surface: string, lines: number): string[] {
|
||||
return [
|
||||
"terminal",
|
||||
"read",
|
||||
"--terminal",
|
||||
surface,
|
||||
"--screen",
|
||||
"--limit",
|
||||
String(Math.max(1, lines)),
|
||||
"--json",
|
||||
];
|
||||
}
|
||||
|
||||
export function readScreen(surface: string, lines = 50): string {
|
||||
const cli = requireOrca();
|
||||
const output = execFileSync(cli, readScreenArgs(surface, lines), { encoding: "utf8" });
|
||||
return parseReadTail(output);
|
||||
}
|
||||
|
||||
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||
const cli = requireOrca();
|
||||
const { stdout } = await execFileAsync(cli, readScreenArgs(surface, lines), {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return parseReadTail(stdout);
|
||||
}
|
||||
|
||||
export function closeSurface(surface: string): void {
|
||||
const cli = requireOrca();
|
||||
const args = ["terminal", "close", "--terminal", surface];
|
||||
if (tabByHandle.has(surface)) args.push("--tab");
|
||||
args.push("--json");
|
||||
const output = execFileSync(cli, args, { encoding: "utf8" });
|
||||
parseResponse("terminal close", output);
|
||||
tabByHandle.delete(surface);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "pi-auto-exit",
|
||||
"version": "1.0.0",
|
||||
"description": "Auto-exit plugin for pi-spawned Claude sessions"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/on-stop.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop hook for pi-spawned Claude sessions.
|
||||
# Writes a sentinel file when Claude completes autonomously (no user interjection).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Read JSON input from stdin
|
||||
input=$(cat)
|
||||
|
||||
# Guard: if stop_hook_active is true, we're in a loop — bail out
|
||||
stop_hook_active=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('stop_hook_active', False))" 2>/dev/null || echo "False")
|
||||
if [ "$stop_hook_active" = "True" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Guard: only act for pi-spawned sessions
|
||||
if [ -z "${PI_CLAUDE_SENTINEL:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get transcript path
|
||||
transcript_path=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('transcript_path', ''))" 2>/dev/null || echo "")
|
||||
if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count real human messages in transcript (not tool results)
|
||||
# Claude's transcript format:
|
||||
# Human message: {"type": "user", "message": {"role": "user", "content": "..."}}
|
||||
# Tool result: {"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", ...}]}}
|
||||
# We only count entries where content is a string (real human input)
|
||||
user_msg_count=$(python3 - "$transcript_path" <<'EOF'
|
||||
import sys, json
|
||||
|
||||
transcript_path = sys.argv[1]
|
||||
count = 0
|
||||
with open(transcript_path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
if entry.get('type') != 'user':
|
||||
continue
|
||||
content = entry.get('message', {}).get('content', '')
|
||||
# Real human messages have string content
|
||||
# Tool results have array content with tool_result blocks
|
||||
if isinstance(content, str):
|
||||
count += 1
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
print(count)
|
||||
EOF
|
||||
)
|
||||
|
||||
# Always write transcript path so the watcher can copy the session file
|
||||
if [ -n "$transcript_path" ]; then
|
||||
echo "$transcript_path" > "${PI_CLAUDE_SENTINEL}.transcript" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# If exactly 1 user message (the initial prompt), this was autonomous — signal completion
|
||||
if [ "$user_msg_count" -eq 1 ]; then
|
||||
# Write last_assistant_message to sentinel so the watcher gets a clean result
|
||||
echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('last_assistant_message', ''))" > "$PI_CLAUDE_SENTINEL" 2>/dev/null || touch "$PI_CLAUDE_SENTINEL"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,625 @@
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export interface SessionEntry {
|
||||
type: string;
|
||||
id: string;
|
||||
parentId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MessageEntry extends SessionEntry {
|
||||
type: "message";
|
||||
message: {
|
||||
role: "user" | "assistant" | "toolResult";
|
||||
content: Array<{ type: string; text?: string; [key: string]: unknown }>;
|
||||
};
|
||||
}
|
||||
|
||||
export type SeededSubagentSessionMode = "lineage-only" | "fork";
|
||||
|
||||
function getForkContentLines(parentSessionFile: string): string[] {
|
||||
const raw = readFileSync(parentSessionFile, "utf8");
|
||||
const lines = raw.split("\n").filter((line) => line.trim());
|
||||
|
||||
let truncateAt = lines.length;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
if (entry.type === "message" && entry.message?.role === "user") {
|
||||
truncateAt = i;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return lines.slice(0, truncateAt).filter((line) => {
|
||||
try {
|
||||
return JSON.parse(line).type !== "session";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function seedSubagentSessionFile(params: {
|
||||
mode: SeededSubagentSessionMode;
|
||||
parentSessionFile: string;
|
||||
childSessionFile: string;
|
||||
childCwd: string;
|
||||
}): void {
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: params.childCwd,
|
||||
parentSession: params.parentSessionFile,
|
||||
};
|
||||
const contentLines =
|
||||
params.mode === "fork" ? getForkContentLines(params.parentSessionFile) : [];
|
||||
const lines = [JSON.stringify(header), ...contentLines];
|
||||
|
||||
mkdirSync(dirname(params.childSessionFile), { recursive: true });
|
||||
writeFileSync(params.childSessionFile, lines.join("\n") + "\n", "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot of everything needed to reconstruct a subagent's sandbox when its
|
||||
* session is later resumed via `subagent_message({ sessionId })`.
|
||||
*
|
||||
* Written next to the session file as `<sessionFile>.loadout.json` at spawn
|
||||
* time. Resume replays this exact snapshot so the reincarnated process gets the
|
||||
* same `--no-extensions` + `--tools` restriction, model, identity, spawn
|
||||
* whitelist, cwd, and config dir it originally ran with — instead of falling
|
||||
* back to pi's default (all global extensions + full toolset). Storing the
|
||||
* resolved loadout (rather than re-deriving from the agent `.md` by name) keeps
|
||||
* resume faithful even if the agent definition is later edited, moved, or
|
||||
* deleted.
|
||||
*/
|
||||
export interface SubagentLoadout {
|
||||
/** Agent profile name (for PI_SUBAGENT_AGENT); null for agentless spawns. */
|
||||
agent: string | null;
|
||||
/** The `--tools` allowlist string, or null when the spawn was unrestricted. */
|
||||
toolAllowlist: string | null;
|
||||
/** Model id (without thinking suffix), or null to use the session default. */
|
||||
model: string | null;
|
||||
/** Thinking level appended to the model as `model:level`, or null. */
|
||||
thinking: string | null;
|
||||
/** How the identity text was applied: append/replace, or null. */
|
||||
systemPromptMode: "append" | "replace" | null;
|
||||
/** The system-prompt/identity text, only when it lived in the system prompt. */
|
||||
identity: string | null;
|
||||
/** Agents this subagent was allowed to spawn (for PI_SUBAGENT_ALLOWED). */
|
||||
spawnable: string[] | null;
|
||||
/** Whether the agent auto-exits (informational; resume forces autonomous). */
|
||||
autoExit: boolean;
|
||||
/** Working directory the subagent ran in, or null. */
|
||||
cwd: string | null;
|
||||
/** PI_CODING_AGENT_DIR the subagent resolved config/extensions from, or null. */
|
||||
agentDir: string | null;
|
||||
}
|
||||
|
||||
/** Path of the loadout sidecar written next to a subagent session file. */
|
||||
export function loadoutSidecarPath(sessionFile: string): string {
|
||||
return `${sessionFile}.loadout.json`;
|
||||
}
|
||||
|
||||
/** Persist a subagent's resolved sandbox loadout beside its session file. */
|
||||
export function writeSubagentLoadout(sessionFile: string, loadout: SubagentLoadout): void {
|
||||
try {
|
||||
writeFileSync(loadoutSidecarPath(sessionFile), JSON.stringify(loadout), "utf8");
|
||||
} catch {
|
||||
// Best-effort: a missing snapshot only means resume will refuse, never that
|
||||
// it launches unrestricted.
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a subagent's loadout snapshot, or null if absent/unparseable. */
|
||||
export function readSubagentLoadout(sessionFile: string): SubagentLoadout | null {
|
||||
try {
|
||||
const p = loadoutSidecarPath(sessionFile);
|
||||
if (!existsSync(p)) return null;
|
||||
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return parsed as SubagentLoadout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name registry ────────────────────────────────────────────────────────────
|
||||
// Each spawner session (the top-level pi session, or a worker that spawns its
|
||||
// own children) gets a registry mapping a subagent's display name to the
|
||||
// session file it ran in. Names are unique per spawner session and persist on
|
||||
// disk, so `subagent_message({ name })` can steer a running subagent or resume
|
||||
// a finished one by the same handle — even across a pi restart. The registry
|
||||
// lives in the spawner's own artifact dir, which is directly addressable from
|
||||
// the spawner's session id (no sessions-tree scan, so resume stays fast).
|
||||
|
||||
export interface NameRegistryEntry {
|
||||
/** Absolute path to the subagent's session .jsonl file. */
|
||||
sessionFile: string;
|
||||
/** Canonical session header id (kept for display/lineage). */
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export type NameRegistry = Record<string, NameRegistryEntry>;
|
||||
|
||||
/** Path of the name registry for a given spawner session's artifact dir. */
|
||||
export function nameRegistryPath(artifactDir: string): string {
|
||||
return join(artifactDir, "subagent-registry.json");
|
||||
}
|
||||
|
||||
/** Read a spawner session's name registry, or {} if absent/corrupt. */
|
||||
export function readNameRegistry(artifactDir: string): NameRegistry {
|
||||
try {
|
||||
const p = nameRegistryPath(artifactDir);
|
||||
if (!existsSync(p)) return {};
|
||||
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return parsed as NameRegistry;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or overwrite) a name → session mapping for a spawner session.
|
||||
* Writes atomically (temp file + rename) so a concurrent reader never sees a
|
||||
* partial registry.
|
||||
*/
|
||||
export function registerName(
|
||||
artifactDir: string,
|
||||
name: string,
|
||||
entry: NameRegistryEntry,
|
||||
): void {
|
||||
try {
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
const registry = readNameRegistry(artifactDir);
|
||||
registry[name] = entry;
|
||||
const p = nameRegistryPath(artifactDir);
|
||||
const tmp = `${p}.tmp-${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf8");
|
||||
renameSync(tmp, p);
|
||||
} catch {
|
||||
// Best-effort: a failed registration only means resume-by-name won't find
|
||||
// this subagent later; it never breaks the spawn itself.
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a name to its registry entry within a spawner session, or null. */
|
||||
export function resolveNameInRegistry(
|
||||
artifactDir: string,
|
||||
name: string,
|
||||
): NameRegistryEntry | null {
|
||||
const entry = readNameRegistry(artifactDir)[name];
|
||||
return entry && typeof entry.sessionFile === "string" ? entry : null;
|
||||
}
|
||||
|
||||
function readEntries(sessionFile: string): SessionEntry[] {
|
||||
const raw = readFileSync(sessionFile, "utf8");
|
||||
return raw
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line) as SessionEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the id of the last entry in the session file (current branch point / leaf).
|
||||
*/
|
||||
export function getLeafId(sessionFile: string): string | null {
|
||||
const entries = readEntries(sessionFile);
|
||||
return entries.length > 0 ? entries[entries.length - 1].id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the canonical session id from a session file's header.
|
||||
*
|
||||
* pi's `--session <id>` flag resolves against this header `id` (exact match,
|
||||
* then prefix), NOT the filename — so this is the value to hand back to the
|
||||
* orchestrator for follow-ups.
|
||||
*/
|
||||
/**
|
||||
* Read only the first line of a file without loading the whole thing into
|
||||
* memory. Session files grow to many MB, but the header we need is always the
|
||||
* first JSON line, so reading a small prefix keeps header lookups cheap — this
|
||||
* is what makes scanning a large session tree fast enough to avoid blocking the
|
||||
* event loop. Returns the first line (sans trailing newline), or null.
|
||||
*/
|
||||
function readFirstLine(path: string, maxBytes = 65536): string | null {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(path, "r");
|
||||
const buf = Buffer.allocUnsafe(maxBytes);
|
||||
const bytes = readSync(fd, buf, 0, maxBytes, 0);
|
||||
if (bytes <= 0) return null;
|
||||
const nl = buf.indexOf(0x0a); // '\n'
|
||||
const end = nl === -1 || nl >= bytes ? bytes : nl;
|
||||
return buf.toString("utf8", 0, end);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionId(sessionFile: string): string | null {
|
||||
return readHeaderId(sessionFile);
|
||||
}
|
||||
|
||||
function readHeaderId(sessionFile: string): string | null {
|
||||
const firstLine = readFirstLine(sessionFile)?.trim();
|
||||
if (!firstLine) return null;
|
||||
try {
|
||||
const entry = JSON.parse(firstLine) as { type?: string; id?: string };
|
||||
return entry.type === "session" && typeof entry.id === "string" ? entry.id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session id (or id prefix) to a session file path by scanning every
|
||||
* `*.jsonl` under `sessionsRoot` and matching the header `id`. Mirrors pi's own
|
||||
* resolution order: exact match first, then prefix match. Most recently
|
||||
* modified file wins on ties. Returns null when nothing matches.
|
||||
*/
|
||||
/**
|
||||
* In-process index of session id → session file, per sessions root.
|
||||
*
|
||||
* Resolving a session id naively walks every `.jsonl` under the sessions tree
|
||||
* and reads each header. With a few thousand sessions that is thousands of
|
||||
* synchronous open/read/stat syscalls — on the extension host's single thread
|
||||
* that blocks the entire terminal UI for many seconds (measured ~67s on a
|
||||
* 2010-file tree). To avoid that, we build the index once per root and cache
|
||||
* it; subsequent lookups are O(1). The cache is validated cheaply (a directory
|
||||
* listing plus statSync-only mtime checks) on every call, so new sessions are
|
||||
* picked up without re-reading unchanged headers and without ever freezing the
|
||||
* UI again.
|
||||
*/
|
||||
interface SessionIndex {
|
||||
idToFile: Map<string, { path: string; mtime: number }>;
|
||||
/** file path → mtime when indexed (staleness detection). */
|
||||
files: Map<string, number>;
|
||||
/** top-level dir signature used to detect newly added cwd dirs. */
|
||||
topSig: string;
|
||||
}
|
||||
const sessionIndexCache = new Map<string, SessionIndex>();
|
||||
|
||||
function topLevelSignature(root: string): string {
|
||||
const parts: string[] = [];
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = join(root, e.name);
|
||||
if (e.isDirectory()) {
|
||||
let m = 0;
|
||||
try {
|
||||
m = statSync(full).mtimeMs;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
parts.push(`d:${e.name}:${m}`);
|
||||
} else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
||||
parts.push(`f:${e.name}`);
|
||||
}
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
/** Recursively index new/changed .jsonl files under dir into idx. */
|
||||
function indexDir(dir: string, idx: SessionIndex): void {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
indexDir(full, idx);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
let mtime = 0;
|
||||
try {
|
||||
mtime = statSync(full).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const known = idx.files.get(full);
|
||||
if (known !== undefined && known === mtime) continue; // unchanged
|
||||
const id = readHeaderId(full); // only read headers for new/changed files
|
||||
idx.files.set(full, mtime);
|
||||
if (!id) continue;
|
||||
const prev = idx.idToFile.get(id);
|
||||
if (!prev || mtime >= prev.mtime) {
|
||||
idx.idToFile.set(id, { path: full, mtime });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionIndex(sessionsRoot: string): SessionIndex {
|
||||
let idx = sessionIndexCache.get(sessionsRoot);
|
||||
const sig = topLevelSignature(sessionsRoot);
|
||||
if (!idx) {
|
||||
idx = { idToFile: new Map(), files: new Map(), topSig: sig };
|
||||
sessionIndexCache.set(sessionsRoot, idx);
|
||||
indexDir(sessionsRoot, idx); // first build: full scan, once per process
|
||||
} else if (idx.topSig !== sig) {
|
||||
idx.topSig = sig;
|
||||
indexDir(sessionsRoot, idx); // a cwd dir was added/changed: incremental rescan
|
||||
} else {
|
||||
indexDir(sessionsRoot, idx); // cheap: stats files, reads only new/changed headers
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
export function resolveSessionFileById(sessionId: string, sessionsRoot: string): string | null {
|
||||
if (!sessionId || !existsSync(sessionsRoot)) return null;
|
||||
const idx = getSessionIndex(sessionsRoot);
|
||||
return lookupSessionIndex(idx, sessionId);
|
||||
}
|
||||
|
||||
function lookupSessionIndex(
|
||||
idx: { idToFile: Map<string, { path: string; mtime: number }> },
|
||||
sessionId: string,
|
||||
): string | null {
|
||||
// Exact match first.
|
||||
const exact = idx.idToFile.get(sessionId);
|
||||
if (exact && existsSync(exact.path)) return exact.path;
|
||||
|
||||
// Prefix match: most recently modified wins (ids are unique in practice, so
|
||||
// this is only a convenience for hand-typed short prefixes).
|
||||
let best: { path: string; mtime: number } | null = null;
|
||||
for (const [id, rec] of idx.idToFile) {
|
||||
if (!id.startsWith(sessionId)) continue;
|
||||
if (!existsSync(rec.path)) continue;
|
||||
if (!best || rec.mtime > best.mtime) best = rec;
|
||||
}
|
||||
return best ? best.path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async variant used by the interactive resume path. Index building/refresh is
|
||||
* synchronous I/O, which can take many seconds on a cold OS page cache with a
|
||||
* few thousand sessions; running it synchronously would block the extension
|
||||
* host's single thread and freeze the terminal UI. Deferring to a macrotask
|
||||
* keeps the event loop responsive. The heavy work only happens on the first
|
||||
* resolution per process (and incrementally thereafter); warm lookups are ~50ms.
|
||||
*/
|
||||
export async function resolveSessionFileByIdAsync(
|
||||
sessionId: string,
|
||||
sessionsRoot: string,
|
||||
): Promise<string | null> {
|
||||
if (!sessionId || !existsSync(sessionsRoot)) return null;
|
||||
// Let the event loop breathe (and the UI repaint) before the sync scan.
|
||||
await new Promise<void>((r) => setImmediate(r));
|
||||
const idx = getSessionIndex(sessionsRoot);
|
||||
return lookupSessionIndex(idx, sessionId);
|
||||
}
|
||||
|
||||
/** Test hook: drop the cached session index so tests start clean. */
|
||||
export function resetSessionIndexCache(): void {
|
||||
sessionIndexCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return entries added after `afterLine` (1-indexed count of existing entries).
|
||||
*/
|
||||
/**
|
||||
* Count the number of entry lines in a session file without parsing each line
|
||||
* into an object. Used by the resume path, which only needs the *count* of
|
||||
* pre-existing entries (so it can later slice out the new ones). Parsing every
|
||||
* line of a large resumed transcript synchronously at resume time would block
|
||||
* the UI; counting newlines is dramatically cheaper.
|
||||
*/
|
||||
export function countSessionEntryLines(sessionFile: string): number {
|
||||
try {
|
||||
const raw = readFileSync(sessionFile, "utf8");
|
||||
// Count non-blank lines, mirroring getNewEntries' `.filter(line => line.trim())`
|
||||
// but skipping the per-line JSON.parse that makes resume slow on big files.
|
||||
let count = 0;
|
||||
for (const line of raw.split("\n")) {
|
||||
if (line.trim()) count++;
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNewEntries(sessionFile: string, afterLine: number): SessionEntry[] {
|
||||
const raw = readFileSync(sessionFile, "utf8");
|
||||
const lines = raw.split("\n").filter((line) => line.trim());
|
||||
return lines.slice(afterLine).map((line) => JSON.parse(line) as SessionEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last assistant message text in a list of entries.
|
||||
*
|
||||
* Falls back to the `errorMessage` field when the last assistant message has
|
||||
* `stopReason: "error"` and no usable text content — this happens when
|
||||
* auto-retry exhausts on a provider overload / rate limit / server error, and
|
||||
* without this fallback the parent would silently see a stale earlier message.
|
||||
*/
|
||||
export function findLastAssistantMessage(entries: SessionEntry[]): string | null {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry as MessageEntry;
|
||||
if (msg.message.role !== "assistant") continue;
|
||||
|
||||
const texts = msg.message.content
|
||||
.filter(
|
||||
(block) =>
|
||||
block.type === "text" && typeof block.text === "string" && block.text.trim() !== "",
|
||||
)
|
||||
.map((block) => block.text as string);
|
||||
|
||||
if (texts.length > 0 && texts.join("").trim()) return texts.join("\n");
|
||||
|
||||
const stopReason = (msg.message as { stopReason?: unknown }).stopReason;
|
||||
const errorMessage = (msg.message as { errorMessage?: unknown }).errorMessage;
|
||||
if (
|
||||
stopReason === "error" &&
|
||||
typeof errorMessage === "string" &&
|
||||
errorMessage.trim() !== ""
|
||||
) {
|
||||
return `Subagent error: ${errorMessage.trim()}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a branch_summary entry to the session file.
|
||||
* Returns the new entry's id.
|
||||
*/
|
||||
export function appendBranchSummary(
|
||||
sessionFile: string,
|
||||
branchPointId: string,
|
||||
fromId: string | null,
|
||||
summary: string,
|
||||
): string {
|
||||
const id = randomBytes(4).toString("hex");
|
||||
const entry = {
|
||||
type: "branch_summary",
|
||||
id,
|
||||
parentId: branchPointId,
|
||||
timestamp: new Date().toISOString(),
|
||||
fromId: fromId ?? branchPointId,
|
||||
summary,
|
||||
};
|
||||
appendFileSync(sessionFile, JSON.stringify(entry) + "\n", "utf8");
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the session file to destDir for parallel worker isolation.
|
||||
* Returns the path of the copy.
|
||||
*/
|
||||
export function copySessionFile(sessionFile: string, destDir: string): string {
|
||||
const id = randomBytes(4).toString("hex");
|
||||
const dest = join(destDir, `subagent-${id}.jsonl`);
|
||||
copyFileSync(sessionFile, dest);
|
||||
return dest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read new entries from sourceFile (after afterLine), append them to targetFile.
|
||||
* Returns the appended entries.
|
||||
*/
|
||||
export function mergeNewEntries(
|
||||
sourceFile: string,
|
||||
targetFile: string,
|
||||
afterLine: number,
|
||||
): SessionEntry[] {
|
||||
const entries = getNewEntries(sourceFile, afterLine);
|
||||
for (const entry of entries) {
|
||||
appendFileSync(targetFile, JSON.stringify(entry) + "\n", "utf8");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
model: string | null;
|
||||
toolCount: number;
|
||||
/** Cumulative token usage across all assistant turns. */
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
/** Current context size: the last assistant turn's totalTokens. */
|
||||
contextTokens: number;
|
||||
/** Cumulative cost in USD across all assistant turns. */
|
||||
cost: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a completed subagent session JSONL into aggregate stats for display:
|
||||
* model, tool-call count, cumulative token usage + cost, and current context
|
||||
* size. Cumulative usage fields are summed across every assistant turn; the
|
||||
* context size is taken from the last assistant turn's `totalTokens` (the live
|
||||
* context window occupancy). Returns null if the file can't be read.
|
||||
*/
|
||||
export function summarizeSessionStats(sessionFile: string): SessionStats | null {
|
||||
let entries: SessionEntry[];
|
||||
try {
|
||||
entries = readEntries(sessionFile);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats: SessionStats = {
|
||||
model: null,
|
||||
toolCount: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
contextTokens: 0,
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "model_change") {
|
||||
const modelId = (entry as { modelId?: unknown }).modelId;
|
||||
if (typeof modelId === "string" && modelId) stats.model = modelId;
|
||||
continue;
|
||||
}
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = (entry as MessageEntry).message;
|
||||
if (msg.role !== "assistant") continue;
|
||||
|
||||
const model = (msg as { model?: unknown }).model;
|
||||
if (typeof model === "string" && model) stats.model = model;
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "toolCall") stats.toolCount++;
|
||||
}
|
||||
|
||||
const usage = (msg as { usage?: Record<string, unknown> }).usage;
|
||||
if (usage && typeof usage === "object") {
|
||||
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||
stats.inputTokens += num(usage.input);
|
||||
stats.outputTokens += num(usage.output);
|
||||
stats.cacheReadTokens += num(usage.cacheRead);
|
||||
stats.cacheWriteTokens += num(usage.cacheWrite);
|
||||
const total = num(usage.totalTokens);
|
||||
if (total > 0) stats.contextTokens = total;
|
||||
const cost = usage.cost;
|
||||
if (cost && typeof cost === "object") stats.cost += num((cost as Record<string, unknown>).total);
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const SNAPSHOT_STALLED_AFTER_MS = 60_000;
|
||||
export const DEFAULT_STATUS_LINE_LIMIT = 4;
|
||||
export const MAX_STATUS_NAME_LENGTH = 72;
|
||||
export const MAX_STATUS_LINE_LENGTH = 120;
|
||||
|
||||
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const DEFAULT_STATUS_CONFIG_PATH = join(PACKAGE_ROOT, "config.json");
|
||||
const STATUS_CONFIG_EXAMPLE_PATH = join(PACKAGE_ROOT, "config.json.example");
|
||||
|
||||
export type SubagentStatusKind = "starting" | "active" | "waiting" | "stalled" | "running";
|
||||
export type SubagentStatusSource = "pi" | "claude";
|
||||
export type SubagentStatusTransition = "stalled" | "recovered" | null;
|
||||
export type StatusSnapshotState = "unseen" | "present" | "missing" | "invalid" | "wrong-id";
|
||||
export type StatusActivityPhase = "starting" | "active" | "waiting" | "done";
|
||||
|
||||
export interface StatusConfig {
|
||||
enabled: boolean;
|
||||
lineLimit: number;
|
||||
}
|
||||
|
||||
export type StatusObservation =
|
||||
| {
|
||||
snapshot: "present";
|
||||
updatedAt: number;
|
||||
sequence: number;
|
||||
phase: StatusActivityPhase;
|
||||
active?: boolean;
|
||||
activeScope?: string;
|
||||
activeSince?: number;
|
||||
waitingSince?: number;
|
||||
latestEvent?: string;
|
||||
activityLabel?: string;
|
||||
}
|
||||
| {
|
||||
snapshot: "missing" | "invalid" | "wrong-id";
|
||||
snapshotError?: string;
|
||||
};
|
||||
|
||||
export interface SubagentStatusState {
|
||||
source: SubagentStatusSource;
|
||||
startTimeMs: number;
|
||||
firstObservationAtMs: number | null;
|
||||
lastActivityAtMs: number | null;
|
||||
lastActivitySequence: number | null;
|
||||
localOverrideAtMs: number | null;
|
||||
localOverrideSequence: number | null;
|
||||
activeNow: boolean;
|
||||
activeSinceMs: number | null;
|
||||
activeScope: string | null;
|
||||
waitingSinceMs: number | null;
|
||||
phase: StatusActivityPhase | null;
|
||||
latestEvent: string | null;
|
||||
activityLabel: string | null;
|
||||
snapshotState: StatusSnapshotState;
|
||||
snapshotProblemSinceMs: number | null;
|
||||
snapshotError: string | null;
|
||||
currentKind: SubagentStatusKind;
|
||||
}
|
||||
|
||||
export interface StatusSnapshot {
|
||||
kind: SubagentStatusKind;
|
||||
elapsedMs: number;
|
||||
elapsedText: string;
|
||||
activeSinceMs: number | null;
|
||||
activeDurationText: string | null;
|
||||
activeScope: string | null;
|
||||
waitingSinceMs: number | null;
|
||||
waitingDurationText: string | null;
|
||||
latestEvent: string | null;
|
||||
activityLabel: string | null;
|
||||
snapshotState: StatusSnapshotState;
|
||||
snapshotError: string | null;
|
||||
snapshotProblemText: string | null;
|
||||
statusLabel: string | null;
|
||||
}
|
||||
|
||||
export interface CappedStatusLines {
|
||||
visibleLines: string[];
|
||||
overflow: number;
|
||||
}
|
||||
|
||||
function invalidStatusConfig(source: string, message: string): never {
|
||||
throw new Error(`Invalid subagent status config in ${source}: ${message}`);
|
||||
}
|
||||
|
||||
function requireObject(value: unknown, source: string, fieldName: string): Record<string, unknown> {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||
invalidStatusConfig(source, `${fieldName} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, source: string, fieldName: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
invalidStatusConfig(source, `${fieldName} must be a boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function rejectUnsupportedKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowedKeys: string[],
|
||||
source: string,
|
||||
fieldName: string,
|
||||
): void {
|
||||
const unsupportedKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key));
|
||||
if (unsupportedKeys.length > 0) {
|
||||
invalidStatusConfig(source, `${fieldName} has unsupported key(s): ${unsupportedKeys.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
if (maxLength <= 1) return text.slice(0, maxLength);
|
||||
return `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export function normalizeStatusName(name: string): string {
|
||||
const collapsed = name.replace(/\s+/g, " ").trim() || "subagent";
|
||||
return truncateText(collapsed, MAX_STATUS_NAME_LENGTH);
|
||||
}
|
||||
|
||||
function boundStatusLine(line: string): string {
|
||||
return truncateText(line.replace(/\s+/g, " ").trim(), MAX_STATUS_LINE_LENGTH);
|
||||
}
|
||||
|
||||
function snapshotProblemLabel(snapshotState: StatusSnapshotState): string | null {
|
||||
if (snapshotState === "wrong-id") return "wrong activity id";
|
||||
return null;
|
||||
}
|
||||
|
||||
function activityLabel(snapshot: Pick<StatusSnapshot, "activityLabel" | "activeScope">): string | null {
|
||||
return snapshot.activityLabel ?? snapshot.activeScope;
|
||||
}
|
||||
|
||||
export function parseStatusConfig(rawConfig: unknown, source = "config.json"): StatusConfig {
|
||||
const config = requireObject(rawConfig, source, "root");
|
||||
const status = requireObject(config.status, source, "status");
|
||||
rejectUnsupportedKeys(status, ["enabled"], source, "status");
|
||||
const enabled = requireBoolean(status.enabled, source, "status.enabled");
|
||||
|
||||
return {
|
||||
enabled,
|
||||
lineLimit: DEFAULT_STATUS_LINE_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
function readStatusConfigFile(configPath: string, examplePath: string): { sourcePath: string; rawConfig: string } {
|
||||
try {
|
||||
return { sourcePath: configPath, rawConfig: readFileSync(configPath, "utf8") };
|
||||
} catch (error) {
|
||||
const errno = error as NodeJS.ErrnoException;
|
||||
if (errno.code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return { sourcePath: examplePath, rawConfig: readFileSync(examplePath, "utf8") };
|
||||
} catch (error) {
|
||||
const errno = error as NodeJS.ErrnoException;
|
||||
if (errno.code === "ENOENT") {
|
||||
throw new Error(
|
||||
`Missing subagent status config. Expected ${configPath} or ${examplePath}.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStatusConfig(
|
||||
configPath = DEFAULT_STATUS_CONFIG_PATH,
|
||||
examplePath = STATUS_CONFIG_EXAMPLE_PATH,
|
||||
): StatusConfig {
|
||||
const { sourcePath, rawConfig } = readStatusConfigFile(configPath, examplePath);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawConfig) as unknown;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid JSON in subagent config ${sourcePath}: ${detail}`);
|
||||
}
|
||||
|
||||
return parseStatusConfig(parsed, sourcePath);
|
||||
}
|
||||
|
||||
export function formatElapsedDuration(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
export function createStatusState(params: {
|
||||
source: SubagentStatusSource;
|
||||
startTimeMs: number;
|
||||
}): SubagentStatusState {
|
||||
const initialKind = params.source === "claude" ? "running" : "starting";
|
||||
return {
|
||||
source: params.source,
|
||||
startTimeMs: params.startTimeMs,
|
||||
firstObservationAtMs: null,
|
||||
lastActivityAtMs: null,
|
||||
lastActivitySequence: null,
|
||||
localOverrideAtMs: null,
|
||||
localOverrideSequence: null,
|
||||
activeNow: false,
|
||||
activeSinceMs: null,
|
||||
activeScope: null,
|
||||
waitingSinceMs: null,
|
||||
phase: null,
|
||||
latestEvent: null,
|
||||
activityLabel: null,
|
||||
snapshotState: params.source === "claude" ? "unseen" : "unseen",
|
||||
snapshotProblemSinceMs: null,
|
||||
snapshotError: null,
|
||||
currentKind: initialKind,
|
||||
};
|
||||
}
|
||||
|
||||
export function observeStatus(
|
||||
state: SubagentStatusState,
|
||||
observation: StatusObservation,
|
||||
now: number,
|
||||
): SubagentStatusState {
|
||||
if (state.source === "claude") return state;
|
||||
|
||||
if (observation.snapshot !== "present") {
|
||||
return {
|
||||
...state,
|
||||
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
||||
snapshotState: observation.snapshot,
|
||||
snapshotProblemSinceMs: state.snapshotProblemSinceMs ?? now,
|
||||
snapshotError: observation.snapshotError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedAt = observation.updatedAt;
|
||||
const sequence = observation.sequence;
|
||||
const lastActivityAtMs = state.lastActivityAtMs;
|
||||
const lastActivitySequence = state.lastActivitySequence;
|
||||
const olderThanLastActivity = lastActivityAtMs != null && (
|
||||
updatedAt < lastActivityAtMs ||
|
||||
(updatedAt === lastActivityAtMs && lastActivitySequence != null && sequence < lastActivitySequence)
|
||||
);
|
||||
if (olderThanLastActivity) return state;
|
||||
|
||||
const blockedByLocalOverride = state.localOverrideAtMs != null && (
|
||||
updatedAt < state.localOverrideAtMs ||
|
||||
(updatedAt === state.localOverrideAtMs && state.localOverrideSequence != null && sequence <= state.localOverrideSequence)
|
||||
);
|
||||
if (blockedByLocalOverride) return state;
|
||||
|
||||
const phase = observation.phase;
|
||||
const activeNow = phase === "active" || observation.active === true;
|
||||
const activeSinceMs = activeNow
|
||||
? observation.activeSince ?? state.activeSinceMs ?? updatedAt
|
||||
: null;
|
||||
const waitingSinceMs = phase === "waiting"
|
||||
? observation.waitingSince ?? state.waitingSinceMs ?? updatedAt
|
||||
: null;
|
||||
|
||||
return {
|
||||
...state,
|
||||
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
||||
lastActivityAtMs: updatedAt,
|
||||
lastActivitySequence: sequence,
|
||||
activeNow,
|
||||
activeSinceMs,
|
||||
activeScope: activeNow ? observation.activeScope ?? null : null,
|
||||
waitingSinceMs,
|
||||
phase,
|
||||
latestEvent: observation.latestEvent ?? null,
|
||||
activityLabel: observation.activityLabel ?? null,
|
||||
snapshotState: "present",
|
||||
snapshotProblemSinceMs: null,
|
||||
snapshotError: null,
|
||||
localOverrideAtMs: null,
|
||||
localOverrideSequence: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function forceStatusAfterInterrupt(state: SubagentStatusState, now: number): SubagentStatusState {
|
||||
if (state.source === "claude") return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
||||
lastActivityAtMs: now,
|
||||
localOverrideAtMs: now,
|
||||
localOverrideSequence: state.lastActivitySequence,
|
||||
activeNow: false,
|
||||
activeSinceMs: null,
|
||||
activeScope: null,
|
||||
waitingSinceMs: now,
|
||||
phase: "waiting",
|
||||
latestEvent: "interrupt_requested",
|
||||
activityLabel: "interrupted",
|
||||
snapshotState: "present",
|
||||
snapshotProblemSinceMs: null,
|
||||
snapshotError: null,
|
||||
currentKind: "waiting",
|
||||
};
|
||||
}
|
||||
|
||||
function classifyProblemState(state: SubagentStatusState, now: number): Pick<StatusSnapshot, "kind" | "statusLabel"> {
|
||||
const problemLabel = snapshotProblemLabel(state.snapshotState);
|
||||
const hasValidSnapshot = state.lastActivityAtMs != null;
|
||||
|
||||
if (!hasValidSnapshot) {
|
||||
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
|
||||
const elapsedMs = Math.max(0, now - referenceMs);
|
||||
return elapsedMs >= SNAPSHOT_STALLED_AFTER_MS
|
||||
? { kind: "stalled", statusLabel: problemLabel }
|
||||
: { kind: "starting", statusLabel: null };
|
||||
}
|
||||
|
||||
const problemSinceMs = state.snapshotProblemSinceMs ?? now;
|
||||
const problemMs = Math.max(0, now - problemSinceMs);
|
||||
if (problemMs >= SNAPSHOT_STALLED_AFTER_MS) return { kind: "stalled", statusLabel: problemLabel };
|
||||
|
||||
const lastHealthyKind = state.activeNow
|
||||
? "active"
|
||||
: state.waitingSinceMs != null || state.phase === "done"
|
||||
? "waiting"
|
||||
: state.currentKind === "stalled"
|
||||
? "starting"
|
||||
: state.currentKind;
|
||||
return { kind: lastHealthyKind, statusLabel: problemLabel };
|
||||
}
|
||||
|
||||
export function classifyStatus(state: SubagentStatusState, now: number): StatusSnapshot {
|
||||
const elapsedMs = Math.max(0, now - state.startTimeMs);
|
||||
const elapsedText = formatElapsedDuration(elapsedMs);
|
||||
|
||||
if (state.source === "claude") {
|
||||
return {
|
||||
kind: "running",
|
||||
elapsedMs,
|
||||
elapsedText,
|
||||
activeSinceMs: null,
|
||||
activeDurationText: null,
|
||||
activeScope: null,
|
||||
waitingSinceMs: null,
|
||||
waitingDurationText: null,
|
||||
latestEvent: null,
|
||||
activityLabel: null,
|
||||
snapshotState: state.snapshotState,
|
||||
snapshotError: null,
|
||||
snapshotProblemText: null,
|
||||
statusLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
let kind: SubagentStatusKind;
|
||||
let statusLabel: string | null = null;
|
||||
|
||||
if (state.snapshotState === "present") {
|
||||
if (state.phase === "active" || state.activeNow) {
|
||||
kind = "active";
|
||||
} else if (state.phase === "waiting") {
|
||||
kind = "waiting";
|
||||
} else if (state.phase === "done") {
|
||||
kind = "waiting";
|
||||
statusLabel = "done";
|
||||
} else {
|
||||
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
|
||||
const elapsedSinceObservationMs = Math.max(0, now - referenceMs);
|
||||
kind = elapsedSinceObservationMs >= SNAPSHOT_STALLED_AFTER_MS ? "stalled" : "starting";
|
||||
statusLabel = null;
|
||||
}
|
||||
} else {
|
||||
const classified = classifyProblemState(state, now);
|
||||
kind = classified.kind;
|
||||
statusLabel = classified.statusLabel;
|
||||
}
|
||||
|
||||
const activeDurationText = state.activeSinceMs == null
|
||||
? null
|
||||
: formatElapsedDuration(now - state.activeSinceMs);
|
||||
const waitingDurationText = state.waitingSinceMs == null
|
||||
? null
|
||||
: formatElapsedDuration(now - state.waitingSinceMs);
|
||||
const snapshotProblemText = state.snapshotProblemSinceMs == null
|
||||
? null
|
||||
: formatElapsedDuration(now - state.snapshotProblemSinceMs);
|
||||
|
||||
return {
|
||||
kind,
|
||||
elapsedMs,
|
||||
elapsedText,
|
||||
activeSinceMs: state.activeSinceMs,
|
||||
activeDurationText,
|
||||
activeScope: state.activeScope,
|
||||
waitingSinceMs: state.waitingSinceMs,
|
||||
waitingDurationText,
|
||||
latestEvent: state.latestEvent,
|
||||
activityLabel: state.activityLabel,
|
||||
snapshotState: state.snapshotState,
|
||||
snapshotError: state.snapshotError,
|
||||
snapshotProblemText,
|
||||
statusLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceStatusState(
|
||||
state: SubagentStatusState,
|
||||
now: number,
|
||||
): {
|
||||
nextState: SubagentStatusState;
|
||||
snapshot: StatusSnapshot;
|
||||
transition: SubagentStatusTransition;
|
||||
} {
|
||||
const snapshot = classifyStatus(state, now);
|
||||
const transition =
|
||||
state.currentKind !== "stalled" && snapshot.kind === "stalled"
|
||||
? "stalled"
|
||||
: state.currentKind === "stalled" && (snapshot.kind === "active" || snapshot.kind === "waiting")
|
||||
? "recovered"
|
||||
: null;
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
transition,
|
||||
nextState: {
|
||||
...state,
|
||||
currentKind: snapshot.kind,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatActiveDetail(snapshot: StatusSnapshot): string {
|
||||
const label = activityLabel(snapshot);
|
||||
if (!label) return "active";
|
||||
const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
|
||||
return `active (${label}${duration})`;
|
||||
}
|
||||
|
||||
function formatWaitingDetail(snapshot: StatusSnapshot): string {
|
||||
const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
|
||||
return `waiting${duration}`;
|
||||
}
|
||||
|
||||
function formatStalledDetail(snapshot: StatusSnapshot): string {
|
||||
const detail = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
|
||||
const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
|
||||
return `stalled${duration}${detail}`;
|
||||
}
|
||||
|
||||
export function formatStatusLine(name: string, snapshot: StatusSnapshot): string {
|
||||
const boundedName = normalizeStatusName(name);
|
||||
|
||||
if (snapshot.kind === "starting") {
|
||||
const label = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, starting${label}.`);
|
||||
}
|
||||
|
||||
if (snapshot.kind === "running") {
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}.`);
|
||||
}
|
||||
|
||||
if (snapshot.kind === "active") {
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatActiveDetail(snapshot)}.`);
|
||||
}
|
||||
|
||||
if (snapshot.kind === "waiting") {
|
||||
const problem = snapshot.statusLabel && snapshot.statusLabel !== "done"
|
||||
? ` (${snapshot.statusLabel})`
|
||||
: snapshot.statusLabel === "done"
|
||||
? " (done)"
|
||||
: "";
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatWaitingDetail(snapshot)}${problem}.`);
|
||||
}
|
||||
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatStalledDetail(snapshot)}.`);
|
||||
}
|
||||
|
||||
export function formatTransitionLine(
|
||||
name: string,
|
||||
snapshot: StatusSnapshot,
|
||||
transition: Exclude<SubagentStatusTransition, null>,
|
||||
): string {
|
||||
const boundedName = normalizeStatusName(name);
|
||||
|
||||
if (transition === "recovered") {
|
||||
const detail = snapshot.kind === "waiting" ? formatWaitingDetail(snapshot) : formatActiveDetail(snapshot);
|
||||
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, recovered; ${detail}.`);
|
||||
}
|
||||
|
||||
return formatStatusLine(boundedName, snapshot);
|
||||
}
|
||||
|
||||
export function capStatusLines(lines: string[], lineLimit: number): CappedStatusLines {
|
||||
const visibleLines = lines.slice(0, lineLimit);
|
||||
return {
|
||||
visibleLines,
|
||||
overflow: Math.max(0, lines.length - visibleLines.length),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatStatusAggregate(lines: string[], lineLimit: number): string {
|
||||
const { visibleLines, overflow } = capStatusLines(lines, lineLimit);
|
||||
const bulletLines = visibleLines.map((line) => `• ${line}`);
|
||||
if (overflow > 0) bulletLines.push(`• +${overflow} more running.`);
|
||||
return `Subagent status:\n${bulletLines.join("\n")}`;
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Extension loaded into sub-agents.
|
||||
* - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+Alt+O)
|
||||
* - Provides an `ask_question` tool for asking the parent orchestrator a question
|
||||
*
|
||||
* Subagents do NOT self-terminate via a tool. Auto-exit agents shut down
|
||||
* automatically when their agent loop ends (see the `agent_end` handler);
|
||||
* interactive agents end when the human exits the pane.
|
||||
*
|
||||
* `ask_question` keeps the session OPEN: it writes a `${sessionFile}.ask`
|
||||
* signal the parent's watcher picks up, parks the session in a "waiting" state
|
||||
* (auto-exit is suppressed for that turn via `awaitingAnswer`), and the parent
|
||||
* replies with subagent_message — which lands as the subagent's next turn.
|
||||
*/
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Box, Text } from "@earendil-works/pi-tui";
|
||||
import { Type } from "typebox";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { createSubagentActivityRecorder } from "./activity.ts";
|
||||
|
||||
export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
|
||||
return agentStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of child subagents this session itself still has in flight.
|
||||
*
|
||||
* When this extension is loaded inside a subagent that can spawn its own
|
||||
* children (e.g. a worker delegating to scout/researcher), `index.ts` runs in
|
||||
* the same process and publishes a live count through a shared process-global
|
||||
* symbol. A subagent that spawns children and then writes a "waiting for
|
||||
* results" message would otherwise auto-exit the instant that turn ends —
|
||||
* killing the session before its children report back. Reading this count lets
|
||||
* `agent_end` keep the session open until every child has finished and its
|
||||
* result has been delivered.
|
||||
*
|
||||
* Returns 0 when the spawning tools aren't loaded (scout/researcher, or a
|
||||
* standalone session), so those agents auto-exit exactly as before.
|
||||
*/
|
||||
export function runningChildrenCount(): number {
|
||||
const fn = (globalThis as any)[Symbol.for("pi-subagents/running-children-count")];
|
||||
if (typeof fn !== "function") return 0;
|
||||
try {
|
||||
const n = fn();
|
||||
return typeof n === "number" && n > 0 ? n : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldAutoExitOnAgentEnd(
|
||||
_userTookOver: boolean,
|
||||
messages: any[] | undefined,
|
||||
): boolean {
|
||||
// Manual input should not strand an auto-exit subagent. If the latest agent
|
||||
// turn completed normally, close the session. Escape/abort still leaves it
|
||||
// open for inspection or another prompt.
|
||||
//
|
||||
// stopReason: "error" (e.g. exhausted retries on a provider overload) also
|
||||
// returns true — we want to shut down so the parent is woken up — but we
|
||||
// pair this with findLatestAssistantError() so the parent learns it was an
|
||||
// error, not a clean completion.
|
||||
if (messages) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg?.role === "assistant") {
|
||||
return msg.stopReason !== "aborted";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface SubagentErrorInfo {
|
||||
errorMessage: string;
|
||||
stopReason: "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* If the last assistant message in the turn ended with `stopReason: "error"`
|
||||
* (typically auto-retry exhausted on an overload / rate limit / server error),
|
||||
* return its error info so the parent orchestrator can surface a clear
|
||||
* failure instead of silently treating the run as completed.
|
||||
*
|
||||
* Returns `null` when the latest assistant turn completed normally or was
|
||||
* aborted by the user (handled separately by shouldAutoExitOnAgentEnd).
|
||||
*/
|
||||
export function findLatestAssistantError(
|
||||
messages: any[] | undefined,
|
||||
): SubagentErrorInfo | null {
|
||||
if (!messages) return null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg?.role !== "assistant") continue;
|
||||
if (msg.stopReason !== "error") return null;
|
||||
const raw = typeof msg.errorMessage === "string" ? msg.errorMessage.trim() : "";
|
||||
return {
|
||||
errorMessage: raw || "Subagent agent loop ended with stopReason=error (no errorMessage field).",
|
||||
stopReason: "error",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseDeniedTools(rawValue: string | undefined): string[] {
|
||||
return (rawValue ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let toolNames: string[] = [];
|
||||
let denied: string[] = [];
|
||||
let expanded = false;
|
||||
|
||||
// Read subagent identity from env vars (set by parent orchestrator)
|
||||
const subagentName = process.env.PI_SUBAGENT_NAME ?? "";
|
||||
const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? "";
|
||||
const deniedToolsValue = process.env.PI_DENY_TOOLS;
|
||||
const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1";
|
||||
const recorder = createSubagentActivityRecorder({
|
||||
runningChildId: process.env.PI_SUBAGENT_ID,
|
||||
activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE,
|
||||
});
|
||||
|
||||
function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) {
|
||||
ctx.ui.setWidget(
|
||||
"subagent-tools",
|
||||
(_tui: any, theme: any) => {
|
||||
const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text));
|
||||
|
||||
const label = subagentAgent || subagentName;
|
||||
const agentTag = label ? theme.bold(theme.fg("accent", `[${label}]`)) : "";
|
||||
|
||||
if (expanded) {
|
||||
// Expanded: full tool list + denied
|
||||
const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
|
||||
const hint = theme.fg("muted", " (Ctrl+Alt+O to collapse)");
|
||||
|
||||
const toolList = toolNames
|
||||
.map((name: string) => theme.fg("dim", name))
|
||||
.join(theme.fg("muted", ", "));
|
||||
|
||||
let deniedLine = "";
|
||||
if (denied.length > 0) {
|
||||
const deniedList = denied
|
||||
.map((name: string) => theme.fg("error", name))
|
||||
.join(theme.fg("muted", ", "));
|
||||
deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList;
|
||||
}
|
||||
|
||||
const content = new Text(
|
||||
`${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
box.addChild(content);
|
||||
} else {
|
||||
// Collapsed: one-line summary
|
||||
const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`);
|
||||
const deniedInfo =
|
||||
denied.length > 0
|
||||
? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`)
|
||||
: "";
|
||||
const hint = theme.fg("muted", " (Ctrl+Alt+O to expand)");
|
||||
|
||||
const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0);
|
||||
box.addChild(content);
|
||||
}
|
||||
|
||||
return box;
|
||||
},
|
||||
{ placement: "aboveEditor" },
|
||||
);
|
||||
}
|
||||
|
||||
let userTookOver = false;
|
||||
let agentStarted = false;
|
||||
// Set when ask_question is called; suppresses auto-exit so the session stays
|
||||
// open while it waits for the orchestrator's reply. Cleared when the reply
|
||||
// lands — on `input` (covers a reply steered into the current run) and on
|
||||
// `agent_start` (covers a reply that starts a fresh turn after parking).
|
||||
let awaitingAnswer = false;
|
||||
|
||||
// Show widget + status bar on session start
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
recorder.sessionStart();
|
||||
const tools = pi.getAllTools();
|
||||
toolNames = tools.map((t) => t.name).sort();
|
||||
denied = parseDeniedTools(deniedToolsValue);
|
||||
|
||||
renderWidget(ctx, null);
|
||||
});
|
||||
|
||||
pi.on("input", () => {
|
||||
recorder.input();
|
||||
// A submitted message is the orchestrator's (or a human's) reply — the
|
||||
// pending ask_question has been answered, however it was delivered. Clear
|
||||
// here, not only on agent_start, because a reply steered in *mid-run* is
|
||||
// absorbed into the current run (pi's `steer` behavior injects it before
|
||||
// the next LLM call): no new agent_start fires, so without this the flag
|
||||
// would stay set and agent_end would park the session as `waiting` even
|
||||
// though the answer already arrived and was consumed. (The `input` event
|
||||
// fires for mid-run steers because prompt() emits it before queueing.)
|
||||
awaitingAnswer = false;
|
||||
// Ignore the initial task message that starts an autonomous subagent.
|
||||
// Only inputs after the first agent run has started count as user takeover.
|
||||
if (!shouldMarkUserTookOver(agentStarted)) return;
|
||||
userTookOver = true;
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", () => {
|
||||
recorder.beforeAgentStart();
|
||||
});
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
agentStarted = true;
|
||||
// A new turn is starting — any pending ask_question has now been answered
|
||||
// (or superseded), so let auto-exit resume normally when this turn ends.
|
||||
awaitingAnswer = false;
|
||||
recorder.agentStart();
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event, ctx) => {
|
||||
const messages = (event as any).messages as any[] | undefined;
|
||||
// Never shut down while this session still has work in flight:
|
||||
// - awaitingAnswer: an ask_question is pending the orchestrator's reply.
|
||||
// - runningChildrenCount(): this subagent spawned its own children and is
|
||||
// waiting for their results (delivered as steered turns). Exiting now
|
||||
// would strand those children and drop their results.
|
||||
// In both cases the session parks as `waiting` and resumes when the next
|
||||
// turn lands.
|
||||
const hasPendingChildren = runningChildrenCount() > 0;
|
||||
const shouldExit =
|
||||
!awaitingAnswer &&
|
||||
!hasPendingChildren &&
|
||||
autoExit &&
|
||||
shouldAutoExitOnAgentEnd(userTookOver, messages);
|
||||
|
||||
if (shouldExit) {
|
||||
// Surface stopReason: "error" turns (auto-retry exhausted, provider
|
||||
// overload, etc.) to the parent via the .exit sidecar so the watcher
|
||||
// can report a clear failure with the underlying error message.
|
||||
// Without this the parent would only see exit code 0 and a stale
|
||||
// assistant message, mistaking the crash for a successful completion.
|
||||
const errorInfo = findLatestAssistantError(messages);
|
||||
const sessionFile = process.env.PI_SUBAGENT_SESSION;
|
||||
if (errorInfo && sessionFile) {
|
||||
try {
|
||||
writeFileSync(
|
||||
`${sessionFile}.exit`,
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
errorMessage: errorInfo.errorMessage,
|
||||
stopReason: errorInfo.stopReason,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Best effort — even without the sidecar, watcher's session-file
|
||||
// fallback can still recover the errorMessage.
|
||||
}
|
||||
}
|
||||
|
||||
recorder.agentEndDone();
|
||||
ctx.shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
recorder.agentEndWaiting();
|
||||
if (autoExit) {
|
||||
// Reset any recorded manual input marker. Auto-exit is decided by whether
|
||||
// the latest agent turn completed normally, not by who initiated it.
|
||||
userTookOver = false;
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("turn_start", (event) => {
|
||||
recorder.turnStart((event as any).turnIndex);
|
||||
});
|
||||
|
||||
pi.on("turn_end", (event) => {
|
||||
recorder.turnEnd((event as any).turnIndex);
|
||||
});
|
||||
|
||||
pi.on("before_provider_request", () => {
|
||||
recorder.beforeProviderRequest();
|
||||
});
|
||||
|
||||
pi.on("after_provider_response", () => {
|
||||
recorder.afterProviderResponse();
|
||||
});
|
||||
|
||||
pi.on("message_update", (event) => {
|
||||
recorder.messageUpdate((event as any).assistantMessageEvent?.type);
|
||||
});
|
||||
|
||||
pi.on("tool_execution_start", (event) => {
|
||||
recorder.toolExecutionStart((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("tool_call", (event) => {
|
||||
recorder.toolCall((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("tool_execution_update", (event) => {
|
||||
recorder.toolExecutionUpdate((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("tool_result", (event) => {
|
||||
recorder.toolResult((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("tool_execution_end", (event) => {
|
||||
recorder.toolExecutionEnd((event as any).toolCallId, (event as any).toolName);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", (event) => {
|
||||
recorder.sessionShutdown((event as any).reason);
|
||||
});
|
||||
|
||||
// Toggle expand/collapse with Ctrl+Alt+O
|
||||
pi.registerShortcut("ctrl+alt+o", {
|
||||
description: "Toggle subagent tools widget",
|
||||
handler: (ctx) => {
|
||||
expanded = !expanded;
|
||||
renderWidget(ctx, null);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "ask_question",
|
||||
label: "ask_question",
|
||||
description:
|
||||
"Ask the orchestrator (the parent agent that spawned you) a single question and pause until they reply. " +
|
||||
"Use this when requirements are ambiguous, a decision would materially affect your work, you're blocked, " +
|
||||
"or you need information or confirmation only the orchestrator has. Prefer asking over guessing. " +
|
||||
"Your session stays open while you wait — the answer arrives as your next message, then you continue. " +
|
||||
"Ask exactly one question per call; make separate calls for unrelated questions.",
|
||||
promptSnippet:
|
||||
"Use this tool to ask the orchestrator one clarifying, missing-requirement, preference, or decision question before continuing — instead of guessing.",
|
||||
promptGuidelines: [
|
||||
"Ask exactly one question per tool call.",
|
||||
"If you need answers to multiple things, make separate ask_question calls instead of bundling them.",
|
||||
"Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.",
|
||||
"Use it when multiple valid paths exist and the right one depends on the orchestrator's intent.",
|
||||
"Give enough context in the question that the orchestrator can answer without re-reading your whole task.",
|
||||
"After asking, stop and wait — the reply will arrive as your next message.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
question: Type.String({
|
||||
description:
|
||||
"The single freeform question to ask the orchestrator. Include enough context to answer it directly.",
|
||||
}),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const sessionFile = process.env.PI_SUBAGENT_SESSION;
|
||||
if (!sessionFile) {
|
||||
throw new Error(
|
||||
"ask_question is only available in subagent contexts. " +
|
||||
"PI_SUBAGENT_SESSION environment variable is not set.",
|
||||
);
|
||||
}
|
||||
|
||||
// Keep the session open: suppress auto-exit for this turn and park in the
|
||||
// "waiting" phase. The parent's watcher picks up the `.ask` signal and
|
||||
// notifies the orchestrator, who replies via subagent_message.
|
||||
awaitingAnswer = true;
|
||||
recorder.askQuestion();
|
||||
const askData = {
|
||||
name: process.env.PI_SUBAGENT_NAME ?? "subagent",
|
||||
agent: process.env.PI_SUBAGENT_AGENT ?? "",
|
||||
question: params.question,
|
||||
};
|
||||
writeFileSync(`${sessionFile}.ask`, JSON.stringify(askData));
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Question sent to the orchestrator. Stop here and wait — do not continue working or " +
|
||||
"assume an answer. Their reply will arrive as your next message.",
|
||||
},
|
||||
],
|
||||
details: { question: params.question },
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
const text =
|
||||
theme.fg("toolTitle", theme.bold("ask_question ")) +
|
||||
theme.fg("muted", String((args as any).question ?? ""));
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Backend-neutral terminal surface facade.
|
||||
*
|
||||
* Herdr is selected when Pi is running in a Herdr-managed pane, Orca when Pi
|
||||
* is running in an Orca-managed terminal. tmux remains the fallback when Pi is
|
||||
* running inside tmux. Selection is automatic so a process uses one backend
|
||||
* consistently for its whole lifetime.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import * as herdr from "./herdr.ts";
|
||||
import * as orca from "./orca.ts";
|
||||
import * as tmux from "./tmux.ts";
|
||||
|
||||
export type MuxBackend = "herdr" | "orca" | "tmux";
|
||||
type Direction = "left" | "right" | "up" | "down";
|
||||
|
||||
type SurfaceBackend = {
|
||||
createSurface(name: string): string;
|
||||
createSurfaceSplit(name: string, direction: Direction, fromSurface?: string): string;
|
||||
sendCommand(surface: string, command: string): void;
|
||||
readScreen(surface: string, lines?: number): string;
|
||||
readScreenAsync(surface: string, lines?: number): Promise<string>;
|
||||
closeSurface(surface: string): void;
|
||||
};
|
||||
|
||||
export function selectMuxBackend(availability: {
|
||||
herdr: boolean;
|
||||
orca: boolean;
|
||||
tmux: boolean;
|
||||
}): MuxBackend | null {
|
||||
if (availability.herdr) return "herdr";
|
||||
if (availability.orca) return "orca";
|
||||
if (availability.tmux) return "tmux";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMuxBackend(): MuxBackend | null {
|
||||
return selectMuxBackend({
|
||||
herdr: herdr.isAvailable(),
|
||||
orca: orca.isAvailable(),
|
||||
tmux: tmux.isAvailable(),
|
||||
});
|
||||
}
|
||||
|
||||
function getBackend(): SurfaceBackend {
|
||||
const backend = getMuxBackend();
|
||||
if (backend === "herdr") return herdr;
|
||||
if (backend === "orca") return orca;
|
||||
if (backend === "tmux") return tmux;
|
||||
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
|
||||
}
|
||||
|
||||
export function isMuxAvailable(): boolean {
|
||||
return getMuxBackend() !== null;
|
||||
}
|
||||
|
||||
export function muxSetupHint(): string {
|
||||
if (process.env.HERDR_ENV === "1") return herdr.setupHint();
|
||||
if (process.env.ORCA_TERMINAL_HANDLE) return orca.setupHint();
|
||||
if (process.env.TMUX) return tmux.setupHint();
|
||||
return "Start pi inside Herdr, Orca, or tmux.";
|
||||
}
|
||||
|
||||
export function createSurface(name: string): string {
|
||||
return getBackend().createSurface(name);
|
||||
}
|
||||
|
||||
export function createSurfaceSplit(
|
||||
name: string,
|
||||
direction: Direction,
|
||||
fromSurface?: string,
|
||||
): string {
|
||||
return getBackend().createSurfaceSplit(name, direction, fromSurface);
|
||||
}
|
||||
|
||||
export function sendCommand(surface: string, command: string): void {
|
||||
getBackend().sendCommand(surface, command);
|
||||
}
|
||||
|
||||
export function readScreen(surface: string, lines = 50): string {
|
||||
return getBackend().readScreen(surface, lines);
|
||||
}
|
||||
|
||||
export function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||
return getBackend().readScreenAsync(surface, lines);
|
||||
}
|
||||
|
||||
export function closeSurface(surface: string): void {
|
||||
getBackend().closeSurface(surface);
|
||||
}
|
||||
|
||||
export function shellEscape(value: string): string {
|
||||
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
export function wrapWithCompletionFile(command: string, completionFile: string): string {
|
||||
return (
|
||||
`${command}; exit_code=$?; ` +
|
||||
`printf '%s\\n' "$exit_code" > ${shellEscape(completionFile)}; ` +
|
||||
`echo "__SUBAGENT_DONE_\${exit_code}__"`
|
||||
);
|
||||
}
|
||||
|
||||
export function sendLongCommand(
|
||||
surface: string,
|
||||
command: string,
|
||||
options?: { scriptPath?: string; scriptPreamble?: string },
|
||||
): string {
|
||||
const scriptPath =
|
||||
options?.scriptPath ??
|
||||
join(
|
||||
tmpdir(),
|
||||
"pi-subagent-scripts",
|
||||
`cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
|
||||
);
|
||||
mkdirSync(dirname(scriptPath), { recursive: true });
|
||||
|
||||
const scriptParts = ["#!/bin/bash"];
|
||||
if (options?.scriptPreamble) scriptParts.push(options.scriptPreamble.trimEnd());
|
||||
scriptParts.push(command);
|
||||
|
||||
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
|
||||
sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
export interface PollResult {
|
||||
reason: "done" | "sentinel" | "error";
|
||||
exitCode: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
function interpretExitSidecar(data: unknown): PollResult {
|
||||
const payload = data as { type?: unknown; errorMessage?: unknown } | null;
|
||||
if (payload?.type === "error") {
|
||||
const errorMessage =
|
||||
typeof payload.errorMessage === "string" && payload.errorMessage.trim() !== ""
|
||||
? payload.errorMessage
|
||||
: "Subagent exited with stopReason=error (no errorMessage in sidecar).";
|
||||
return { reason: "error", exitCode: 1, errorMessage };
|
||||
}
|
||||
return { reason: "done", exitCode: 0 };
|
||||
}
|
||||
|
||||
function readCompletionFile(path: string): PollResult | null {
|
||||
if (!existsSync(path)) return null;
|
||||
const raw = readFileSync(path, "utf8").trim();
|
||||
const exitCode = Number.parseInt(raw, 10);
|
||||
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(exitCode)) return null;
|
||||
rmSync(path, { force: true });
|
||||
return { reason: "sentinel", exitCode };
|
||||
}
|
||||
|
||||
export const __pollForExitTest__ = { interpretExitSidecar, readCompletionFile };
|
||||
|
||||
export async function pollForExit(
|
||||
surface: string,
|
||||
signal: AbortSignal,
|
||||
options: {
|
||||
interval: number;
|
||||
sessionFile?: string;
|
||||
sentinelFile?: string;
|
||||
completionFile?: string;
|
||||
onTick?: (elapsed: number) => void;
|
||||
},
|
||||
): Promise<PollResult> {
|
||||
const start = Date.now();
|
||||
|
||||
for (;;) {
|
||||
if (signal.aborted) throw new Error("Aborted while waiting for subagent to finish");
|
||||
|
||||
if (options.sessionFile) {
|
||||
try {
|
||||
const exitFile = `${options.sessionFile}.exit`;
|
||||
if (existsSync(exitFile)) {
|
||||
const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8")));
|
||||
rmSync(exitFile, { force: true });
|
||||
return result;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (options.sentinelFile) {
|
||||
try {
|
||||
if (existsSync(options.sentinelFile)) {
|
||||
return { reason: "sentinel", exitCode: 0 };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (options.completionFile) {
|
||||
try {
|
||||
const result = readCompletionFile(options.completionFile);
|
||||
if (result) return result;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const screen = await readScreenAsync(surface, 5);
|
||||
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
|
||||
if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
|
||||
} catch {
|
||||
// Surface reads are best-effort. Filesystem completion remains authoritative.
|
||||
}
|
||||
|
||||
const elapsed = Math.floor((Date.now() - start) / 1000);
|
||||
options.onTick?.(elapsed);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (signal.aborted) return reject(new Error("Aborted"));
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, options.interval);
|
||||
function onAbort() {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Aborted"));
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* tmux surface backend.
|
||||
*
|
||||
* This module only implements tmux-specific terminal operations. Backend
|
||||
* selection, command scripts, and exit polling live in surface.ts.
|
||||
*/
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const commandAvailability = new Map<string, boolean>();
|
||||
|
||||
function hasCommand(command: string): boolean {
|
||||
const cached = commandAvailability.get(command);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let available = false;
|
||||
try {
|
||||
execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
|
||||
available = true;
|
||||
} catch {
|
||||
available = false;
|
||||
}
|
||||
|
||||
commandAvailability.set(command, available);
|
||||
return available;
|
||||
}
|
||||
|
||||
export function isAvailable(): boolean {
|
||||
return !!process.env.TMUX && hasCommand("tmux");
|
||||
}
|
||||
|
||||
export function setupHint(): string {
|
||||
return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
|
||||
}
|
||||
|
||||
function requireTmux(): void {
|
||||
if (!isAvailable()) {
|
||||
throw new Error(`tmux is required for this surface. ${setupHint()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** tmux layout used to keep sibling panes evenly sized. */
|
||||
const SUBAGENT_TMUX_LAYOUT = "even-horizontal";
|
||||
let rebalanceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function rebalanceSurfaces(hintPane?: string): void {
|
||||
const target = process.env.TMUX_PANE ?? hintPane;
|
||||
if (!target) return;
|
||||
if (rebalanceTimer) clearTimeout(rebalanceTimer);
|
||||
rebalanceTimer = setTimeout(() => {
|
||||
rebalanceTimer = null;
|
||||
try {
|
||||
execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
} catch {
|
||||
// Pane/window may be gone; balancing is best-effort.
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
export function createSurface(name: string): string {
|
||||
return createSurfaceSplit(name, "right", process.env.TMUX_PANE);
|
||||
}
|
||||
|
||||
export function createSurfaceSplit(
|
||||
name: string,
|
||||
direction: "left" | "right" | "up" | "down",
|
||||
fromSurface?: string,
|
||||
): string {
|
||||
void name;
|
||||
requireTmux();
|
||||
|
||||
const args = ["split-window", "-d"];
|
||||
args.push(direction === "left" || direction === "right" ? "-h" : "-v");
|
||||
if (direction === "left" || direction === "up") args.push("-b");
|
||||
if (fromSurface) args.push("-t", fromSurface);
|
||||
args.push("-P", "-F", "#{pane_id}");
|
||||
|
||||
const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
|
||||
if (!pane.startsWith("%")) {
|
||||
throw new Error(`Unexpected tmux split-window output: ${pane}`);
|
||||
}
|
||||
|
||||
rebalanceSurfaces(pane);
|
||||
return pane;
|
||||
}
|
||||
|
||||
export function sendCommand(surface: string, command: string): void {
|
||||
requireTmux();
|
||||
execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
|
||||
execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
export function readScreen(surface: string, lines = 50): string {
|
||||
requireTmux();
|
||||
return execFileSync(
|
||||
"tmux",
|
||||
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||
requireTmux();
|
||||
const { stdout } = await execFileAsync(
|
||||
"tmux",
|
||||
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export function closeSurface(surface: string): void {
|
||||
requireTmux();
|
||||
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
||||
rebalanceSurfaces();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Safe bash extension for the worker subagent.
|
||||
* Wraps the built-in bash tool with dangerous command blocking.
|
||||
*
|
||||
* Loaded into a child pi process via `--extension` when an agent's `tools`
|
||||
* frontmatter lists `safe_bash`. See CUSTOM_TOOL_EXTENSIONS in ../index.ts.
|
||||
*/
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { createBashTool } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
const DANGEROUS_PATTERNS = [
|
||||
/\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
|
||||
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
|
||||
/\bsudo\b/,
|
||||
/\bmkfs\b/,
|
||||
/\bdd\s+if=/,
|
||||
/:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
|
||||
/>\s*\/dev\/[sh]d[a-z]/,
|
||||
/\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
|
||||
/\bchown\s+(-[a-zA-Z]+\s+)?root/,
|
||||
/\bcurl\s.*\|\s*(ba)?sh/,
|
||||
/\bwget\s.*\|\s*(ba)?sh/,
|
||||
/\bshutdown\b/,
|
||||
/\breboot\b/,
|
||||
/\binit\s+0\b/,
|
||||
/\bkill\s+-9\s+1\b/,
|
||||
/\bkillall\b/,
|
||||
];
|
||||
|
||||
function isDangerous(command: string): string | null {
|
||||
const normalized = command.replace(/\\\n/g, " ");
|
||||
for (const pattern of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(normalized)) {
|
||||
return `Command blocked by safe_bash: matches dangerous pattern ${pattern}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const bashTool = createBashTool(process.cwd());
|
||||
|
||||
pi.registerTool({
|
||||
name: "safe_bash",
|
||||
label: "Safe Bash",
|
||||
description:
|
||||
"Execute a bash command. Blocks dangerous commands (rm -rf /, sudo, mkfs, etc.).",
|
||||
parameters: Type.Object({
|
||||
command: Type.String({ description: "Bash command to execute" }),
|
||||
timeout: Type.Optional(
|
||||
Type.Number({ description: "Timeout in seconds (optional)" }),
|
||||
),
|
||||
}),
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
const danger = isDangerous(params.command);
|
||||
if (danger) {
|
||||
throw new Error(danger);
|
||||
}
|
||||
return bashTool.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user