152 lines
4.1 KiB
TypeScript
152 lines
4.1 KiB
TypeScript
import * as path from "node:path";
|
|
import {
|
|
DefaultResourceLoader,
|
|
getAgentDir,
|
|
ProjectTrustStore,
|
|
SettingsManager,
|
|
type AgentSession,
|
|
type SessionShutdownEvent,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
|
|
const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
|
|
/** Tools that headless children must not receive. Everything else stays enabled. */
|
|
export const CHILD_EXCLUDED_TOOL_NAMES = [
|
|
"subagent_spawn",
|
|
"subagent_wait",
|
|
"subagent_cancel",
|
|
"subagent_check",
|
|
"subagent_list",
|
|
"workflow",
|
|
"ask_user",
|
|
] as const;
|
|
|
|
/** Fresh SDK options avoid turning the denylist into an accidental allowlist. */
|
|
export function childToolPolicy() {
|
|
return { excludeTools: [...CHILD_EXCLUDED_TOOL_NAMES] };
|
|
}
|
|
|
|
export interface ChildResourceOptions {
|
|
cwd: string;
|
|
projectTrusted: boolean;
|
|
appendSystemPrompt?: string[];
|
|
agentDir?: string;
|
|
}
|
|
|
|
/** Load normal global/package resources and trust-gated project resources. */
|
|
export async function createChildResources(options: ChildResourceOptions) {
|
|
const agentDir = options.agentDir ?? getAgentDir();
|
|
const settingsManager = SettingsManager.create(options.cwd, agentDir, {
|
|
projectTrusted: options.projectTrusted,
|
|
});
|
|
const loader = new DefaultResourceLoader({
|
|
cwd: options.cwd,
|
|
agentDir,
|
|
settingsManager,
|
|
...(options.appendSystemPrompt
|
|
? { appendSystemPrompt: options.appendSystemPrompt }
|
|
: {}),
|
|
});
|
|
await loader.reload();
|
|
return { loader, settingsManager };
|
|
}
|
|
|
|
/**
|
|
* Same-directory children inherit the live parent decision. An alternate cwd
|
|
* is trusted only when Pi's persisted trust store explicitly trusts it (or a
|
|
* containing directory); unreadable/invalid trust data fails closed.
|
|
*/
|
|
export function resolveStandaloneChildProjectTrust(options: {
|
|
parentCwd: string;
|
|
childCwd: string;
|
|
parentTrusted: boolean;
|
|
agentDir?: string;
|
|
}) {
|
|
if (path.resolve(options.childCwd) === path.resolve(options.parentCwd)) {
|
|
return options.parentTrusted;
|
|
}
|
|
try {
|
|
const trustStore = new ProjectTrustStore(options.agentDir ?? getAgentDir());
|
|
return trustStore.get(options.childCwd) === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Start child extension session hooks/resources in headless print mode. */
|
|
export async function bindChildSessionExtensions(
|
|
session: Pick<AgentSession, "bindExtensions">,
|
|
) {
|
|
await session.bindExtensions({ mode: "print" });
|
|
}
|
|
|
|
interface ChildExtensionRunner {
|
|
hasHandlers(eventType: string): boolean;
|
|
emit(event: SessionShutdownEvent): Promise<unknown>;
|
|
}
|
|
|
|
export interface DisposableChildSession {
|
|
readonly extensionRunner: ChildExtensionRunner;
|
|
dispose(): void;
|
|
}
|
|
|
|
const childShutdowns = new WeakMap<object, Promise<void>>();
|
|
|
|
export function waitForChildOperation(
|
|
operation: Promise<unknown>,
|
|
timeoutMs = CHILD_SHUTDOWN_TIMEOUT_MS,
|
|
) {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<void>((resolve) => {
|
|
timer = setTimeout(resolve, timeoutMs);
|
|
});
|
|
return Promise.race([
|
|
operation.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
),
|
|
timeout,
|
|
])
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
if (timer) clearTimeout(timer);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Emit child session_shutdown once, then dispose once. Hook failures and a
|
|
* bounded hook deadline never prevent disposal.
|
|
*/
|
|
export function shutdownAndDisposeChildSession(
|
|
session: DisposableChildSession,
|
|
options: { timeoutMs?: number } = {},
|
|
) {
|
|
const existing = childShutdowns.get(session);
|
|
if (existing) return existing;
|
|
|
|
const shutdown = (async () => {
|
|
try {
|
|
if (session.extensionRunner.hasHandlers("session_shutdown")) {
|
|
await waitForChildOperation(
|
|
session.extensionRunner.emit({
|
|
type: "session_shutdown",
|
|
reason: "quit",
|
|
}),
|
|
options.timeoutMs ?? CHILD_SHUTDOWN_TIMEOUT_MS,
|
|
);
|
|
}
|
|
} catch {
|
|
// Extension runner inspection/emission is best-effort during teardown.
|
|
} finally {
|
|
try {
|
|
session.dispose();
|
|
} catch {
|
|
// Disposal is terminal and must remain idempotent for callers.
|
|
}
|
|
}
|
|
})();
|
|
|
|
childShutdowns.set(session, shutdown);
|
|
return shutdown;
|
|
}
|