/** * `/agent-board` command: opens the dashboard, runs its action loop, and attaches through * agent-board PTY hosts for fast switching. `ctx.switchSession` is only a no-PTY fallback. * Also wires dispatch+attach and stale-row recovery on open. */ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui"; import { requestDashboardRender } from "../core/dashboard-render.mjs"; import { createService } from "../runtime/service.mjs"; import { planAttachPrelude, planAttachResolved } from "./attach-decision.mjs"; import { screenLogPath } from "../core/paths.mjs"; import { DashboardComponent, type DashboardResult } from "../ui/dashboard.js"; import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.js"; const POLL_MS = 700; export interface AgentBoardCommandOptions { root: string; runnerScript: string; ptyRunnerScript?: string; titleRunnerScript?: string; piCommand: string; piArgsPrefix: string[]; getThinkingLevel: () => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export function registerAgentBoardCommand(pi: ExtensionAPI, opts: AgentBoardCommandOptions): void { pi.registerCommand("agent-board", { description: "Open the background agent-board dashboard", handler: async (args, ctx) => { const attachMatch = /(?:^|\s)--attach\s+(\S+)/.exec(args); const stopFirst = /(^|\s)--stop(\s|$)/.test(args); const service = createService({ root: opts.root, runnerScript: opts.runnerScript, ptyRunnerScript: opts.ptyRunnerScript, titleRunnerScript: opts.titleRunnerScript, piCommand: opts.piCommand, piArgsPrefix: opts.piArgsPrefix, defaultCwd: ctx.cwd, // Stale-defaultModel launch guard (issue #90): live list, undefined // when the registry is unavailable so validation conservatively skips. availableModels: () => { try { return ctx.modelRegistry.getAvailable(); } catch { return undefined; } }, }); if (!ctx.hasUI) { ctx.ui.notify("The agent-board dashboard requires interactive mode.", "warning"); return; } if (attachMatch) { const outcome = await attach(ctx, service, opts.root, attachMatch[1], stopFirst); if (outcome.action !== "switched") await dashboardAttachLoop(ctx, service, opts.root, attachMatch[1], opts.getThinkingLevel); return; } await dashboardAttachLoop(ctx, service, opts.root, null, opts.getThinkingLevel); }, }); } export async function openDashboard( ctx: Pick, service: ReturnType, options: { initialSelectedId?: string | null; currentThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; } = {}, ): Promise { ctx.ui.setWorkingVisible(false); ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} })); ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} })); ctx.ui.setTitle("agent-board"); let availableModels: any[] = []; try { availableModels = ctx.modelRegistry.getAvailable(); } catch { availableModels = []; } try { return await ctx.ui.custom( (tui, theme, keybindings, done) => { let interval: ReturnType | null = null; const wrappedDone = (result: DashboardResult) => { if (interval) clearInterval(interval); interval = null; done(result); }; const comp = new DashboardComponent(tui, theme as never, keybindings, wrappedDone, { service, root: service.getRoot(), defaultCwd: ctx.cwd, initialSelectedId: options.initialSelectedId, availableModels, currentModel: ctx.model ?? null, currentThinkingLevel: options.currentThinkingLevel ?? "off", }); interval = setInterval(() => { void service.reconcile().catch(() => {}); comp.refresh(); requestDashboardRender(tui); }, POLL_MS); const withDispose = comp as DashboardComponent & { dispose: () => void }; // Chain the component's own dispose so its cleanup (prewarm scheduler // cancel) still runs; a plain assignment would shadow the prototype method. const origDispose = comp.dispose.bind(comp); withDispose.dispose = () => { origDispose(); if (interval) clearInterval(interval); interval = null; }; return comp; }, { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 }, }, ); } finally { ctx.ui.setHeader(undefined); ctx.ui.setFooter(undefined); ctx.ui.setWorkingVisible(true); } } type AttachOutcome = { action: "detached" | "closed" | "switched" | "none" }; export async function dashboardAttachLoop( ctx: ExtensionCommandContext, service: ReturnType, root: string, initialSelectedId: string | null, getThinkingLevel?: () => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", ): Promise { let selectedId = initialSelectedId; let again = true; while (again) { const currentId = currentViewId(ctx, service); if (currentId) void service.markVisited?.(currentId)?.catch(() => {}); void service.reconcile().catch(() => {}); const result = await openDashboard(ctx, service, { initialSelectedId: selectedId, currentThinkingLevel: getThinkingLevel?.(), }); if (result.action !== "attach") return; selectedId = result.viewId; const outcome = await attach(ctx, service, root, result.viewId, result.stopFirst); again = outcome.action === "detached" || outcome.action === "closed" || outcome.action === "none"; } } async function attach( ctx: ExtensionCommandContext, service: ReturnType, root: string, viewId: string, stopFirst: boolean, ): Promise { const row = service.row(viewId); if (!row) { ctx.ui.notify("Session no longer exists.", "warning"); return { action: "none" }; } const prelude = planAttachPrelude({ rowAlive: row.alive, rowHostActive: row.hostActive, stopFirst }); if (prelude.plan === "warn-running") { ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning"); return { action: "none" }; } if (prelude.plan === "stop-first") { service.stop(viewId); // Give the runner a moment to terminate the worker and release the session file. await sleep(500); } // Single async resolver entry: real probe + bounded recovery decide readiness (issue #70). const plan = planAttachResolved(await service.resolveAttachTarget(viewId)); if (plan.plan === "open-pty") { void service.markVisited?.(viewId)?.catch(() => {}); const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath); void service.markVisited?.(viewId)?.catch(() => {}); return { action: result.action === "closed" ? "closed" : "detached" }; } if (plan.plan === "session-switch") { const latest = service.row(viewId) ?? row; if (!existsSync(latest.meta.sessionFile)) { ctx.ui.notify("Session file isn't ready yet — try again once the run has started.", "warning"); return { action: "none" }; } const name = latest.meta.name; void service.markVisited?.(viewId)?.catch(() => {}); const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable"); const result = await ctx.switchSession(latest.meta.sessionFile, { withSession: async (replaced) => { replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info"); installBackToDashboard(replaced, service); }, }).finally(() => { try { switchingOverlay?.hide(); } catch {} }); if (result.cancelled) { ctx.ui.notify("Attach cancelled.", "warning"); return { action: "none" }; } return { action: "switched" }; } if (plan.plan === "notify-pending") { ctx.ui.notify(plan.reason, "info"); return { action: "none" }; } ctx.ui.notify("Session no longer exists.", "warning"); return { action: "none" }; } async function openPtyAttach( ctx: ExtensionCommandContext, root: string, viewId: string, name: string, socketPath: string, ): Promise { ctx.ui.setWorkingVisible(false); ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} })); ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} })); ctx.ui.setTitle(`agent-board: ${name}`); try { return await ctx.ui.custom( (tui, theme, keybindings, done) => new PtyAttachComponent(tui, theme as never, keybindings, done, { socketPath, screenLogPath: root ? screenLogPath(root, viewId) : undefined, title: name, }), { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 }, }, ); } finally { ctx.ui.setHeader(undefined); ctx.ui.setFooter(undefined); ctx.ui.setWorkingVisible(true); } } async function showSwitchingOverlay(ctx: ExtensionCommandContext, name: string, reason: string): Promise<{ hide(): void } | null> { let handle: { hide(): void } | null = null; void ctx.ui.custom( (tui, theme) => ({ render(width: number): string[] { const height = tui.terminal?.rows ?? 24; const out = Array.from({ length: Math.max(0, Math.floor(height / 2) - 2) }, () => ""); out.push(clipLine(theme.fg("accent", theme.bold(`Switching to "${name}"…`)), width)); out.push(clipLine(theme.fg("dim", `Starting fallback session switch (${reason})`), width)); while (out.length < height) out.push(""); return out; }, invalidate() {}, }), { overlay: true, overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 }, onHandle: (h) => { handle = h; }, }, ); await sleep(50); return handle; } function clipLine(text: string, width: number): string { return truncateToWidth(text, width); } function installBackToDashboard(ctx: ExtensionCommandContext, service: ReturnType): void { ctx.ui.setStatus("agent-board.back", ctx.ui.theme.fg("muted", "← board")); let opening = false; ctx.ui.onTerminalInput((data: string) => { if (opening || !matchesKey(data, Key.left)) return undefined; // Do not steal normal cursor-left while the user is composing a message. if (ctx.ui.getEditorText().length > 0) return undefined; opening = true; void (async () => { try { let selectedId = currentViewId(ctx, service); while (true) { if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {}); void service.reconcile().catch(() => {}); const result = await openDashboard(ctx, service, { initialSelectedId: selectedId }); if (result.action !== "attach") return; selectedId = result.viewId; const target = service.row(result.viewId); const currentSessionFile = ctx.sessionManager.getSessionFile(); if (target && currentSessionFile && samePath(target.meta.sessionFile, currentSessionFile)) { ctx.ui.notify("Already attached to this session.", "info"); continue; } const outcome = await attach(ctx, service, service.root, result.viewId, result.stopFirst); if (outcome.action === "switched") return; } } catch (err) { ctx.ui.notify(`Couldn't open agent board: ${err instanceof Error ? err.message : String(err)}`, "error"); } finally { opening = false; } })(); return { consume: true }; }); } function currentViewId(ctx: ExtensionCommandContext, service: ReturnType): string | null { const currentSessionFile = ctx.sessionManager.getSessionFile(); if (!currentSessionFile) return null; return service.rows().find((r) => samePath(r.meta.sessionFile, currentSessionFile))?.meta.id ?? null; } function samePath(a: string, b: string): boolean { return resolve(a) === resolve(b); }