mirror of
https://github.com/wassname/pi-plan.git
synced 2026-10-02 12:30:49 +08:00
Recover paused supervision and tighten approval boundaries
Restore bindings before model availability checks, require explicit reconnect/restart recovery, detach inactive plans, remove the general Intercom actuator, and reject placeholder evidence without hashing the plan log. Preserve synchronous handoff-before-ack; document that the void SDK cannot confirm durable message delivery.
This commit is contained in:
1 parent
2824396a71
commit
325b93983f
12 files changed
+403
-39
No files matched your search
+3
-3
@@ -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 {
|
||||
|
||||
+98
-15
@@ -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<PlanState>(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<void> {
|
||||
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<void> {
|
||||
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<typeof setInterval> | undefined;
|
||||
|
||||
async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started"): Promise<void> {
|
||||
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<boolean> {
|
||||
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 <objective> | clear | model <supervisor>`,
|
||||
description: `Plan goals, then open a visible supervisor session. /goals <objective> | reconnect | restart | clear | model <supervisor>`,
|
||||
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);
|
||||
|
||||
+24
-4
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -23,6 +23,7 @@ export class RoleModels {
|
||||
leave(): void { this.role = undefined; }
|
||||
|
||||
async enter(role: ModelRole, ctx: ExtensionContext, useCurrent = false): Promise<void> {
|
||||
if (this.stopped) throw new Error("Role model session ended.");
|
||||
this.role = role;
|
||||
this.ctx = ctx;
|
||||
let choice: Choice | undefined;
|
||||
|
||||
+32
-10
@@ -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<void> => {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user