diff --git a/README.md b/README.md index 62723ce..30cb7ba 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Plan in one Pi session, then do the work there while a stronger visible Pi sessi The two Pi sessions are visible. You can switch to the supervisor pane and talk to it directly. Supervisor instructions are shown in full, including in collapsed tool rows; ordinary messages and emitted thinking use Pi's display settings. The supervisor is prompted to give brief progress assessments and use judgment about when to intervene. -On resume, monitoring and read-only tools are restored. Views include the latest human direction, source-session path, worker model, and new messages since the last acknowledged view. They report Pi idleness and tracked process/subagent activity separately. Unavailable trackers stay unknown; unregistered detached jobs are not tracked. Approval is blocked while tracked work is active or unknown. Intercom disconnects are reported; unsent current views and unacknowledged instructions are retained in Pi session history for reconnect. A receipt confirms transport handling, not execution. Reviews stop after all goals are completed or cancelled, and both panes remain available. These mechanics are tested; useful judgment and savings from a cheaper worker still require a representative two-model run. -- Pi/OpenAI +On resume, monitoring and read-only tools are restored. Views include the latest human direction, source-session path, worker model, and new messages since the last acknowledged view. They report Pi idleness and tracked process/subagent activity separately. Unavailable trackers stay unknown; unregistered detached jobs are not tracked. Approval is blocked while tracked work is active or unknown. Intercom disconnects are reported; unsent current views and unacknowledged instructions are retained in Pi session history for reconnect. A receipt confirms adapter handling only—not durable queue persistence, model receipt, or execution. Pi's void message API can fail asynchronously after that acknowledgement; crashes can also cause duplicate handoffs. End-to-end exactly-once or durable delivery is not guaranteed. Reviews stop after all goals are completed or cancelled, and both panes remain available. These mechanics are tested; useful judgment and savings from a cheaper worker still require a representative two-model run. -- Pi/OpenAI ## Install @@ -44,6 +44,14 @@ Run Pi from the Git repository that the plan will change. **Ready** fails if the `/goals clear` keeps the plan file. Starting another plan also keeps older versions. +If a required model or supervisor is unavailable, the widget says **goals paused** and implementation/sign-off tools are gated. Human input, read-only diagnosis, `/model`, and recovery commands remain available: + +- `/goals reconnect` retries the remembered role model and existing supervisor binding. Reconnect waits five seconds and never replaces a slow or missing pane automatically. A returning peer clears the connection pause automatically. +- `/goals restart` explicitly closes only the tracked supervisor pane and starts a replacement for a working plan, preserving its file/version but invalidating old approvals. During planning it clears the failed pane so Ready can launch again. +- In the supervisor pane, use `/model` then `/goals reconnect` to recover an unavailable supervisor model. + +A new supervisor may still need up to five minutes for initial compaction. Recovery does not terminate background jobs. Planning/diagnostic command checks are guardrails, not an OS sandbox; loaded extensions and repository Git configuration must be trusted. + Model choices are remembered per project and role in `.pi/pi-goals/models/`. Use `/model` in planning, worker, or supervisor sessions to change that role's choice. Ready restores the worker choice after the planning fork is ready. An unavailable saved model stops the transition instead of substituting another. `/goals model ` explicitly overrides the supervisor choice for launch. -- Pi/OpenAI ## Plan format diff --git a/src/approval.ts b/src/approval.ts index 926b64b..6d4ca0a 100644 --- a/src/approval.ts +++ b/src/approval.ts @@ -42,7 +42,7 @@ export function repositoryState(cwd: string): { repoRoot: string; head: string; } export function goalBlock(plan: string, goal: string): string | null { - const lines = plan.split("\n"); + const lines = plan.split(/^##\s+Log\s*$/im, 1)[0].split("\n"); const wanted = goal.trim().toLowerCase(); const hits = lines.flatMap((line, index) => { const match = GOAL_LINE.exec(line); @@ -52,12 +52,12 @@ export function goalBlock(plan: string, goal: string): string | null { const start = hits[0]; let end = lines.length; for (let index = start + 1; index < lines.length; index++) { - if (GOAL_LINE.test(lines[index])) { + if (GOAL_LINE.test(lines[index]) || /^#{1,2}\s/.test(lines[index])) { end = index; break; } } - return lines.slice(start, end).join("\n"); + return lines.slice(start, end).join("\n").trimEnd(); } export function hashGoalBlock(block: string): string { diff --git a/src/index.ts b/src/index.ts index 23256fe..70f1d5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,7 +111,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { const intercom = new GoalIntercom(pi); const models = new RoleModels(pi); intercom.onSteer = (instruction) => { - if (state.phase !== "working") throw new Error("Worker plan is not active; instruction rejected."); + if (state.phase !== "working" || modelError) throw new Error("Worker is paused or its plan is not active; instruction not delivered. Use /goals reconnect after selecting an available model."); pi.sendUserMessage(`[supervisor] ${instruction}`, { deliverAs: "steer" }); }; let state: PlanState = { @@ -122,6 +122,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { planVersion: null, latestDirection: "", }; + let modelError: string | null = null; + intercom.onConnectionChange = (ctx) => updateWidget(ctx); let planningContextPending = false; let resyncReason: string | null = "New session."; @@ -142,6 +144,24 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { pi.appendEntry(STATE, state); } + function pauseReason(): string | null { + if (!state.phase) return null; + if (modelError) return `${modelError} Select /model, then run /goals reconnect.`; + if (state.phase === "working" && !intercom.connected) return "Supervisor disconnected. Run /goals reconnect, or /goals restart to replace its tracked pane without discarding the plan."; + return null; + } + + async function restoreModel(role: "planning" | "worker", ctx: ExtensionContext): Promise { + modelError = `${role} model restoration is pending.`; + try { + await models.enter(role, ctx); + modelError = null; + } catch (error) { + modelError = error instanceof Error ? error.message : String(error); + throw error; + } + } + function beginReview(ctx: ExtensionContext): void { for (const goal of scanGoals(readPlan(ctx))) { rmSync(approvalPath(ctx.cwd, ctx.sessionManager.getSessionId(), goal.subject), { force: true }); @@ -157,12 +177,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } async function startSupervisor(ctx: ExtensionContext): Promise { + if (intercom.ended) throw new Error("Session ended before supervisor startup."); repositoryRoot(ctx.cwd); const sourceSessionFile = ctx.sessionManager.getSessionFile(); if (!sourceSessionFile) throw new Error("The current session is not persisted, so it cannot be forked."); if (state.supervisorPaneId && state.approvalId) { intercom.configure(state.approvalId, "worker", ctx); - await intercom.waitReady(); + await intercom.waitReady(5000); return; } beginReview(ctx); @@ -177,6 +198,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { extensionPath: fileURLToPath(import.meta.url), model: state.supervisorModel, }, (opened) => { + if (intercom.ended) throw new Error("Session ended during supervisor startup."); + paneId = opened; state = { ...state, supervisorPaneId: opened }; persist(); }); @@ -184,6 +207,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { if (paneId) throw new Error(`Supervisor startup failed in Herdr pane ${paneId}; it remains open for inspection. ${error instanceof Error ? error.message : String(error)}`); throw error; } + if (intercom.ended) throw new Error("Session ended during supervisor startup."); state = { ...state, supervisorPaneId: paneId }; persist(); await intercom.waitReady(); @@ -194,11 +218,11 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { let viewTimer: ReturnType | undefined; async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started"): Promise { - if (state.phase !== "working" || intercom.ended) return; + if (state.phase !== "working" || modelError || !intercom.bound) return; const generation = ++viewGeneration; const binding = state.approvalId; const background = reason === "started" ? { quiet: false, description: "agent starting; background state not queried" } : await backgroundState(pi); - if (intercom.ended || generation !== viewGeneration || binding !== state.approvalId || state.phase !== "working") return; + if (!intercom.bound || modelError || generation !== viewGeneration || binding !== state.approvalId || state.phase !== "working") return; const entries = ctx.sessionManager.getBranch(); const view = workerView(entries, reason, reason !== "started" && ctx.isIdle(), { sourceSession: ctx.sessionManager.getSessionFile()!, latestDirection: state.latestDirection, @@ -212,6 +236,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { state = { ...state, phase: null }; models.leave(); persist(); + intercom.detach(); } } @@ -227,10 +252,11 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } async function stopSupervisor(): Promise { - stopWorkerTimers(); - if (!state.supervisorPaneId) return true; + if (!state.supervisorPaneId) { stopWorkerTimers(); intercom.detach(); return true; } try { await closeSupervisorPane(state.supervisorPaneId); + stopWorkerTimers(); + intercom.detach(); state = { ...state, supervisorPaneId: null }; persist(); return true; @@ -240,6 +266,12 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } function updateWidget(ctx: ExtensionContext): void { + const paused = pauseReason(); + if (paused) { + ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "goals paused")); + ctx.ui.setWidget(WIDGET_KEY, [`pi-goals paused: ${paused}`]); + return; + } if (state.phase === "planning") { ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "planning")); ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: drafting goals"]); @@ -271,9 +303,39 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { // --- /goals: enter plan mode or configure supervision -- Pi/Codex ----------------------------- pi.registerCommand("goals", { - description: `Plan goals, then open a visible supervisor session. /goals | clear | model `, + description: `Plan goals, then open a visible supervisor session. /goals | reconnect | restart | clear | model `, handler: async (args, ctx) => { const arg = args.trim(); + if (arg === "reconnect" || arg === "restart") { + if (!state.phase) { ctx.ui.notify("No active plan to recover.", "info"); return; } + if (!ctx.isIdle()) { ctx.ui.notify("Stop the current turn before recovering goal supervision.", "warning"); return; } + try { + await restoreModel(state.phase === "planning" ? "planning" : "worker", ctx); + if (arg === "restart") { + if (!(await stopSupervisor())) throw new Error("Could not close the tracked supervisor pane; no replacement was opened."); + state = { ...state, supervisorPaneId: null, approvalId: null }; + persist(); + } + if (state.phase === "working" || state.supervisorPaneId) { + if (arg === "reconnect") { + if (!state.approvalId) throw new Error("No saved supervision binding. Use /goals restart."); + intercom.configure(state.approvalId, "worker", ctx); + await intercom.waitReady(5000); + } else await startSupervisor(ctx); + } + if (intercom.ended) return; + if (state.phase === "working") { + startWorkerTimers(ctx); + await publishWorkerView(ctx, "settled"); + } + ctx.ui.notify(state.phase === "planning" ? "Planning model restored. Choose Ready when the plan is agreed." : "Goal supervision reconnected; the current plan is unchanged.", "info"); + } catch (error) { + if (intercom.ended) return; + ctx.ui.notify(`Goal recovery failed: ${String(error)} Use /goals reconnect to retry, or /goals restart to explicitly replace the tracked pane.`, "warning"); + } + updateWidget(ctx); + return; + } if (arg === "clear") { if (state.planVersion === null) { ctx.ui.notify("No active plan to disconnect.", "info"); @@ -286,6 +348,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null }; models.leave(); + modelError = null; persist(); updateWidget(ctx); ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info"); @@ -310,7 +373,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { ctx.ui.notify("Could not close the visible supervisor; no new plan was started.", "warning"); return; } - await models.enter("planning", ctx); + await restoreModel("planning", ctx); state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg }; planningContextPending = true; resyncReason = null; @@ -339,6 +402,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { // The phase snapshot enters context only when planning starts or context was lost. pi.on("before_agent_start", async (_event, ctx) => { + const paused = pauseReason(); + if (paused) return { systemPrompt: `${ctx.getSystemPrompt()}\n\nGoal work is paused: ${paused} Do not implement or sign off goals. Human input and read-only diagnosis remain available; wait for recovery before resuming autonomous work.` }; if (state.phase === "working") { return { systemPrompt: `${ctx.getSystemPrompt()}\n\nYou are the implementation worker for ${planRel(ctx)}. Keep the full conversation and do the work directly. A stronger read-only supervisor watches this session through pi-intercom and can steer you. Commit clean evidence before asking for sign-off. Stop when a goal appears complete so the supervisor can inspect a settled worker view. Call CompleteGoal only after the supervisor says it recorded approval. -- PI[Kimi K3]`, @@ -385,6 +450,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { }); pi.on("tool_call", async (event, ctx) => { + const paused = pauseReason(); + if (paused && !(["read", "grep", "find", "ls"].includes(event.toolName) || (event.toolName === "bash" && isPlanningReadOnlyCommand(String((event.input as { command?: string }).command))))) { + return { block: true, terminate: true, reason: `Goal work is paused: ${paused} Only read-only diagnosis is available.` }; + } if (state.phase === "planning") { if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) { const target = (event.input as { path?: string }).path; @@ -410,7 +479,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { await publishWorkerView(ctx, "settled"); return; } - if (state.phase !== "planning" || !ctx.hasUI) return; + if (state.phase !== "planning" || modelError || !ctx.hasUI) return; let printed = ""; while (true) { if (intercom.ended) return; @@ -440,6 +509,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { continue; } if (choice === "Cancel") { + if (!(await stopSupervisor())) { ctx.ui.notify("Could not close the tracked supervisor; plan was not discarded.", "warning"); return; } rmSync(planPath(ctx), { force: true }); models.leave(); state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null }; @@ -452,7 +522,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { try { await startSupervisor(ctx); if (intercom.ended) return; - await models.enter("worker", ctx); + await restoreModel("worker", ctx); state = { ...state, phase: "working" }; resyncReason = "The plan was approved."; persist(); @@ -463,7 +533,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { pi.sendUserMessage("The plan is approved. Begin implementation as the worker."); } catch (error) { if (intercom.ended) return; - ctx.ui.notify(`Goal supervisor could not start: ${error instanceof Error ? error.message : String(error)}`, "warning"); + ctx.ui.notify(`Goal supervisor could not start: ${error instanceof Error ? error.message : String(error)} Use /goals reconnect to retry, or /goals restart to replace the tracked pane.`, "warning"); state = { ...state, phase: "planning" }; persist(); updateWidget(ctx); @@ -485,13 +555,25 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { planVersion: last?.data?.planVersion ?? null, latestDirection: last?.data?.latestDirection ?? "", }; - if (state.phase) await models.enter(state.phase === "planning" ? "planning" : "worker", ctx); + modelError = state.phase ? "Role model restoration is pending." : null; planningContextPending = state.phase === "planning"; resyncReason = state.phase === "working" ? "New session." : null; - if (state.phase === "working") { - intercom.configure(state.approvalId!, "worker", ctx); + if (state.phase === "working" && state.approvalId) { + intercom.configure(state.approvalId, "worker", ctx, false); startWorkerTimers(ctx); } + try { + if (state.phase) await restoreModel(state.phase === "planning" ? "planning" : "worker", ctx); + } catch (error) { + if (!intercom.ended) ctx.ui.notify(`Goal work paused: ${String(error)} Use /model, then /goals reconnect.`, "warning"); + } + if (intercom.ended) return; + if (state.phase === "working" && state.approvalId && !modelError) { + intercom.markReady(); + void intercom.waitReady(5000).then(() => publishWorkerView(ctx, "settled")).catch(error => { + if (!intercom.ended && state.phase === "working") ctx.ui.notify(`Goal work paused: ${String(error)} Use /goals reconnect or /goals restart.`, "warning"); + }); + } updateWidget(ctx); }); @@ -508,9 +590,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { }), async execute(_id, params, _signal, _onUpdate, ctx) { if (state.phase !== "working") return result("Planning is not approved. Choose Ready before signing off a goal.", true); + if (pauseReason()) return result(`Goal sign-off blocked: ${pauseReason()}`, true); if (!state.approvalId) return result("Goal sign-off blocked: no current supervisor review.", true); const background = await backgroundState(pi); - if (intercom.ended || !background.quiet) return result(`Goal sign-off blocked: ${background.description}`, true); + if (intercom.ended || !background.quiet || pauseReason()) return result(`Goal sign-off blocked: ${pauseReason() ?? background.description}`, true); const plan = readPlan(ctx); if (!plan.trim()) return result(`No plan file at ${planRel(ctx)}. Run /goals to draft one.`, true); const block = goalBlock(plan, params.goal); diff --git a/src/intercom.ts b/src/intercom.ts index cd114e4..ac4f6f8 100644 --- a/src/intercom.ts +++ b/src/intercom.ts @@ -24,6 +24,7 @@ export class GoalIntercom { acknowledgedEntry?: string; onView: (view: View) => void = () => {}; onSteer: (text: string) => void = () => {}; + onConnectionChange: (ctx: ExtensionContext) => void = () => {}; constructor(private pi: ExtensionAPI) { pi.events.on("intercom:extension-registry-ready", () => this.register()); @@ -39,11 +40,11 @@ export class GoalIntercom { }); } - configure(binding: string, role: Role, ctx: ExtensionContext): void { + configure(binding: string, role: Role, ctx: ExtensionContext, ready = role === "worker"): void { this.binding = binding; this.role = role; this.ctx = ctx; - this.ready = role === "worker"; + this.ready = ready; this.peer = undefined; this.peerReady = false; this.pending.clear(); @@ -66,9 +67,22 @@ export class GoalIntercom { this.hello(); } + // End this plan's binding without disposing the session's transport. + detach(): void { + this.ready = false; + this.hello(); + this.binding = ""; + this.peer = undefined; + this.peerReady = false; + this.latestView = undefined; + this.pending.clear(); + if (this.ctx) this.onConnectionChange(this.ctx); + } + markReady(): void { if (!this.stopped) { this.ready = true; this.hello(); } } get ended(): boolean { return this.stopped; } - get connected(): boolean { return Boolean(this.channel?.snapshot().connected && this.peerReady); } + get bound(): boolean { return !this.stopped && Boolean(this.binding); } + get connected(): boolean { return Boolean(!this.stopped && this.ready && this.binding && this.channel?.snapshot().connected && this.peerReady); } async waitReady(timeoutMs = 300_000): Promise { if (this.connected) return; @@ -86,6 +100,7 @@ export class GoalIntercom { } view(text: string, reason: string, through?: string, backgroundQuiet = false): View { + if (!this.bound) throw new Error("No active supervision binding for a worker view."); const id = randomUUID(); const message: Message = { binding: this.binding, role: this.role, kind: "view", id, text: `${text}\n\nworker view id: ${id}`, reason, through, backgroundQuiet }; this.record("out", message); @@ -121,11 +136,13 @@ export class GoalIntercom { this.peer = undefined; this.peerReady = false; } else this.hello(); + if (this.ctx) this.onConnectionChange(this.ctx); return; } if (event.type === "session_left" && event.sessionId === this.peer) { this.peer = undefined; this.peerReady = false; this.ctx?.ui.notify("Goal supervision peer disconnected; reconnect the existing session.", "warning"); + if (this.ctx) this.onConnectionChange(this.ctx); return; } if (event.type === "session_joined") { this.hello(); return; } @@ -139,11 +156,12 @@ export class GoalIntercom { this.peerReady = Boolean(message.ready); if (changed) { this.hello(); - if (this.peerReady) { + if (this.peerReady && this.ready) { if (this.role === "worker" && this.latestView) this.publish({ binding: this.binding, role: this.role, kind: "view", ...this.latestView }); for (const pending of this.pending.values()) this.publish(pending); } } + if (changed && this.ctx) this.onConnectionChange(this.ctx); for (const wake of this.waiters) wake(); return; } @@ -166,6 +184,8 @@ export class GoalIntercom { this.publish({ binding: this.binding, role: this.role, kind: "received", id: message.id }); } } else if (message.kind === "steer" && this.role === "worker") { + // Pi's void message API provides synchronous handoff, not a durable queue receipt. + // Ack only after that handoff; asynchronous enqueue errors are not observable here. this.onSteer(message.text!); this.received.add(message.id); this.record("in", message); diff --git a/src/role-models.ts b/src/role-models.ts index c906086..3f0f706 100644 --- a/src/role-models.ts +++ b/src/role-models.ts @@ -23,6 +23,7 @@ export class RoleModels { leave(): void { this.role = undefined; } async enter(role: ModelRole, ctx: ExtensionContext, useCurrent = false): Promise { + if (this.stopped) throw new Error("Role model session ended."); this.role = role; this.ctx = ctx; let choice: Choice | undefined; diff --git a/src/supervisor-session.ts b/src/supervisor-session.ts index 7c47891..2a6b7b3 100644 --- a/src/supervisor-session.ts +++ b/src/supervisor-session.ts @@ -10,7 +10,7 @@ import { RoleModels } from "./role-models.js"; const BOOTSTRAPPED = "pi-goals-visible-supervisor-v2"; const INITIAL_COMPACT_AT_TOKENS = 20_000; const COMPACT_AT_TOKENS = 100_000; -const WRITER_TOOLS = new Set(["bash", "edit", "write", "multi_edit", "multiedit", "apply_patch", "notebook_edit", "edit_file", "write_file", "quick_edit", "target_edit"]); +const BLOCKED_TOOLS = new Set(["intercom", "bash", "edit", "write", "multi_edit", "multiedit", "apply_patch", "notebook_edit", "edit_file", "write_file", "quick_edit", "target_edit"]); interface SupervisorConfig { workerSessionId: string; @@ -49,7 +49,7 @@ function hasEvidenceEntry(block: string): boolean { const childIndent = lines[child].match(/^\s*/)?.[0].length ?? 0; if (lines[child].trim() && childIndent <= indent) break; const entry = /^\s+[-*]\s+(.+?)\s*$/.exec(lines[child]); - if (entry?.[1].trim()) return true; + if (entry?.[1].trim() && !/^\(empty until sign-off\)$/i.test(entry[1].trim())) return true; } } return false; @@ -93,6 +93,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void { const settings = config(); let compacting = false; let bootstrapping = false; + let modelError: string | null = null; const intercom = new GoalIntercom(pi); const models = new RoleModels(pi); intercom.onView = (view) => pi.sendUserMessage(view.text, { deliverAs: "followUp" }); @@ -103,16 +104,16 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void { bootstrapping = true; try { const active = pi.getActiveTools(); - pi.setActiveTools(active.filter((tool) => !WRITER_TOOLS.has(tool.toLowerCase()))); - const writers = pi.getActiveTools().filter((tool) => WRITER_TOOLS.has(tool.toLowerCase())); - if (writers.length) throw new Error(`Could not remove supervisor writing tools: ${writers.join(", ")}`); + pi.setActiveTools(active.filter((tool) => !BLOCKED_TOOLS.has(tool.toLowerCase()))); + const blocked = pi.getActiveTools().filter((tool) => BLOCKED_TOOLS.has(tool.toLowerCase())); + if (blocked.length) throw new Error(`Could not remove supervisor writing or messaging tools: ${blocked.join(", ")}`); if (!entries.some((entry: { type?: string; customType?: string }) => entry.type === "custom" && entry.customType === BOOTSTRAPPED)) { pi.appendEntry(BOOTSTRAPPED, { version: 2, workerSessionId: settings.workerSessionId, planPath: settings.planPath }); } intercom.markReady(); } catch (error) { ctx.ui.notify(`Supervisor startup failed: ${error instanceof Error ? error.message : String(error)}`, "error"); - } + } finally { bootstrapping = false; } }; const bootstrapAfterInitialCompaction = (ctx: ExtensionContext): void => { @@ -139,11 +140,30 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void { }); }; - pi.on("session_start", async (_event, ctx) => { + const start = async (ctx: ExtensionContext): Promise => { + modelError = "Supervisor model restoration is pending."; intercom.configure(settings.approvalId, "supervisor", ctx); - pi.setActiveTools(pi.getActiveTools().filter((tool) => !WRITER_TOOLS.has(tool.toLowerCase()))); - await models.enter("supervisor", ctx, process.env.PI_GOALS_MODEL_EXPLICIT === "1"); - setImmediate(() => { bootstrapAfterInitialCompaction(ctx); }); + pi.setActiveTools(pi.getActiveTools().filter((tool) => !BLOCKED_TOOLS.has(tool.toLowerCase()))); + try { + await models.enter("supervisor", ctx, process.env.PI_GOALS_MODEL_EXPLICIT === "1"); + modelError = null; + setImmediate(() => { if (!intercom.ended) bootstrapAfterInitialCompaction(ctx); }); + } catch (error) { + modelError = String(error); + if (!intercom.ended) ctx.ui.notify(`Supervisor paused: ${modelError} Select /model, then /goals reconnect.`, "error"); + } + }; + pi.on("session_start", async (_event, ctx) => start(ctx)); + pi.registerCommand("goals", { + description: "Retry supervisor model restoration and readiness: /goals reconnect", + handler: async (args, ctx) => { + if (args.trim() !== "reconnect") { ctx.ui.notify("Use /goals reconnect here; manage the plan or restart the pane from the worker session.", "info"); return; } + if (!ctx.isIdle() || compacting) { ctx.ui.notify("Wait for the supervisor to settle before reconnecting.", "warning"); return; } + await start(ctx); + }, + }); + pi.on("tool_call", async (event) => { + if (BLOCKED_TOOLS.has(event.toolName.toLowerCase())) return { block: true, terminate: true, reason: "Supervisor is read-only; use SteerWorker for the bound worker, not the general intercom tool." }; }); pi.on("before_agent_start", async (_event, ctx) => ({ systemPrompt: `${ctx.getSystemPrompt()}\n\n${supervisorPrompt(settings)}` })); pi.on("agent_settled", async (_event, ctx) => { @@ -174,6 +194,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void { return new Text(`${theme.fg("toolTitle", "Supervisor → worker")}\n${args.instruction ?? ""}`, 0, 0); }, async execute(_id, params) { + if (modelError) return result(`Supervisor paused: ${modelError} Use /model, then /goals reconnect.`, true); const instruction = params.instruction.trim(); if (!instruction) return result("A worker instruction cannot be empty.", true); const id = intercom.steer(instruction); @@ -191,6 +212,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void { verifyOutputPath: Type.String({ description: "Nonempty repository-relative file containing the verification output you inspected." }), }), async execute(_id, params, _signal, _onUpdate, ctx) { + if (modelError) return result(`Supervisor paused: ${modelError} Use /model, then /goals reconnect.`, true); const view = latestWorkerView(ctx); const newest = intercom.latestView; if (!intercom.connected || !newest || view !== newest.text) return result("Cannot approve without inspecting the latest worker view.", true); diff --git a/test/approval.test.ts b/test/approval.test.ts new file mode 100644 index 0000000..b93bece --- /dev/null +++ b/test/approval.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vitest"; +import { goalBlock, hashGoalBlock } from "../src/approval.js"; + +it("hashes only the current goal, excluding the log, interview, and their historical goal text", () => { + const goal = "1. [ ] goal: output\n - evidence: output.txt\n"; + const before = `${goal}\n## Log\n- first entry\n`; + const after = `${goal}\n## Log\n- later entry\n\n${goal}\n## Interview\n> new notes\n`; + expect(goalBlock(before, "output")).toBe(goal.trimEnd()); + expect(hashGoalBlock(goalBlock(before, "output")!)).toBe(hashGoalBlock(goalBlock(after, "output")!)); + expect(goalBlock(`${goal}\n## Interview\n> notes`, "output")).toBe(goal.trimEnd()); + expect(goalBlock(`${goal}2. [ ] goal: second\n - evidence: second.txt`, "output")).toBe(goal.trimEnd()); + expect(hashGoalBlock(goalBlock(before.replace("output.txt", "changed.txt"), "output")!)).not.toBe(hashGoalBlock(goalBlock(before, "output")!)); +}); diff --git a/test/goals-flow.test.ts b/test/goals-flow.test.ts index 39e47a8..ac78504 100644 --- a/test/goals-flow.test.ts +++ b/test/goals-flow.test.ts @@ -9,6 +9,7 @@ import { intercomFixture } from "./intercom-fixture.js"; const openSupervisorPane = vi.fn(async () => "pane-2"); const closeSupervisorPane = vi.fn(async () => undefined); +const shutdowns: Array<() => Promise> = []; vi.mock("../src/herdr.js", () => ({ openSupervisorPane, closeSupervisorPane })); const { default: piGoalsExtension, isMainSession } = await import("../src/index.js"); @@ -64,7 +65,8 @@ function setup(selectChoices: Array, editorChoices: Array messages.push({ content }), }; piGoalsExtension(pi as unknown as ExtensionAPI); - return { commands, ctx, cwd, entries, hooks, messages, notifications, tools, transport }; + shutdowns.push(() => hooks.get("session_shutdown")()); + return { pi, commands, ctx, cwd, entries, hooks, messages, notifications, tools, transport }; } function writePlan(cwd: string, content: string): string { @@ -78,7 +80,9 @@ function approvedPlan(cwd: string): string { return writePlan(cwd, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n - discriminator: output exists\n - evidence:\n - `result.txt`: contains ok\n\n## Log\n"); } -afterEach(() => { +afterEach(async () => { + for (const shutdown of shutdowns.splice(0)) await shutdown(); + vi.useRealTimers(); openSupervisorPane.mockClear(); closeSupervisorPane.mockClear(); }); @@ -104,6 +108,13 @@ describe("/goals flow", () => { await flow.hooks.get("agent_settled")({}, flow.ctx); const count = views().length; expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null }); + const binding = (flow.entries.at(-1)?.data as any).approvalId; + const messageCount = flow.messages.length; + flow.transport.receive({ binding, role: "supervisor", kind: "steer", id: "late-completed", text: "Obsolete instruction." }); + await flow.commands.get("goals").handler("restart", flow.ctx); + await flow.commands.get("goals").handler("reconnect", flow.ctx); + expect(flow.messages).toHaveLength(messageCount); + expect(openSupervisorPane).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(60 * 60_000); expect(views()).toHaveLength(count); } finally { @@ -193,7 +204,14 @@ describe("/goals flow", () => { await flow.commands.get("goals").handler("make the file", flow.ctx); const planPath = approvedPlan(flow.cwd); await flow.hooks.get("agent_settled")({}, flow.ctx); + const binding = (flow.entries.at(-1)?.data as any).approvalId; await flow.commands.get("goals").handler("clear", flow.ctx); + const messageCount = flow.messages.length; + flow.transport.receive({ binding, role: "supervisor", kind: "steer", id: "late-cleared", text: "Obsolete instruction." }); + await flow.commands.get("goals").handler("restart", flow.ctx); + await flow.commands.get("goals").handler("reconnect", flow.ctx); + expect(flow.messages).toHaveLength(messageCount); + expect(openSupervisorPane).toHaveBeenCalledTimes(1); expect(closeSupervisorPane).toHaveBeenCalledWith("pane-2"); expect(readFileSync(planPath, "utf8")).toContain("make the file"); expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, supervisorPaneId: null, planVersion: null }); @@ -221,6 +239,7 @@ describe("/goals flow", () => { verifyOutputPath: "verify.txt", supervisor: { sessionId: "supervisor", runId: null }, timestamp: new Date().toISOString(), }); + writeFileSync(planPath, `${plan}- Appended manual log after approval.\n`); const signed = await flow.tools.get("CompleteGoal").execute("id", { goal }, undefined, undefined, flow.ctx); expect(signed.isError).toBe(false); expect(readFileSync(planPath, "utf8")).toContain("1. [x] goal: make the file"); @@ -236,3 +255,122 @@ describe("process role", () => { expect(isMainSession(true)).toBe(false); }); }); + +function restoredPlan(flow: ReturnType, phase: "working" | "planning" = "working") { + const path = approvedPlan(flow.cwd); + flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase, approvalId: "restored-binding", supervisorPaneId: "owned-pane", planVersion: 1 } }); + return path; +} + +it.each(["working", "planning"] as const)("restores %s linkage even when its remembered model is unavailable, and supports explicit recovery", async phase => { + const flow = setup([]); + try { + const path = restoredPlan(flow, phase); + const plan = readFileSync(path, "utf8"); + const role = phase === "working" ? "worker" : "planning"; + mkdirSync(join(flow.cwd, ".pi/pi-goals/models"), { recursive: true }); + const modelPath = join(flow.cwd, `.pi/pi-goals/models/${role}.json`); + writeFileSync(modelPath, JSON.stringify({ provider: "gone", id: "expired" })); + flow.ctx.modelRegistry.find = vi.fn().mockReturnValue(undefined); + await expect(flow.hooks.get("session_start")({}, flow.ctx)).resolves.toBeUndefined(); + expect(flow.pi.setModel).not.toHaveBeenCalled(); + expect(readFileSync(modelPath, "utf8")).toContain("expired"); + expect(flow.transport.sent.filter(message => message.kind === "hello" && message.ready)).toHaveLength(0); + expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", "goals paused"); + if (phase === "working") expect(flow.transport.sent).toContainEqual(expect.objectContaining({ kind: "hello", binding: "restored-binding" })); + expect((await flow.hooks.get("tool_call")({ toolName: "edit", input: { path: "code.ts" } }, flow.ctx)).block).toBe(true); + expect(await flow.hooks.get("tool_call")({ toolName: "read", input: { path: "code.ts" } }, flow.ctx)).toBeUndefined(); + expect(await flow.hooks.get("input")({ source: "interactive", text: "Why are we paused?" }, flow.ctx)).toBeUndefined(); + const signoff = await flow.tools.get("CompleteGoal").execute("id", { goal: "make the file" }, undefined, undefined, flow.ctx); + expect(signoff.isError).toBe(true); + flow.ctx.modelRegistry.find = (provider, id) => ({ provider, id }); + await flow.hooks.get("model_select")({ source: "set", model: { provider: "test", id: "chosen" } }, flow.ctx); + await flow.commands.get("goals").handler("reconnect", flow.ctx); + expect(flow.pi.setModel).toHaveBeenLastCalledWith({ provider: "test", id: "chosen" }); + expect(openSupervisorPane).not.toHaveBeenCalled(); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase, approvalId: "restored-binding", planVersion: 1 }); + const injection = await flow.hooks.get("before_agent_start")({}, flow.ctx); + if (phase === "planning") expect(injection.message.content).toContain(path); + else expect(injection.systemPrompt).toContain("implementation worker"); + // Human diagnostic input is retained in the planning interview, never discarded by recovery. + expect(readFileSync(path, "utf8")).toContain(plan.trim()); + } finally { rmSync(flow.cwd, { recursive: true, force: true }); } +}); + +it("shows a missing resumed supervisor, pauses writes, and automatically unpauses when that peer returns", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"] }); + const flow = setup([]); + try { + restoredPlan(flow); + flow.transport.replyToHello(false); + await flow.hooks.get("session_start")({}, flow.ctx); + expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", "goals paused"); + await vi.advanceTimersByTimeAsync(5000); + expect(flow.notifications.some(text => text.includes("/goals restart"))).toBe(true); + expect((await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "code.ts" } }, flow.ctx)).terminate).toBe(true); + expect(await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "git status" } }, flow.ctx)).toBeUndefined(); + flow.transport.receive({ binding: "restored-binding", role: "supervisor", kind: "hello", id: "hello", ready: true }); + expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("supervised")); + expect(await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "code.ts" } }, flow.ctx)).toBeUndefined(); + expect(openSupervisorPane).not.toHaveBeenCalled(); + } finally { rmSync(flow.cwd, { recursive: true, force: true }); } +}); + +it("times out stale Ready retries in five seconds, without replacing the pane automatically", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"] }); + const flow = setup(["Ready", "Ready"]); + try { + await flow.commands.get("goals").handler("make the file", flow.ctx); + approvedPlan(flow.cwd); + flow.transport.replyToHello(false); + openSupervisorPane.mockImplementationOnce(async (_input: any, opened: any) => { opened("failed-pane"); throw new Error("pane run failed"); }); + await flow.hooks.get("agent_settled")({}, flow.ctx); + expect(flow.notifications.at(-1)).toContain("failed-pane"); + const retry = flow.hooks.get("agent_settled")({}, flow.ctx); + await vi.advanceTimersByTimeAsync(5000); + await retry; + expect(openSupervisorPane).toHaveBeenCalledTimes(1); + expect(closeSupervisorPane).not.toHaveBeenCalled(); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", supervisorPaneId: "failed-pane" }); + } finally { rmSync(flow.cwd, { recursive: true, force: true }); } +}); + +it("explicitly restarts only the tracked pane, keeps the plan, and invalidates old approval binding", async () => { + const flow = setup([]); + try { + const path = restoredPlan(flow); + const before = readFileSync(path, "utf8"); + await flow.hooks.get("session_start")({}, flow.ctx); + const checkpoint = approvalPath(flow.cwd, "session-a", "make the file"); + mkdirSync(join(flow.cwd, ".pi/pi-goals/approvals"), { recursive: true }); + writeFileSync(checkpoint, "old checkpoint"); + await flow.commands.get("goals").handler("restart", flow.ctx); + expect(closeSupervisorPane).toHaveBeenCalledExactlyOnceWith("owned-pane"); + expect(openSupervisorPane).toHaveBeenCalledTimes(1); + expect(readFileSync(path, "utf8")).toBe(before); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", planVersion: 1 }); + expect((flow.entries.at(-1)?.data as any).approvalId).not.toBe("restored-binding"); + expect(() => readFileSync(checkpoint)).toThrow(); + await flow.commands.get("goals").handler("clear", flow.ctx); + expect(await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "unrelated.ts" } }, flow.ctx)).toBeUndefined(); + } finally { rmSync(flow.cwd, { recursive: true, force: true }); } +}); + +it("does not persist startup results or launch work after session shutdown", async () => { + const flow = setup(["Ready"]); + try { + let finish: (() => void) | undefined; + openSupervisorPane.mockImplementationOnce(() => new Promise(resolve => { finish = () => resolve("late-pane"); })); + await flow.commands.get("goals").handler("make the file", flow.ctx); + approvedPlan(flow.cwd); + const starting = flow.hooks.get("agent_settled")({}, flow.ctx); + await new Promise(resolve => setImmediate(resolve)); + await flow.hooks.get("session_shutdown")(); + const entries = flow.entries.length; + const messages = flow.messages.length; + finish!(); + await starting; + expect(flow.entries).toHaveLength(entries); + expect(flow.messages).toHaveLength(messages); + } finally { rmSync(flow.cwd, { recursive: true, force: true }); } +}); diff --git a/test/intercom-fixture.ts b/test/intercom-fixture.ts index c1e3c94..7ab78e7 100644 --- a/test/intercom-fixture.ts +++ b/test/intercom-fixture.ts @@ -1,10 +1,12 @@ export function intercomFixture() { + let autoHello = true; let registration: any; const sent: any[] = []; let connected = true; const receive = (payload: any, fromSessionId = "peer") => registration.onEvent({ type: "message", fromSessionId, payload }); return { sent, receive, + replyToHello: (value: boolean) => { autoHello = value; }, event: (event: any) => registration.onEvent(event), connect: (value: boolean) => { connected = value; registration.onEvent({ type: "connection", connected: value, supported: true }); }, events: { @@ -16,7 +18,7 @@ export function intercomFixture() { snapshot: () => ({ connected, supported: true }), publish: (message: any) => { sent.push(message); - if (message.kind === "hello") queueMicrotask(() => receive({ ...message, role: message.role === "worker" ? "supervisor" : "worker", ready: true })); + if (message.kind === "hello" && autoHello) queueMicrotask(() => receive({ ...message, role: message.role === "worker" ? "supervisor" : "worker", ready: true })); }, }); return true; diff --git a/test/intercom.test.ts b/test/intercom.test.ts index 18a342e..ee5ebcf 100644 --- a/test/intercom.test.ts +++ b/test/intercom.test.ts @@ -73,3 +73,31 @@ describe("pi-intercom transport", () => { await rejection; }); }); + +it("does not acknowledge a synchronous handoff failure, and retries the instruction", async () => { + const runtime = setup("worker"); + await runtime.link.waitReady(); + const delivery = vi.fn().mockImplementationOnce(() => { throw new Error("Delivery unavailable"); }); + runtime.link.onSteer = delivery; + const message = { binding: "binding", role: "supervisor", kind: "steer", id: "retry", text: "Inspect evidence." }; + runtime.fixture.receive(message); + expect(runtime.fixture.sent.filter(m => m.kind === "received")).toHaveLength(0); + expect(runtime.entries.filter(e => e.data.direction === "in")).toHaveLength(0); + runtime.fixture.receive(message); + expect(delivery).toHaveBeenCalledTimes(2); + expect(runtime.fixture.sent.filter(m => m.kind === "received")).toHaveLength(1); +}); + +it("detaches a completed binding and ignores its late advice without replay errors or false acceptance", async () => { + const runtime = setup("worker"); + await runtime.link.waitReady(); + const delivery = vi.fn(); + runtime.link.onSteer = delivery; + runtime.link.detach(); + runtime.fixture.receive({ binding: "binding", role: "supervisor", kind: "steer", id: "late", text: "Obsolete advice." }); + expect(runtime.link.connected).toBe(false); + expect(delivery).not.toHaveBeenCalled(); + expect(runtime.ctx.ui.notify).not.toHaveBeenCalled(); + expect(runtime.fixture.sent.filter(m => m.kind === "received")).toHaveLength(0); + expect(runtime.fixture.sent.at(-1)).toMatchObject({ kind: "hello", ready: false }); +}); diff --git a/test/native-intercom.test.ts b/test/native-intercom.test.ts index 6fab7fd..055776f 100644 --- a/test/native-intercom.test.ts +++ b/test/native-intercom.test.ts @@ -47,12 +47,14 @@ it("runs a forked Pi supervisor and receives its exact instruction in another Pi let supervisor: Driver | undefined; let workerFile: string | undefined; let supervisorFile: string | undefined; + let supervisorTools: string[] = []; const server = createServer(async (request, response) => { let body = ""; for await (const chunk of request) body += chunk; const input = JSON.parse(body); const latest = input.messages.at(-1); const steer = latest.role === "user" && JSON.stringify(latest.content).includes("The worker stopped."); + if (steer) supervisorTools = input.tools.map((tool: any) => tool.function.name); response.writeHead(200, { "content-type": "text/event-stream" }); const delta = steer ? { tool_calls: [{ index: 0, id: "test-steer", type: "function", function: { name: "SteerWorker", arguments: JSON.stringify({ instruction: advice }) } }] } : { content: "Test context retained. Actual outputs still need inspection." }; response.write(`data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`); @@ -92,6 +94,9 @@ it("runs a forked Pi supervisor and receives its exact instruction in another Pi expect(JSON.stringify(received)).toContain(advice); const result = await supervisor.wait(message => message.type === "tool_execution_end" && message.toolName === "SteerWorker"); expect(result.isError).toBe(false); + expect(supervisorTools).toContain("SteerWorker"); + expect(supervisorTools).not.toContain("intercom"); + expect(supervisorTools).not.toContain("bash"); supervisor.send({ type: "get_state", id: "supervisor-state" }); const supervisorState = await supervisor.wait(message => message.type === "response" && message.id === "supervisor-state"); supervisorFile = supervisorState.data.sessionFile; diff --git a/test/supervisor-session.test.ts b/test/supervisor-session.test.ts index d72aaa3..c9c49d1 100644 --- a/test/supervisor-session.test.ts +++ b/test/supervisor-session.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; @@ -19,12 +19,14 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp vi.stubEnv("PI_GOALS_APPROVAL_ID", "approval-1"); const hooks = new Map(); const tools = new Map(); + const commands = new Map(); const entries: any[] = []; const messages: string[] = []; let branch: any[] = []; - let activeTools = ["read", "grep", "bash", "write", "edit"]; + let activeTools = ["read", "grep", "bash", "write", "edit", "intercom"]; const ctx = { cwd, + isIdle: () => true, getSystemPrompt: () => "base", model: { provider: "test", id: "supervisor" }, modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) }, @@ -40,6 +42,7 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp hooks.set(name, async (...args: any[]) => { await prior?.(...args); return handler(...args); }); }, registerTool: (tool: any) => tools.set(tool.name, tool), + registerCommand: (name: string, command: any) => commands.set(name, command), appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }), sendUserMessage: (message: string) => messages.push(message), getActiveTools: () => activeTools, @@ -49,7 +52,7 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp registerVisibleSupervisor(pi as unknown as ExtensionAPI); shutdowns.push(() => hooks.get("session_shutdown")()); return { - activeTools: () => activeTools, branch: (value: any[]) => { branch = value; }, ctx, entries, hooks, transport, messages, tools, + commands, pi, activeTools: () => activeTools, branch: (value: any[]) => { branch = value; }, ctx, entries, hooks, transport, messages, tools, ready: () => transport.sent.some(message => message.kind === "hello" && message.role === "supervisor" && message.ready), start: async () => { await hooks.get("session_start")({}, ctx); await new Promise(resolve => setImmediate(resolve)); }, view: (id: string, text: string, reason = "settled", backgroundQuiet = true) => { @@ -212,6 +215,15 @@ describe("visible supervisor session", () => { await runtime.start(); const view = runtime.view("first", "The worker stopped.\n\ntool calls with no result: none"); runtime.branch([{ type: "message", message: { role: "user", content: [{ type: "text", text: view.text }] } }]); + const originalPlan = readFileSync(planPath, "utf8"); + for (const evidence of [" - evidence: (empty until sign-off)", " - evidence:\n - (empty until sign-off)"]) { + writeFileSync(planPath, originalPlan.replace(" - evidence:\n - `result.txt`: contains ok", evidence)); + const rejected = await runtime.tools.get("ApproveGoal").execute("id", { goal: "make the file", verifyOutputPath: "verify.txt" }, undefined, undefined, runtime.ctx); + expect(rejected.isError).toBe(true); + expect(rejected.content[0].text).toContain("nonblank evidence"); + expect(existsSync(approvalPath(cwd, "worker-session", "make the file"))).toBe(false); + } + writeFileSync(planPath, originalPlan); const approved = await runtime.tools.get("ApproveGoal").execute("id", { goal: "make the file", verifyOutputPath: "verify.txt" }, undefined, undefined, runtime.ctx); expect(approved.isError).toBe(false); expect(existsSync(approvalPath(cwd, "worker-session", "make the file"))).toBe(true); @@ -227,3 +239,35 @@ describe("visible supervisor session", () => { } finally { rmSync(cwd, { recursive: true, force: true }); } }); }); + +it("blocks the general intercom actuator even if enabled after startup", async () => { + const cwd = mkdtempSync(join(tmpdir(), "goals-supervisor-actuators-")); + try { + const runtime = setup(cwd, join(cwd, "plan.md")); + await runtime.start(); + expect(runtime.activeTools()).not.toContain("intercom"); + runtime.pi.setActiveTools(["intercom", "SteerWorker", "read"]); + expect((await runtime.hooks.get("tool_call")({ toolName: "intercom" }, runtime.ctx)).block).toBe(true); + expect(await runtime.hooks.get("tool_call")({ toolName: "SteerWorker" }, runtime.ctx)).toBeUndefined(); + } finally { rmSync(cwd, { recursive: true, force: true }); } +}); + +it("keeps a supervisor unready after model restoration failure, then recovers explicitly without substituting a model", async () => { + const cwd = mkdtempSync(join(tmpdir(), "goals-supervisor-model-recovery-")); + try { + const runtime = setup(cwd, join(cwd, "plan.md")); + mkdirSync(join(cwd, ".pi/pi-goals/models"), { recursive: true }); + writeFileSync(join(cwd, ".pi/pi-goals/models/supervisor.json"), JSON.stringify({ provider: "gone", id: "expired" })); + runtime.ctx.modelRegistry.find = vi.fn().mockReturnValue(undefined); + await runtime.start(); + expect(runtime.ready()).toBe(false); + expect(runtime.pi.setModel).not.toHaveBeenCalled(); + expect((await runtime.tools.get("SteerWorker").execute("id", { instruction: "Do not deliver." })).isError).toBe(true); + runtime.ctx.modelRegistry.find = (provider, id) => ({ provider, id }); + await runtime.hooks.get("model_select")({ source: "set", model: { provider: "test", id: "chosen" } }, runtime.ctx); + await runtime.commands.get("goals").handler("reconnect", runtime.ctx); + await new Promise(resolve => setImmediate(resolve)); + expect(runtime.pi.setModel).toHaveBeenLastCalledWith({ provider: "test", id: "chosen" }); + expect(runtime.ready()).toBe(true); + } finally { rmSync(cwd, { recursive: true, force: true }); } +});