/** * Orchestrator-side glue for the worker fleet: the Supervisor's host info, the * reconciliation pass on `session_start`, and the fleet widget refresh. */ import * as path from "node:path"; import { parseArgs } from "@earendil-works/pi-coding-agent"; import type { ExtensionContext, SessionShutdownEvent } from "@earendil-works/pi-coding-agent"; import { type WorkerConfig, loadWorkerConfig } from "./config.ts"; import { bootstrapProfiles, loadProfiles } from "./profiles.ts"; import { type ReconcileOutcome, gitignoreRuntime, loadAllRuns, reconcile, recoveryBlock } from "./registry.ts"; import { Supervisor, type SupervisorHostInfo } from "./supervisor.ts"; import { STATUS_POLL_MS, type RunStatus } from "./status.ts"; import { WIDGET_KEY, renderFleetWidget } from "./widget.ts"; /** * R-WORK-2: a worker inherits the parent's `-e` CLI extensions, so a user's custom * tooling is present in the worker too. * * pi exposes no ExtensionAPI accessor for the current session's `-e` paths (there is * no `getExtensionPaths()`; `ExtensionRuntime` keeps them internally). The * orchestrator's own `process.argv` is the same input pi itself parses in `cli.ts`, * and `parseArgs` is exported, so the parent's flags are read with pi's own parser * rather than a hand-rolled scan that would drift from it. * * Consequence, stated because it is a real limitation: an extension loaded by * *discovery* (settings, `~/.pi/agent/extensions/`) is not in argv, so it is * inherited through normal discovery in the child, which is the intended behavior. */ export function inheritedCliOptions(argv: string[] = process.argv.slice(2)): { extensionPaths: string[]; sessionDir: string | undefined; } { try { const parsed = parseArgs(argv); return { extensionPaths: (parsed.extensions ?? []).map((entry) => path.resolve(entry)), sessionDir: parsed.sessionDir, }; } catch { return { extensionPaths: [], sessionDir: undefined }; } } export interface FleetController { supervisor(): Supervisor | undefined; config(ctx: ExtensionContext): WorkerConfig; /** Called on toggle ON. Bootstraps profiles and starts the widget. */ activate(ctx: ExtensionContext): void; deactivate(ctx: ExtensionContext): void; /** §10.7 on session_start. Returns the minimal recovery delta when there is one. */ reconcileOnStart(ctx: ExtensionContext): string | undefined; refresh(ctx: ExtensionContext): void; shutdown(ctx: ExtensionContext, reason: SessionShutdownEvent["reason"]): void; /** Pending recovery delta, consumed once by a minimal startup/wake context block. */ takeRecoveryBlock(): string | undefined; lastReconcile(): ReconcileOutcome | undefined; } export interface FleetOptions { /** Phase 5: the Supervisor's terminal-state callback, where a wake is armed. */ onRunTerminal?: (runId: string, status: RunStatus) => void; } export function createFleetController(options: FleetOptions = {}): FleetController { let supervisor: Supervisor | undefined; let timer: ReturnType | undefined; let lastSignature: string | undefined; let pendingRecovery: string | undefined; let outcome: ReconcileOutcome | undefined; let liveCtx: ExtensionContext | undefined; let configCache: { cwd: string; trusted: boolean; config: WorkerConfig } | undefined; const notifiedProfileWarnings = new Set(); function config(ctx: ExtensionContext): WorkerConfig { const trusted = (() => { try { return ctx.isProjectTrusted(); } catch { return false; } })(); if (configCache !== undefined && configCache.cwd === ctx.cwd && configCache.trusted === trusted) return configCache.config; const loaded = loadWorkerConfig(ctx.cwd, { isProjectTrusted: () => trusted }); configCache = { cwd: ctx.cwd, trusted, config: loaded }; return loaded; } function hostInfo(ctx: ExtensionContext): SupervisorHostInfo { const cli = inheritedCliOptions(); const profiles = loadProfiles(ctx.cwd, { isProjectTrusted: () => ctx.isProjectTrusted() }); const model = ctx.model; return { cwd: ctx.cwd, config: config(ctx), profiles: profiles.profiles, // The worker always receives the parent's exact provider/model. ...(model === undefined ? {} : { inheritedModel: `${model.provider}/${model.id}` }), ...(ctx.thinkingLevel === undefined ? {} : { inheritedThinking: ctx.thinkingLevel }), inheritedExtensionPaths: cli.extensionPaths, ...(cli.sessionDir === undefined ? {} : { sessionDir: cli.sessionDir }), // R-CTRL-18: every run this orchestrator starts is stamped with this id, so // bulk control can tell its own work from a concurrent session's. ...(() => { try { const sessionId = ctx.sessionManager.getSessionId(); return typeof sessionId === "string" && sessionId.length > 0 ? { sessionId } : {}; } catch { return {}; } })(), // The orchestrator is depth 0 (R-CONC-8). PI_AGI_DEPTH is only set in a // worker, which never reaches this code path (the role guard returns first). depth: Number(process.env.PI_AGI_DEPTH ?? "0") || 0, }; } function setWidget(ctx: ExtensionContext, lines: string[] | undefined): void { // The widget is cosmetic. A host whose ui surface lacks setWidget (an embedder, // a partial harness) must not be able to break mode activation over it, and the // R-UI-18 clear path runs on shutdown where a throw would leak a stale widget. try { ctx.ui.setWidget(WIDGET_KEY, lines); } catch { // Nothing to recover: there is no widget surface here. } } function refresh(ctx: ExtensionContext): void { liveCtx = ctx; supervisor?.refreshControlState(); const workerConfig = config(ctx); if (!workerConfig.widget) { if (lastSignature !== undefined) { setWidget(ctx, undefined); lastSignature = undefined; } return; } const { entries } = loadAllRuns(ctx.cwd); const summary = renderFleetWidget(entries, ctx, { maxRows: workerConfig.widgetMaxRows }); // R-UI-14: only request a render when the content actually changed, otherwise a // 500ms interval repaints the whole TUI forever. if (summary.signature === lastSignature) return; lastSignature = summary.signature; setWidget(ctx, summary.lines); } return { supervisor: () => supervisor, config, lastReconcile: () => outcome, takeRecoveryBlock() { const value = pendingRecovery; pendingRecovery = undefined; return value; }, activate(ctx) { gitignoreRuntime(ctx.cwd); try { // R-WORK-15: never overwrites, so a user's edits survive every activation. bootstrapProfiles(); } catch (error) { ctx.ui.notify(`AGI: agent profiles could not be written to ~/.pi/agent/agi/agents/ (${(error as Error).message}). Built-in profiles are still available.`, "warning"); } const profiles = loadProfiles(ctx.cwd, { isProjectTrusted: () => ctx.isProjectTrusted() }); if (profiles.problems.length > 0) { ctx.ui.notify( `AGI: ${profiles.problems.length} agent profile(s) could not be loaded and were skipped:\n` + profiles.problems.map((problem) => ` ${problem.path}: ${problem.reason}`).join("\n"), "warning", ); } const newWarnings = profiles.warnings.filter((warning) => { const key = `${warning.path}\0${warning.reason}`; if (notifiedProfileWarnings.has(key)) return false; notifiedProfileWarnings.add(key); return true; }); if (newWarnings.length > 0) { ctx.ui.notify( `AGI: ${newWarnings.length} legacy agent profile setting(s) were ignored:\n` + newWarnings.map((warning) => ` ${warning.path}: ${warning.reason}`).join("\n"), "warning", ); } supervisor = new Supervisor( hostInfo(ctx), () => { if (liveCtx !== undefined) refresh(liveCtx); }, (runId, status) => options.onRunTerminal?.(runId, status), ); liveCtx = ctx; if (timer === undefined && ctx.hasUI) { timer = setInterval(() => { if (liveCtx !== undefined) refresh(liveCtx); }, STATUS_POLL_MS); timer.unref?.(); } refresh(ctx); }, deactivate(ctx) { if (timer !== undefined) { clearInterval(timer); timer = undefined; } setWidget(ctx, undefined); lastSignature = undefined; // Detach every pump before dropping the Supervisor. Without this the pumps // stay attached to their children's stdout/exit events and keep writing // status.json for runs nothing owns any more, while a later toggle ON builds // a fresh Supervisor whose empty pump map cannot see — or stop — them. // // Detach rather than shutdown: toggling the mode off is not an instruction to // destroy in-flight work. The runs stay on disk exactly as they would after a // crash, so §10.7 reconciliation adopts or orphans them on the next activate. supervisor?.detachAll(); supervisor = undefined; }, reconcileOnStart(ctx) { // R-CTRL-31/R-CTRL-18: an adopted run becomes this session's responsibility, // so it is re-stamped with this orchestrator's ownership as it is adopted. const adopter = supervisor?.ownerIdentity(); outcome = reconcile(ctx.cwd, adopter === undefined ? {} : { owner: adopter }); pendingRecovery = recoveryBlock(outcome); if (supervisor !== undefined) supervisor.updateHost(hostInfo(ctx)); return pendingRecovery; }, refresh, shutdown(ctx, reason) { // R-CTRL-18 is unconditional across shutdown reasons. The stop files are // authoritative; shutdown adds bounded identity-validated group teardown. supervisor?.shutdown(); if (timer !== undefined) { clearInterval(timer); timer = undefined; } setWidget(ctx, undefined); lastSignature = undefined; supervisor = undefined; liveCtx = undefined; }, }; }