import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { completeGoalArguments, isRemovedQueueCommand, parseCommand } from "./command.js"; import type { GoalCommandController } from "./commands.js"; import { isStaleContextError, notifyTerminal, safeTerminalText } from "./errors.js"; import type { GoalRuntime } from "./runtime.js"; type GoalManagerModule = Pick; type GoalSettingsModule = Pick; export interface GoalCommandRegistrationOptions { settingsPath?: string; loadGoalManager?: () => Promise; loadGoalSettings?: () => Promise; } export function registerGoalCommand( pi: ExtensionAPI, runtime: GoalRuntime, commands: GoalCommandController, options: GoalCommandRegistrationOptions = {}, ) { const loadGoalManager = cachedModuleLoader(options.loadGoalManager ?? (() => import("./menu.js"))); const loadGoalSettings = cachedModuleLoader(options.loadGoalSettings ?? (() => import("./settings-ui.js"))); pi.registerCommand("goal", { description: "Run a goal to completion: /goal [--tokens 100k] ", getArgumentCompletions: (prefix) => completeGoalArguments(prefix), handler: async (args, commandCtx) => { try { const ctx = withDisplayedNotices(pi, commandCtx); if (runtime.hasLegacyQueueInterface() && isRemovedQueueCommand(args)) { reportRemovedQueueCommand(ctx, runtime); return; } const result = parseCommand(args); if (typeof result === "string") { reportCommandError(result, ctx); return; } if (result.kind === "show" && args.trim() === "") { const menuIsCurrent = captureMenuOwnership(runtime); let managerModule: GoalManagerModule; try { managerModule = await loadGoalManager(); } catch (error) { if (!menuIsCurrent()) return; throw error; } if (!menuIsCurrent()) return; const { showGoalManager } = managerModule; await showGoalManager(runtime, commands, ctx, async (menuCtx, target) => { const settingsAreCurrent = captureMenuOwnership(runtime); let settingsModule: GoalSettingsModule; try { settingsModule = await loadGoalSettings(); } catch (error) { if (!settingsAreCurrent()) return; throw error; } if (!settingsAreCurrent()) return; const { showGoalSettings } = settingsModule; await showGoalSettings(runtime, menuCtx, { settingsPath: options.settingsPath, initialScreen: target, }); }); return; } switch (result.kind) { case "show": commands.showGoal(ctx); return; case "pause": commands.pauseGoal(ctx); return; case "resume": await commands.resumeGoal(ctx); return; case "clear": commands.clearGoal(ctx); return; case "edit": await commands.editGoal(result.objective ?? "", result.tokenBudget, ctx); return; case "start": await commands.startGoal(result.objective ?? "", result.tokenBudget, ctx); return; } } catch (error) { // Pi invalidates command ctx when the session is replaced while a // confirmation or menu await is still in flight. The dead session's // command result is discarded, so a stale context is not an error. if (isStaleContextError(error)) return; throw error; } }, }); } export const GOAL_NOTICE_CUSTOM_TYPE = "goal-notice"; /** * Selesai: an RPC child (the ACP bridge to T3 Code) never forwards notify bodies, so /goal feedback * would be invisible there. While the session is idle, publish it as displayed transcript text * instead. During a run it stays a notify so it cannot join that run. Error bodies can carry * internal or provider text, so only their fixed prefix ("Cannot start /goal") is displayed. */ export function withDisplayedNotices(pi: ExtensionAPI, ctx: T): T { if (ctx.mode !== "rpc") return ctx; const ui = ctx.ui; const notify = (message: string, level?: "info" | "warning" | "error") => { let idle = false; try { idle = ctx.isIdle(); } catch (error) { if (!isStaleContextError(error)) throw error; } if (!idle) { ui.notify(message, level); return; } const text = safeTerminalText(level === "error" ? `${message.split(":", 1)[0]}.` : message); pi.sendMessage({ customType: GOAL_NOTICE_CUSTOM_TYPE, content: text, display: true }); }; const displayedUi = new Proxy(ui, { get: (target, key) => (key === "notify" ? notify : bound(target, key)) }); return new Proxy(ctx, { get: (target, key) => (key === "ui" ? displayedUi : bound(target, key)) }); } function bound(target: object, key: string | symbol) { const value = Reflect.get(target, key, target); return typeof value === "function" ? value.bind(target) : value; } function reportCommandError(message: string, ctx: ExtensionCommandContext) { const safeMessage = safeTerminalText(message); if (ctx.mode === "print" || ctx.mode === "json") throw new Error(safeMessage); notifyTerminal(ctx.ui, safeMessage, "warning"); } function reportRemovedQueueCommand(ctx: ExtensionCommandContext, runtime: GoalRuntime) { const message = runtime.activeGoal ? "Ordered goal queue has been removed. Use /goal edit to reprioritize the active objective instead." : "Ordered goal queue has been removed. Start /goal to continue with one merged objective, or use /goal clear to discard the old queue state."; if (ctx.mode === "print" || ctx.mode === "json") throw new Error(message); notifyTerminal(ctx.ui, message, "warning"); } function captureMenuOwnership(runtime: GoalRuntime): () => boolean { const generation = runtime.menuGeneration; const controller = runtime.menuController; return () => runtime.menuGeneration === generation && runtime.menuController === controller && !controller.signal.aborted; } function cachedModuleLoader(load: () => Promise): () => Promise { let pending: Promise | undefined; return () => { if (!pending) { pending = load().catch((error) => { pending = undefined; throw error; }); } return pending; }; }