mirror of
https://github.com/wassname/pi-plan.git
synced 2026-09-26 14:10:23 +08:00
Consolidate supervision and remember models per role
Bundle Intercom/VCC with the internal supervisor. Add default alignment questions and conversational plan review. Cover model recovery, cancellation and packed single-package operation.
This commit is contained in:
+124
-22
@@ -42,16 +42,21 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import supervise from "./internal/supervisor/index.js";
|
||||
import {
|
||||
alignmentPolicy,
|
||||
completeGoalDescription,
|
||||
completeGoalParamDescription,
|
||||
discussPlan,
|
||||
judgeSystem,
|
||||
judgeUser,
|
||||
planDrafting,
|
||||
planningState,
|
||||
reminder,
|
||||
resync,
|
||||
waivesAlignment,
|
||||
} from "./prompts.js";
|
||||
import { RoleModels } from "./role-models.js";
|
||||
import { focusSupervisor, initializeSupervisor, planHash, type SupervisorBinding, type SupervisorDecision, startSupervisor, supervisorBootstrap, supervisorRequest } from "./supervisor.js";
|
||||
|
||||
const STATE = "pi-goals-state";
|
||||
@@ -132,21 +137,29 @@ interface PlanState {
|
||||
/** Distinguishes explicit preferences from the old opt-in defaults. */
|
||||
defaultsVersion: 1;
|
||||
phase: Phase;
|
||||
reviewRequested: boolean;
|
||||
questionsWaived: boolean;
|
||||
/** Ready captured its fork, but worker model recovery is still pending (also across reload). */
|
||||
modelRecovery: "worker" | null;
|
||||
/** Optional model ref for the sign-off judge; unset => current session model, else pi's default. */
|
||||
judgeModel: string | null;
|
||||
planVersion: number | null;
|
||||
/** Interval for continuing active goals when supervision is disabled. */
|
||||
autoIntervalMs: number | null;
|
||||
autoPaused: boolean;
|
||||
/** Real supervisor session, enabled by default and paired through pi-intercom-supervisor. */
|
||||
/** Real supervisor session, enabled by default and paired through bundled Intercom. */
|
||||
stewardEnabled: boolean;
|
||||
supervisor: SupervisorBinding | null;
|
||||
}
|
||||
|
||||
export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
const models = new RoleModels(pi);
|
||||
let state: PlanState = {
|
||||
defaultsVersion: 1,
|
||||
phase: null,
|
||||
reviewRequested: false,
|
||||
questionsWaived: false,
|
||||
modelRecovery: null,
|
||||
judgeModel: null,
|
||||
planVersion: null,
|
||||
autoIntervalMs: AUTO_DEFAULT_INTERVAL_MS,
|
||||
@@ -202,7 +215,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
function scheduleAutoContinue(ctx: ExtensionContext, delayMs = state.autoIntervalMs): void {
|
||||
if (state.stewardEnabled || supervisorOnly) { clearAutoTimer(); return; }
|
||||
if (state.stewardEnabled || supervisorOnly || !models.ready) { clearAutoTimer(); return; }
|
||||
clearAutoTimer();
|
||||
if (delayMs === null || state.phase !== "working" || state.autoIntervalMs === null || state.autoPaused || !activeGoals(ctx)) return;
|
||||
autoTimer = setTimeout(() => {
|
||||
@@ -248,8 +261,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
function updateWidget(ctx: ExtensionContext): void {
|
||||
const tools = pi.getActiveTools().filter(tool => tool !== "RequestPlanReview");
|
||||
pi.setActiveTools(state.phase === "planning" && !supervisorOnly ? [...tools, "RequestPlanReview"] : tools);
|
||||
if (state.phase === "planning") {
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "planning"));
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", state.modelRecovery ? models.ready ? "retry Ready" : "worker model paused" : "planning"));
|
||||
ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: drafting goals"]);
|
||||
return;
|
||||
}
|
||||
@@ -300,9 +315,16 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
const version = state.planVersion;
|
||||
const approvedDraft = planHash(readPlan(ctx));
|
||||
const handoff = workMessage(ctx);
|
||||
const recoveringWorker = state.modelRecovery === "worker";
|
||||
state = { ...state, phase: "starting" };
|
||||
persist(); updateWidget(ctx);
|
||||
try {
|
||||
if (recoveringWorker) {
|
||||
const workerReady = await models.enter("worker", ctx);
|
||||
if (signal.aborted || state.planVersion !== version || !state.stewardEnabled) return;
|
||||
if (planHash(readPlan(ctx)) !== approvedDraft) throw new Error("The plan changed during model recovery; select Ready again");
|
||||
if (!workerReady) { state = { ...state, phase: "planning" }; persist(); updateWidget(ctx); return; }
|
||||
}
|
||||
const binding = await startSupervisor(pi, ctx, planPath(ctx), state.supervisor, supervisor => {
|
||||
if (signal.aborted) return;
|
||||
state = { ...state, supervisor }; persist();
|
||||
@@ -311,6 +333,16 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
if (planHash(readPlan(ctx)) !== approvedDraft) { await supervisorRequest(pi, "stop", { bindingId: binding.id }); state = { ...state, supervisor: null }; throw new Error("The plan changed during initialization; select Ready again"); }
|
||||
state = { ...state, supervisor: binding };
|
||||
persist(); updateWidget(ctx);
|
||||
state = { ...state, modelRecovery: "worker" }; persist();
|
||||
const workerReady = await models.enter("worker", ctx);
|
||||
if (signal.aborted || state.planVersion !== version || !state.stewardEnabled) return;
|
||||
if (!workerReady) {
|
||||
state = { ...state, phase: "planning" }; persist(); updateWidget(ctx);
|
||||
return; // Keep the attached pairing inactive and the preference target on worker.
|
||||
}
|
||||
if (signal.aborted || state.planVersion !== version || state.supervisor?.id !== binding.id || !state.stewardEnabled) return;
|
||||
if (planHash(readPlan(ctx)) !== approvedDraft) { await supervisorRequest(pi, "stop", { bindingId: binding.id }); state = { ...state, supervisor: null }; throw new Error("The plan changed during model restoration; select Ready again"); }
|
||||
state = { ...state, modelRecovery: null }; persist();
|
||||
await supervisorRequest(pi, "activate", { bindingId: binding.id }, signal);
|
||||
if (signal.aborted || state.planVersion !== version || state.supervisor?.id !== binding.id || !state.stewardEnabled) return;
|
||||
if (planHash(readPlan(ctx)) !== approvedDraft) { await supervisorRequest(pi, "stop", { bindingId: binding.id }); state = { ...state, supervisor: null }; throw new Error("The plan changed during activation; select Ready again"); }
|
||||
@@ -319,16 +351,26 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
pi.sendUserMessage(handoff, { deliverAs: "followUp" });
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
state = { ...state, phase: "planning" }; persist(); updateWidget(ctx);
|
||||
state = { ...state, phase: "planning", modelRecovery: null }; persist(); updateWidget(ctx);
|
||||
ctx.ui.notify(`Could not initialize the supervisor: ${String(error)}. Use /goals supervisor to inspect startup, or /goals steward off and retry Ready.`, "error");
|
||||
} finally { if (operation === controller) operation = null; }
|
||||
} finally {
|
||||
if (!lifetime.signal.aborted && state.phase !== "working" && !state.modelRecovery) {
|
||||
await models.enter("planning", ctx);
|
||||
if (!state.phase) models.leave();
|
||||
}
|
||||
if (operation === controller) operation = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- /goals: enter plan mode (or clear / set judge / set steward) -------------------------------
|
||||
|
||||
pi.registerCommand("goals", {
|
||||
description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals plan <objective> | /goals supervisor | /goals worker | /goals zoom | /goals <objective> | /goals clear | /goals auto [minutes|off] | /goals judge <model> | /goals steward [on|off|status]`,
|
||||
description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals plan <objective> | /goals model current | /goals supervisor | /goals worker | /goals zoom | /goals <objective> | /goals clear | /goals auto [minutes|off] | /goals judge <model> | /goals steward [on|off|status]`,
|
||||
handler: async (args, ctx) => {
|
||||
if (args.trim() === "model current") {
|
||||
if (await models.useCurrent(ctx)) modelRecovered(ctx);
|
||||
return;
|
||||
}
|
||||
if (supervisorOnly) {
|
||||
const bootstrap = supervisorBootstrap(ctx)!;
|
||||
if (["worker", "supervisor", "zoom"].includes(args.trim())) await focusSupervisor(pi, bootstrap.binding, args.trim() as "worker" | "supervisor" | "zoom");
|
||||
@@ -355,12 +397,14 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
state = {
|
||||
...state,
|
||||
phase: null,
|
||||
modelRecovery: null,
|
||||
planVersion: null,
|
||||
autoPaused: false,
|
||||
supervisor: null,
|
||||
};
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
models.leave();
|
||||
ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info");
|
||||
return;
|
||||
}
|
||||
@@ -434,9 +478,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
return;
|
||||
}
|
||||
await stopSupervisor(ctx);
|
||||
if (!await models.enter("planning", ctx)) return;
|
||||
state = {
|
||||
...state,
|
||||
phase: "planning",
|
||||
modelRecovery: null,
|
||||
reviewRequested: false,
|
||||
questionsWaived: waivesAlignment(arg),
|
||||
planVersion: nextVersion(ctx),
|
||||
supervisor: null,
|
||||
};
|
||||
@@ -449,8 +497,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
// why plan mode read as never-ending: every reply re-armed it. They come back only on a
|
||||
// resync (session start / compaction), when the model has genuinely lost them.
|
||||
const seed = arg
|
||||
? `We're in plan mode. Objective: ${arg}\n\n${planDrafting}\n\nWrite the plan to ${planPath(ctx)}.`
|
||||
: `We're in plan mode. Tell me what you want to plan.\n\n${planDrafting}\n\nWrite the plan to ${planPath(ctx)}.`;
|
||||
? `We're in plan mode. Objective: ${arg}\n\n${planDrafting}\n\n${alignmentPolicy(state.questionsWaived)}\n\nWrite the plan to ${planPath(ctx)}.`
|
||||
: `We're in plan mode. Tell me what you want to plan.\n\n${planDrafting}\n\n${alignmentPolicy(state.questionsWaived)}\n\nWrite the plan to ${planPath(ctx)}.`;
|
||||
pi.sendUserMessage(seed, { deliverAs: "followUp" });
|
||||
},
|
||||
});
|
||||
@@ -483,7 +531,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
if ((state.phase !== "planning" && state.phase !== "starting") || !planningContextPending) return;
|
||||
planningContextPending = false;
|
||||
const content = planningState(planPath(ctx));
|
||||
const content = planningState(planPath(ctx), state.questionsWaived);
|
||||
return { message: { customType: PLANNING_CONTEXT, content, display: false } };
|
||||
});
|
||||
|
||||
@@ -494,7 +542,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
const messages = inPlanGate ? event.messages : event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT);
|
||||
if (inPlanGate && planningContextPending) {
|
||||
planningContextPending = false;
|
||||
const text = planningState(planPath(ctx));
|
||||
const text = planningState(planPath(ctx), state.questionsWaived);
|
||||
return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] };
|
||||
}
|
||||
const text = dueInjection(ctx, readPlan(ctx));
|
||||
@@ -505,6 +553,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
|
||||
// PI: Human plan-mode replies are durable evidence of the interview, not model summaries.
|
||||
pi.on("input", async (event, ctx) => {
|
||||
if (!models.ready) { ctx.ui.notify("Role model unavailable. Select a different model with /model, explicitly use the current one with /goals model current, or configure the saved model and reload.", "error"); return { action: "handled" as const }; }
|
||||
if (event.source !== "extension") {
|
||||
clearAutoTimer();
|
||||
autoImmediateUsed = false;
|
||||
@@ -538,6 +587,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
if (state.phase === "working" && (event.toolName === "subagent" || (event.toolName === "process" && (event.input as { action?: string }).action === "start"))) {
|
||||
runStartedBackgroundWork = true;
|
||||
}
|
||||
if (!models.ready) return { block: true, reason: "Role model unavailable; select with /model before continuing." };
|
||||
if (state.phase !== "planning" && state.phase !== "starting") return;
|
||||
if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) {
|
||||
const target = (event.input as { path?: string }).path;
|
||||
@@ -556,12 +606,18 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
});
|
||||
|
||||
// PI: Print after Pi settles. agent_end is still streaming, so its message queues behind the menu.
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
let reviewOpen = false;
|
||||
async function reviewPlan(ctx: ExtensionContext): Promise<void> {
|
||||
if (reviewOpen || lifetime.signal.aborted) return;
|
||||
reviewOpen = true;
|
||||
try { await offerPlanReview(ctx); } finally { reviewOpen = false; }
|
||||
}
|
||||
async function offerPlanReview(ctx: ExtensionContext): Promise<void> {
|
||||
if (state.phase === "working") {
|
||||
settleAuto(ctx);
|
||||
return;
|
||||
}
|
||||
if (state.phase !== "planning" || !ctx.hasUI) return;
|
||||
if (state.phase !== "planning" || !state.reviewRequested || !ctx.hasUI) return;
|
||||
let printed = "";
|
||||
while (true) {
|
||||
const plan = readPlan(ctx);
|
||||
@@ -573,13 +629,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
printed = plan;
|
||||
pi.sendMessage({ customType: "plan", content: plan, display: true });
|
||||
}
|
||||
const choice = await ctx.ui.select(`Plan drafted in ${planRel(ctx)}.`, ["Ready", "Refine", "Edit", "Cancel"]);
|
||||
if (choice === "Refine") {
|
||||
const notes = await ctx.ui.editor("What should change about the plan?", "");
|
||||
if (!notes?.trim()) continue;
|
||||
writePlan(ctx, appendInterview(plan, notes));
|
||||
const choice = await ctx.ui.select(`Plan drafted in ${planRel(ctx)}.`, ["Ready", "Discuss", "Edit", "Cancel"]);
|
||||
if (choice === "Discuss" || choice === undefined) {
|
||||
if (state.modelRecovery && !await models.enter("planning", ctx)) return;
|
||||
state = { ...state, modelRecovery: null };
|
||||
state = { ...state, reviewRequested: false }; persist();
|
||||
planningContextPending = true;
|
||||
pi.sendUserMessage(`Revise the plan at ${planPath(ctx)} using these human notes:\n\n${notes}\n\nKeep the same goal structure.`, { deliverAs: "followUp" });
|
||||
pi.sendUserMessage(discussPlan, { deliverAs: "followUp" });
|
||||
return;
|
||||
}
|
||||
if (choice === "Edit") {
|
||||
@@ -593,11 +649,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
state = {
|
||||
...state,
|
||||
phase: null,
|
||||
modelRecovery: null,
|
||||
planVersion: null,
|
||||
supervisor: null,
|
||||
};
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
models.leave();
|
||||
ctx.ui.notify("Plan discarded.", "info");
|
||||
return;
|
||||
}
|
||||
@@ -606,20 +664,33 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
await startPlanSupervisor(ctx);
|
||||
return;
|
||||
}
|
||||
state = { ...state, phase: "working" };
|
||||
const version = state.planVersion;
|
||||
const approvedDraft = planHash(readPlan(ctx));
|
||||
state = { ...state, modelRecovery: "worker" }; persist();
|
||||
const workerReady = await models.enter("worker", ctx);
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (state.phase !== "planning" || state.planVersion !== version || planHash(readPlan(ctx)) !== approvedDraft) {
|
||||
await models.enter("planning", ctx);
|
||||
if (!state.phase) models.leave();
|
||||
ctx.ui.notify("Plan changed while restoring the worker model; review it again before Ready.", "warning");
|
||||
return;
|
||||
}
|
||||
if (!workerReady) return;
|
||||
state = { ...state, phase: "working", modelRecovery: null };
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
pi.sendUserMessage(workMessage(ctx), { deliverAs: "followUp" });
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
pi.on("agent_settled", async (_event, ctx) => reviewPlan(ctx));
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const bootstrap = supervisorBootstrap(ctx);
|
||||
if (bootstrap) {
|
||||
supervisorOnly = true;
|
||||
pi.setActiveTools(pi.getActiveTools().filter(tool => tool !== "CompleteGoal"));
|
||||
initializeSupervisor(pi, ctx, bootstrap, lifetime.signal);
|
||||
pi.setActiveTools(pi.getActiveTools().filter(tool => tool !== "CompleteGoal" && tool !== "RequestPlanReview"));
|
||||
if (await models.enter("supervisor", ctx)) initializeSupervisor(pi, ctx, bootstrap, lifetime.signal);
|
||||
return;
|
||||
}
|
||||
const last = ctx.sessionManager
|
||||
@@ -632,6 +703,9 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
state = {
|
||||
defaultsVersion: 1,
|
||||
phase: last?.data?.phase === "working" ? "working" : last?.data?.phase ? "planning" : null,
|
||||
reviewRequested: last?.data?.reviewRequested ?? true,
|
||||
questionsWaived: last?.data?.questionsWaived ?? false,
|
||||
modelRecovery: last?.data?.modelRecovery ?? null,
|
||||
judgeModel: last?.data?.judgeModel ?? null,
|
||||
planVersion: last?.data?.planVersion ?? null,
|
||||
autoIntervalMs: useNewDefaults || saved?.autoIntervalMs === undefined ? AUTO_DEFAULT_INTERVAL_MS : saved.autoIntervalMs,
|
||||
@@ -644,6 +718,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
planningContextPending = state.phase === "planning" || state.phase === "starting";
|
||||
resyncReason = state.phase === "working" ? "New session." : null;
|
||||
updateWidget(ctx);
|
||||
if (state.phase && !await models.enter(state.phase === "working" || state.modelRecovery ? "worker" : "planning", ctx)) return;
|
||||
scheduleAutoContinue(ctx);
|
||||
});
|
||||
|
||||
@@ -653,6 +728,19 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
clearAutoTimer();
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "RequestPlanReview",
|
||||
label: "Review plan",
|
||||
description: "Planning only: after task-specific alignment questions have been answered (or explicitly waived for this objective), and the final plan is ready, show the human Ready / Discuss / Edit / Cancel. Do not call while waiting for answers. Call again after discussion is finished, even for an unchanged draft. This does not approve or start work.",
|
||||
parameters: Type.Object({}),
|
||||
async execute(_id, _params, _signal, _update, ctx) {
|
||||
if (supervisorOnly || state.phase !== "planning") return result("Only a planning session can request plan review.", true);
|
||||
if (!scanGoals(readPlan(ctx)).length) return result("Draft concrete goals before requesting review.", true);
|
||||
state = { ...state, reviewRequested: true }; persist();
|
||||
return { ...result("Plan review requested. End this response and wait for the human's choice."), terminate: true };
|
||||
},
|
||||
});
|
||||
|
||||
// --- the one blessed tool: CompleteGoal ---------------------------------------------------------
|
||||
|
||||
pi.registerTool({
|
||||
@@ -724,6 +812,20 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
return result(outcome.resultText, outcome.isError);
|
||||
},
|
||||
});
|
||||
// Registered after role restoration, so rejoin cannot start a supervisor turn on the worker model.
|
||||
supervise(pi, () => models.ready);
|
||||
function modelRecovered(ctx: ExtensionContext): void {
|
||||
if (supervisorOnly) {
|
||||
const bootstrap = supervisorBootstrap(ctx);
|
||||
if (bootstrap) initializeSupervisor(pi, ctx, bootstrap, lifetime.signal);
|
||||
} else if (state.modelRecovery && models.ready) {
|
||||
// Do not await a UI dialog inside Pi's model_select dispatch.
|
||||
setImmediate(() => { void reviewPlan(ctx).catch(error => ctx.ui.notify(String(error), "error")); });
|
||||
}
|
||||
}
|
||||
pi.on("model_select", (event, ctx) => {
|
||||
if (models.ready && !models.restoring && event.source !== "restore") modelRecovered(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// --- helpers (module scope) --------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface BackgroundState { quiet: boolean; description: string }
|
||||
|
||||
/** Public process-local protocols only. Missing owners remain explicitly unknown. */
|
||||
export async function backgroundState(pi: any): Promise<BackgroundState> {
|
||||
let processes: unknown;
|
||||
pi.events.emit("processes:request:list", { reply: (value: unknown) => { processes = value; } });
|
||||
const rows = Array.isArray(processes) ? processes : null;
|
||||
const processKnown = rows?.every(p => p && ["running", "terminating", "terminate_timeout", "exited", "killed"].includes(p.status));
|
||||
const activeProcesses = processKnown ? rows!.filter(p => !["exited", "killed"].includes(p.status)).length : null;
|
||||
let activeSubagents: number | null = null;
|
||||
if (pi.getAllTools?.().some((tool: any) => tool.name === "subagent")) {
|
||||
const requestId = randomUUID();
|
||||
activeSubagents = await new Promise<number | null>(resolve => {
|
||||
let unsubscribe: unknown;
|
||||
const finish = (value: number | null) => { clearTimeout(timer); if (typeof unsubscribe === "function") unsubscribe(); resolve(value); };
|
||||
const timer = setTimeout(() => finish(null), 2_000);
|
||||
unsubscribe = pi.events.on(`subagents:rpc:v1:reply:${requestId}`, (reply: any) => {
|
||||
if (reply?.requestId !== requestId) return;
|
||||
const count = reply?.success && reply?.data?.fleet?.version === 1 ? reply.data.fleet.totalActive : undefined;
|
||||
finish(Number.isSafeInteger(count) && count >= 0 ? count : null);
|
||||
});
|
||||
pi.events.emit("subagents:rpc:v1:request", { version: 1, requestId, method: "status", params: {}, source: { extension: "pi-supervise" } });
|
||||
});
|
||||
}
|
||||
return {
|
||||
quiet: activeProcesses === 0 && activeSubagents === 0,
|
||||
description: `processes: ${activeProcesses ?? "unknown (provider unavailable)"}; subagents: ${activeSubagents ?? "unknown (provider unavailable)"}; unregistered detached work is not tracked`,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/** Process-local integration; Intercom carries the corresponding peer messages. */
|
||||
export const PLAN_API_EVENT = "pi-supervise:plan:v1";
|
||||
export interface PlanBinding {
|
||||
id: string;
|
||||
planPath: string;
|
||||
workerSession: string;
|
||||
workerPane: string;
|
||||
supervisorPane?: string;
|
||||
supervisorSession?: string;
|
||||
active?: boolean;
|
||||
stopped?: boolean;
|
||||
everyTurns: number;
|
||||
intervalMs: number;
|
||||
compactTokens: number;
|
||||
}
|
||||
export interface GoalReview {
|
||||
requestId: string;
|
||||
bindingId: string;
|
||||
goal: string;
|
||||
planHash: string;
|
||||
}
|
||||
export interface GoalDecision extends GoalReview {
|
||||
decision: "approve" | "needs_work" | "needs_user";
|
||||
reason: string;
|
||||
}
|
||||
export type PlanWire =
|
||||
| { t: "plan_hello" | "plan_hello_ack"; to: string; bindingId: string; role: "worker" | "supervisor"; sessionFile: string }
|
||||
| ({ t: "goal_review"; to: string } & GoalReview)
|
||||
| ({ t: "goal_decision"; to: string } & GoalDecision)
|
||||
| { t: "goal_cancel"; to: string; requestId: string; bindingId: string }
|
||||
| { t: "plan_update"; to: string; bindingId: string }
|
||||
| { t: "plan_activate" | "plan_stop"; to: string; bindingId: string };
|
||||
export interface PlanApiRequest {
|
||||
version: 1;
|
||||
method: "prepare" | "bootstrap" | "attached" | "activate" | "status" | "review" | "update" | "stop";
|
||||
binding?: PlanBinding;
|
||||
bindingId?: string;
|
||||
workerId?: string;
|
||||
goal?: string;
|
||||
planHash?: string;
|
||||
signal?: AbortSignal;
|
||||
handled?: boolean;
|
||||
resolve(value: unknown): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
export function validBinding(value: unknown): value is PlanBinding {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const b = value as PlanBinding;
|
||||
return [b.id, b.planPath, b.workerSession, b.workerPane].every(v => typeof v === "string" && v.length > 0)
|
||||
&& [b.everyTurns, b.intervalMs, b.compactTokens].every(v => Number.isSafeInteger(v) && v > 0);
|
||||
}
|
||||
export function planText(binding: PlanBinding): string {
|
||||
return readFileSync(binding.planPath, "utf8");
|
||||
}
|
||||
export function planHash(text: string): string {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
export function validPlanWire(value: any): value is PlanWire {
|
||||
if (!value || typeof value.to !== "string" || typeof value.bindingId !== "string") return false;
|
||||
if (value.t === "plan_hello" || value.t === "plan_hello_ack") return ["worker", "supervisor"].includes(value.role) && typeof value.sessionFile === "string";
|
||||
if (value.t === "plan_update" || value.t === "plan_activate" || value.t === "plan_stop") return true;
|
||||
if (typeof value.requestId !== "string") return false;
|
||||
if (value.t === "goal_cancel") return true;
|
||||
if (typeof value.goal !== "string" || typeof value.planHash !== "string") return false;
|
||||
if (value.t === "goal_review") return true;
|
||||
return value.t === "goal_decision" && ["approve", "needs_work", "needs_user"].includes(value.decision) && typeof value.reason === "string";
|
||||
}
|
||||
|
||||
/** A wait owns its cancellation and deadline; a timeout never means approval. */
|
||||
export function pendingReply<T>(signal: AbortSignal | undefined, cancel: () => void, timeoutMs = 600_000) {
|
||||
let finish!: (value?: T, error?: Error) => void;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const abort = () => finish(undefined, new Error("Supervisor request cancelled"));
|
||||
const timer = setTimeout(() => finish(undefined, new Error("Supervisor request timed out; retry or turn the steward off")), timeoutMs);
|
||||
finish = (value, error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
if (error) { cancel(); reject(error); } else resolve(value as T);
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) queueMicrotask(abort);
|
||||
});
|
||||
return { requestId: randomUUID(), promise, finish };
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Every word the supervisor session reads, in the order it reads them, so this file is the run:
|
||||
*
|
||||
* 1. loadSupervisorPrompt, the policy, read once at /supervise
|
||||
* 2. BRIEF, sent once at pairing, carrying that policy
|
||||
* 3. TOOL_*, the three verdicts, in context at every model call because tools always are
|
||||
* 4. REVIEW_NUDGE, sent with every view, and short because 1 to 3 already said the rest
|
||||
* 5. NO_GOAL and DONE_BLOCKED, refusals, read only when a tool is refused
|
||||
* 6. DEFAULT_SUPERVISOR_PROMPT, the policy used when no SUPERVISOR.md exists
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/**
|
||||
* Same precedence as @monotykamary/pi-supervisor, so an existing SUPERVISOR.md keeps working:
|
||||
* <cwd>/.pi/SUPERVISOR.md, then <agent dir>/SUPERVISOR.md, then the default below.
|
||||
*
|
||||
* getAgentDir is pi's own, so a profile that moves the agent dir moves this with it. That matters:
|
||||
* the only SUPERVISOR.md on this machine lives in a profile, not in ~/.pi/agent.
|
||||
*/
|
||||
export function loadSupervisorPrompt(cwd: string): { prompt: string; source: string } {
|
||||
for (const path of [join(cwd, ".pi", "SUPERVISOR.md"), join(getAgentDir(), "SUPERVISOR.md")]) {
|
||||
if (existsSync(path)) return { prompt: readFileSync(path, "utf-8").trim(), source: path };
|
||||
}
|
||||
return { prompt: DEFAULT_SUPERVISOR_PROMPT, source: "built-in" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Put in the supervisor's context when pairing, so it knows its job before the first view arrives.
|
||||
*
|
||||
* The last line has been wrong three times. "Reply with exactly: watching" taught a text answer,
|
||||
* and deepseek-v4-flash then answered two real views with the plain word "wait" and no tool call
|
||||
* (session 019ff4eb-c66c, 2026-08-12). Ordering a let_it_run call taught the opposite: with no view
|
||||
* to read, one supervisor answered its own brief 98 times in a row (019ffa5f, 2026-08-13). Asking
|
||||
* for no answer at all still got two calls, because the brief arrived as a user message and a user
|
||||
* message is a turn. It now arrives without one, so the view is the first thing there is to answer.
|
||||
*/
|
||||
export const BRIEF = (policy: string, goal: string, worker: string) =>
|
||||
`${policy}
|
||||
|
||||
You are now supervising the pi session "${worker}".
|
||||
|
||||
The goal is between the tags below, exactly as the human typed it. Nothing outside the tags is
|
||||
part of the goal. A multi-line goal appears as a one-line locator in ordinary views. Its full text
|
||||
returns after a goal change, reload, compaction, and before every fifth review.
|
||||
|
||||
<goal>
|
||||
${goal || "not given, so infer it from the first view you receive and call set_goal"}
|
||||
</goal>
|
||||
|
||||
Your verdict is a tool call, not text: let_it_run, steer or done. If the policy above tells you to reply
|
||||
with JSON, ignore that part: it belongs to a different supervisor and nothing parses it here.
|
||||
|
||||
You see the worker twice: when it stops, and on a check in while it is still working. Each view
|
||||
carries only what is new since your last look, so read it against what you already know rather
|
||||
than expecting the whole session again.
|
||||
|
||||
The view names the worker's model and how full its context is. A small or fast model needs one
|
||||
small step per instruction. A worker near the top of its context is about to compact, so tell it
|
||||
to write down what matters before it loses the detail.
|
||||
|
||||
There is no round limit and no budget. Supervision runs until the human stops it. Ending early is
|
||||
the failure this exists to prevent, so never stop because it feels like enough.
|
||||
|
||||
The human typing in the worker session is not a handover, and it is not a reason to stand back.
|
||||
They say a word and go to bed; the worker is then stopped with nobody driving it, which is the
|
||||
state you exist for. They will stop you themselves when they want you stopped. Judge the worker
|
||||
against the goal and nothing else.
|
||||
|
||||
Every view says how long the worker has gone with no new turn. A worker that has produced nothing
|
||||
for a long time is stuck, or waiting for you, or in one command that will not return. Say which
|
||||
one you think it is, and use the number rather than guessing from the turns.
|
||||
|
||||
The worker sends its first view as it pairs. It is below this message, and it is what you
|
||||
answer.`;
|
||||
|
||||
/**
|
||||
* Sent on a resume or a /reload that finds the pairing still alive.
|
||||
*
|
||||
* Short on purpose: the policy is already in the transcript above it. Only the answer shape and the
|
||||
* goal repeat, because those are what a supervisor drops first, and because a /reload is how a
|
||||
* changed prompt reaches a running session. Without this, fixing the wording of the brief needs
|
||||
* /supervise stop and a fresh pairing, which throws away the supervisor's memory of its own steers.
|
||||
*/
|
||||
export const REANCHOR = (goal: string, rounds: number) =>
|
||||
`Supervising again, after a reload or a restart.
|
||||
|
||||
<goal>
|
||||
${goal || "not set"}
|
||||
</goal>
|
||||
|
||||
${rounds} instructions so far.
|
||||
|
||||
A view of the worker follows. Answer it with one tool call: steer, done or let_it_run. The word on its
|
||||
own does nothing; only the call reaches the worker.`;
|
||||
|
||||
/** Sent when the human runs /supervise goal, so the supervisor does not judge against the old one. */
|
||||
export const GOAL_CHANGED = (goal: string) =>
|
||||
`The human changed the goal. From now on judge the worker against what is between the tags,
|
||||
and against nothing else:
|
||||
|
||||
<goal>
|
||||
${goal}
|
||||
</goal>
|
||||
|
||||
A fresh view follows. Answer it with one tool call: steer, done or let_it_run.`;
|
||||
|
||||
/**
|
||||
* The three verdicts. These live in the tool descriptions, which the API sends at every model call,
|
||||
* so they are the only instructions here that a supervisor compaction cannot lose.
|
||||
*/
|
||||
export const TOOL_LET_IT_RUN =
|
||||
"Use when the current worker view gives quoted evidence that no instruction is needed."
|
||||
+ " The call sends no message to the worker. Call it once, then end the current supervisor response."
|
||||
// Repeated here because a tool description survives a compaction and the brief does not. The
|
||||
// live failure was a let_it_run reasoned "human is actively directing", two hours before dawn.
|
||||
+ " A human message does not end supervision; only an explicit stop command ends supervision.";
|
||||
|
||||
/**
|
||||
* How a look ends, and it must appear in every verdict's result.
|
||||
*
|
||||
* A tool result reads as a prompt to act again. A turn ends only when the assistant writes text and
|
||||
* calls no tool, so a result that does not name that exit leaves another tool call as the only move.
|
||||
* The let_it_run result used to say "Say nothing more until the next view arrives", which forbids the
|
||||
* exit outright: session 019ffa73 answered with a second let_it_run on all sixteen looks before 11:05Z
|
||||
* and aborted every one. The steer result said nothing about ending, and cost a spare let_it_run on
|
||||
* 22 of 22 steers in the fifteen hours after.
|
||||
*/
|
||||
export const END_TURN =
|
||||
`End the current supervisor response now: write one short line or no text, then make no further tool call.`;
|
||||
|
||||
export const LET_IT_RUN_ACK = (reason: string, workerStopped = false) =>
|
||||
`No supervisor instruction was sent for the current worker view. Supervisor-provided reason, not independently verified: ${reason}\n\nThe supervisor has completed its verdict for the current worker view. ${END_TURN}
|
||||
${workerStopped ? STOPPED_WARNING : "A later worker view starts the next supervisor review."}`;
|
||||
|
||||
/**
|
||||
* Added to the let_it_run result when the view said the worker had stopped.
|
||||
*
|
||||
* Session 019ffa73, 2026-08-14: the worker stopped, the supervisor answered let_it_run "waiting for
|
||||
* the worker to re-queue", and both sat still for two and a half hours. A stopped worker does not
|
||||
* resume on its own, so letting it run leaves it stopped. The timer now looks again either way, and
|
||||
* this says why that look will show the same thing.
|
||||
*/
|
||||
export const STOPPED_WARNING =
|
||||
`The current worker view reports that worker execution stopped. A stopped worker does not resume
|
||||
without a new user or supervisor message. If the goal remains unmet, send a concrete continuation
|
||||
instruction. A human message does not end supervision. A later worker view will report the worker state.`;
|
||||
|
||||
/** The answer to a second let_it_run in one look. Costs a round trip and no error line. */
|
||||
export const LET_IT_RUN_AGAIN =
|
||||
`The supervisor already recorded a verdict for the current worker view. This second let_it_run call
|
||||
sent no instruction. ${END_TURN}`;
|
||||
|
||||
/** The answer after a supervisor directive is sent. A repeat warning is appended after it. */
|
||||
export const STEER_ACK = (round: number, workerId: string) =>
|
||||
`Supervisor instruction ${round} was sent to worker session ${workerId}. Worker receipt and execution
|
||||
are not confirmed. The supervisor has completed its verdict for the current worker view. ${END_TURN}`;
|
||||
export const TOOL_STEER =
|
||||
"Send one concrete next action to the worker. The extension sends the message to the paired worker session; worker receipt and execution require a later worker view.";
|
||||
export const TOOL_DONE =
|
||||
"Declare the goal met and stop supervising. Only call this with quoted evidence from the view.";
|
||||
|
||||
/**
|
||||
* Sent with every view, so it is deliberately short.
|
||||
*
|
||||
* What used to be here and is now sent once: the verdict rules (BRIEF, and the tool descriptions,
|
||||
* which survive a compaction) and the instructions already sent (the supervisor's own steer calls
|
||||
* are in its context; the steer tool warns about a repeat when it happens). A multi-line goal
|
||||
* stays as a one-line locator inside the view.
|
||||
*
|
||||
* A check in is not a decision point. Interrupting a working agent is expensive and usually wrong,
|
||||
* so the two triggers ask for different things.
|
||||
*/
|
||||
/**
|
||||
* The two openers, and the test the context pruner uses to find a view it can drop.
|
||||
*
|
||||
* They are constants because two things read them: the nudge that writes a view, and the pruner
|
||||
* that later collapses it. Matching the prose in two places would let them drift silently, and a
|
||||
* pruner that stops recognising views just quietly stops working.
|
||||
*/
|
||||
export const VIEW_STOPPED = "The worker stopped.";
|
||||
export const VIEW_CHECKIN = "Checking in on the worker, which is still going.";
|
||||
export const isViewText = (text: string) => text.startsWith(VIEW_STOPPED) || text.startsWith(VIEW_CHECKIN);
|
||||
|
||||
/** What an old view is replaced with. Short, and it says where the content went. */
|
||||
export const VIEW_PRUNED =
|
||||
"[an earlier view of the worker, dropped once you had judged it. Your verdict on it follows.]";
|
||||
|
||||
export const REVIEW_NUDGE = (view: string, rounds: number, stopped: boolean) =>
|
||||
stopped
|
||||
? `${VIEW_STOPPED}
|
||||
|
||||
${view}
|
||||
|
||||
${rounds} instructions so far. The status line says how long it has had no new turn. It will not start
|
||||
again by itself, and the human being present does not count as somebody driving it. Answer with one
|
||||
tool call: steer, done or let_it_run. The word on its own does nothing; only the call reaches the
|
||||
worker.`
|
||||
: `${VIEW_CHECKIN}
|
||||
|
||||
${view}
|
||||
|
||||
Call let_it_run unless the view gives concrete evidence that the worker needs an instruction.`;
|
||||
|
||||
/** Refusal shown when done is called while the worker still has work running. */
|
||||
export const DONE_BLOCKED = (what: string) =>
|
||||
`Cannot finish: the worker still has work running (${what}). Wait for the next view.`;
|
||||
|
||||
/** Refusal shown when the supervisor tries to steer with no goal set. */
|
||||
export const NO_GOAL = `No goal is set, so you must not steer or finish. Inventing a task is worse
|
||||
than doing nothing. Either call set_goal with the goal you infer from the worker's view, which
|
||||
tells the human what you chose, or reply in plain text asking them for it. Your reply reaches
|
||||
their phone.`;
|
||||
|
||||
/**
|
||||
* Default supervisor prompt. A project SUPERVISOR.md overrides it, same as @monotykamary/pi-supervisor.
|
||||
* Unlike that extension there is no JSON verdict to parse, because the verdict is a tool call.
|
||||
*/
|
||||
export const DEFAULT_SUPERVISOR_PROMPT = `You supervise a coding agent from outside its session.
|
||||
Your job is to make it reach the goal without the human stepping in.
|
||||
|
||||
Judge from the view only. You cannot see the worker's files unless you read them yourself.
|
||||
|
||||
Call steer when the work is incomplete, when the worker asked a question you can answer with a
|
||||
sensible default, or when it claims success without evidence. One concrete next action per steer.
|
||||
Never repeat a steer that had no effect; change the approach instead.
|
||||
|
||||
The view line "child pi processes still running" means the worker delegated to a subagent that is
|
||||
still working. It stopped, the subagent did not. Do not call done, it will be refused. Steer the
|
||||
worker to wait for that subagent and report what it produced.
|
||||
|
||||
The view line "no new file or commit for N reviews in a row" means your last N instructions
|
||||
moved nothing the worker's session can show. Two or more is your signal to change approach, ask the
|
||||
human, or check whether the goal is already met. Sometimes it is honest work on one file, so read
|
||||
the recent turns before you decide.
|
||||
|
||||
Call done only when all of these hold:
|
||||
1. the worker named the artifact file it produced, with a path
|
||||
2. the worker quoted text from that file, rather than summarising it
|
||||
3. nothing in the view contradicts the claim
|
||||
|
||||
A confident summary is not evidence. When in doubt, steer.
|
||||
|
||||
When the worker does machine learning or data research, a wrong result looks exactly like a right
|
||||
one. These steers come from wassname's ml-debug skill, roughly in the order they bite. Each one is
|
||||
something you can see in the view.
|
||||
- It concluded without reading its data. Steer it to paste the lines it read into the chat, a raw
|
||||
sample and the metric line, not a summary of them. Quoting is the point, twice over: you and the
|
||||
human can then check the same text, and an agent that has to quote has to look (Karpathy inspects
|
||||
the data before touching the model; Nanda: read your data, often it is quite bad). A conclusion
|
||||
with no quoted output, a ranking with no per-item evidence, or "the method failed" with no sample
|
||||
of what the output looked like, all mean it has not looked.
|
||||
- It reports a surprising win. Most true results are boring, so an exciting one is more likely to
|
||||
be false (Neel Nanda). Steer it to rule out a bug, leakage or a broken evaluation first.
|
||||
- It reports a failure and moves on, or calls the failure a property of the method. Assume a bug:
|
||||
bugs are far more common, and far cheaper to find, than a real negative result (Andy Jones).
|
||||
Steer it to write two or three diagnoses, one of them a bug in its own code, put a rough
|
||||
probability on each, and run the cheapest test that tells them apart. Broken research code fails
|
||||
silently and still runs, so "it ran" is not evidence that it worked.
|
||||
- It is about to start another long run without saying what each outcome would mean. Steer it to
|
||||
write that prediction first (Rahtz: think more, experiment less). On a shared GPU that is the
|
||||
cheapest hour you can buy.
|
||||
- It compares two methods from one run each. Seed variance alone splits identical configurations
|
||||
into different distributions (Henderson), so steer it to say what varies before it ranks
|
||||
anything.
|
||||
- It changed two things in one run and credits one of them. Changing anything changes everything
|
||||
(Sculley et al., CACE). Steer it to say what it can actually attribute, or to rerun with one
|
||||
change.
|
||||
- It saw a number it cannot explain and carried on. An anomaly it did not go looking for is the
|
||||
cheapest bug it will ever find, so steer it to chase that before anything else.
|
||||
|
||||
Three more ways work gets faked, from @monotykamary/pi-supervisor's cheating list. Steer, and ask
|
||||
for the output that would settle it.
|
||||
- the worker edits a test to weaken an assertion, or skips a failing one, and calls that progress
|
||||
- it reports a number without the command output it came from, or edits the measurement instead of
|
||||
the thing being measured
|
||||
- it runs a smaller dataset or part of the suite, then reports as if it ran the whole thing
|
||||
|
||||
Do not answer questions that need real human knowledge: passwords, credentials, spending money,
|
||||
or a choice between two designs the human cares about. For those, reply in plain text saying what
|
||||
you need. Your reply reaches the human's phone.`;
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* What the two sessions say to each other over the pi-intercom extension channel.
|
||||
*
|
||||
* The channel never enters a transcript and never starts a turn, so each side triggers its own
|
||||
* turn locally with pi.sendUserMessage after it receives one of these.
|
||||
*/
|
||||
|
||||
import { type PlanBinding, type PlanWire, validBinding, validPlanWire } from "./plan-api.js";
|
||||
|
||||
export const NAMESPACE = "wassname/pi-intercom-supervisor/v1";
|
||||
|
||||
// No round cap, no budget, on purpose. Supervision runs until the human stops it with
|
||||
// /supervise stop, because premature stopping is the failure this whole thing exists to prevent
|
||||
// (wassname's SUPERVISOR.md, citing arXiv:2410.07095: 8.7% vs 0.8% on MLE-bench).
|
||||
|
||||
export type Wire = PlanWire
|
||||
/** Roll call, broadcast, so "to" is the wildcard rather than a session. Only /supervise sends it. */
|
||||
| { t: "who"; to: "*" }
|
||||
/** The answer to a roll call: I load this extension, I am free, and I am not a child run. */
|
||||
| { t: "here"; to: string }
|
||||
| { t: "pair"; to: string; goal: string; plan?: PlanBinding }
|
||||
| { t: "paired"; to: string; plan?: PlanBinding }
|
||||
| { t: "goal"; to: string; goal: string }
|
||||
/** stopped: the worker settled, so this is a decision point. false: a check in mid-turn. */
|
||||
| { t: "view"; to: string; view: string; stopped: boolean }
|
||||
/** Supervisor asks for a view now. Its own turn cannot make one: the worker publishes them. */
|
||||
| { t: "look"; to: string }
|
||||
| { t: "directive"; to: string; text: string }
|
||||
| { t: "done"; to: string; reason: string }
|
||||
| { t: "unpair"; to: string };
|
||||
|
||||
/** Validates the field each kind carries, so a malformed peer cannot inject "[supervisor] undefined". */
|
||||
export function isWire(payload: unknown): payload is Wire {
|
||||
if (typeof payload !== "object" || payload === null) return false;
|
||||
if (validPlanWire(payload)) return true;
|
||||
const { t, to, goal, view, stopped, text, reason, plan } = payload as Record<string, unknown>;
|
||||
if ((t === "pair" || t === "paired") && plan !== undefined && !validBinding(plan)) return false;
|
||||
if (typeof to !== "string") return false;
|
||||
if (t === "pair" || t === "goal") return typeof goal === "string";
|
||||
if (t === "view") return typeof view === "string" && typeof stopped === "boolean";
|
||||
if (t === "directive") return typeof text === "string" && text.trim().length > 0;
|
||||
if (t === "done") return typeof reason === "string";
|
||||
return t === "unpair" || t === "paired" || t === "look" || t === "who" || t === "here";
|
||||
}
|
||||
|
||||
/**
|
||||
* Words two instructions share, over the words either uses. Stopwords and short words dropped.
|
||||
*
|
||||
* Six remembered instructions do not stop repetition, because the same order rephrased reads as
|
||||
* new. This catches the rephrasing that shares vocabulary; it cannot catch a true paraphrase.
|
||||
*/
|
||||
export function overlap(a: string, b: string): number {
|
||||
const words = (s: string) =>
|
||||
new Set(
|
||||
s
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9_./-]+/)
|
||||
.filter((w) => w.length > 3 && !STOPWORDS.has(w)),
|
||||
);
|
||||
const [x, y] = [words(a), words(b)];
|
||||
if (!x.size || !y.size) return 0;
|
||||
const shared = [...x].filter((w) => y.has(w)).length;
|
||||
return shared / (x.size + y.size - shared);
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
"then", "with", "that", "this", "from", "into", "your", "each", "have", "then", "should", "please",
|
||||
"make", "sure", "also", "them", "they", "what", "when", "here", "there", "which", "will", "would",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Two instructions sharing this much vocabulary get flagged back to the supervisor.
|
||||
*
|
||||
* Measured on rewordings of one instruction: about 0.44. On two different instructions: under 0.2.
|
||||
* A true paraphrase that shares no words scores 0 and slips through, so this is a floor on
|
||||
* repetition, not a bound.
|
||||
*/
|
||||
export const OVERLAP_WARN = 0.4;
|
||||
|
||||
export interface SuperviseState {
|
||||
role: "none" | "worker" | "supervisor";
|
||||
/** Intercom session ID of the other side. The broker stamps this, so it cannot be forged. */
|
||||
pairedId: string;
|
||||
goal: string;
|
||||
steerRounds: number;
|
||||
/** Recent steer texts, so the supervisor can see repetition after its own context is compacted. */
|
||||
recentSteers: string[];
|
||||
/** Optional pi-goals integration. Standalone supervision keeps its original policy. */
|
||||
plan?: PlanBinding;
|
||||
/** Supervisor bootstrap completed and the worker acknowledged this plan pairing. */
|
||||
planInitialized?: boolean;
|
||||
}
|
||||
|
||||
export const EMPTY_STATE: SuperviseState = {
|
||||
role: "none",
|
||||
pairedId: "",
|
||||
goal: "",
|
||||
steerRounds: 0,
|
||||
recentSteers: [],
|
||||
};
|
||||
|
||||
/** How many past steers to keep and show back. Enough to spot a loop, small enough to stay cheap. */
|
||||
export const STEER_MEMORY = 6;
|
||||
|
||||
/** Session entry type used to persist state, so a compaction or reload cannot reset the count. */
|
||||
export const STATE_ENTRY = "supervise-state";
|
||||
|
||||
/** Rebuild state from session entries. The last one written wins. */
|
||||
export function restoreState(entries: Array<{ type: string; customType?: string; data?: unknown }>): SuperviseState {
|
||||
let state = EMPTY_STATE;
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data) {
|
||||
// Merge over the defaults so a record written before a field existed still loads.
|
||||
state = { ...EMPTY_STATE, ...(entry.data as Partial<SuperviseState>) };
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Child pi processes, so a settled worker with a subagent still running is not called finished.
|
||||
*
|
||||
* The in-session check only sees tool calls that never got a result. A subagent spawned as its own
|
||||
* process leaves no such trace, so the worker settles and the view looks quiet.
|
||||
*
|
||||
* This is a snapshot, not a wait. The original polls here for up to two minutes, and pi awaits the
|
||||
* settle handler (agent-session.js:330), so that holds the worker's own settle for the whole poll.
|
||||
* The loop already does the waiting: the supervisor sees the process listed, done is refused, and
|
||||
* it steers instead. That leaves the waiting in the transcript where you can read it.
|
||||
*
|
||||
* Ported from @monotykamary/pi-supervisor (MIT), src/subagent-detector.ts. Extension agnostic: it
|
||||
* does not matter who spawned them. Nothing is caught here, so a broken ps is loud.
|
||||
*/
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface PiProcess {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
}
|
||||
|
||||
async function piProcesses(): Promise<PiProcess[]> {
|
||||
if (process.platform !== "darwin" && process.platform !== "linux") return [];
|
||||
const { stdout } = await execAsync(`ps -eo ppid,pid,comm | grep -E "\\bpi\\b" || true`);
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.filter((parts) => parts.length >= 3 && parts[2] === "pi")
|
||||
.map((parts) => ({ ppid: Number(parts[0]), pid: Number(parts[1]) }));
|
||||
}
|
||||
|
||||
export async function childPiProcesses(): Promise<number[]> {
|
||||
return (await piProcesses()).filter((p) => p.ppid === process.pid).map((p) => p.pid);
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/** Typed boundary for pi-vcc 0.5.0's source-only API. Its own source uses older Pi message
|
||||
* unions and Intl.Segmenter types; do not typecheck that dependency as pi-goals source. */
|
||||
declare module "@sting8k/pi-vcc/src/core/summarize.ts" {
|
||||
export function compile(input: { messages: unknown[] }): string;
|
||||
}
|
||||
declare module "@sting8k/pi-vcc/src/core/normalize.ts" {
|
||||
export function normalize(messages: unknown[]): VccBlock[];
|
||||
interface VccBlock { type: string; [key: string]: unknown }
|
||||
}
|
||||
declare module "@sting8k/pi-vcc/src/extract/files.ts" {
|
||||
export function extractFiles(blocks: unknown[]): { modified: Set<string>; created: Set<string> };
|
||||
}
|
||||
declare module "@sting8k/pi-vcc/src/extract/commits.ts" {
|
||||
export function extractCommits(blocks: unknown[]): Array<{ hash?: string; message: string }>;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* The worker view: what the supervisor judges from.
|
||||
*
|
||||
* Built from ctx.sessionManager.getBranch(), which already follows the live leaf path, so a fork
|
||||
* or a rewind cannot leave dead entries in here. There is no disk read and no cross-branch merge.
|
||||
*
|
||||
* The body is pi-vcc's compiler, the same algorithmic compactor the worker can run, called here on
|
||||
* the live messages with the worker's last compaction summary as previousSummary. So the view is
|
||||
* "compaction summary, merged with everything since". We add what a compactor has no reason to
|
||||
* track: unanswered tool calls and whether anything changed since the last review.
|
||||
*/
|
||||
|
||||
import { normalize } from "@sting8k/pi-vcc/src/core/normalize.ts";
|
||||
import { compile } from "@sting8k/pi-vcc/src/core/summarize.ts";
|
||||
import { extractCommits } from "@sting8k/pi-vcc/src/extract/commits.ts";
|
||||
import { extractFiles } from "@sting8k/pi-vcc/src/extract/files.ts";
|
||||
|
||||
const SUPERVISOR_PREFIX = "[supervisor] ";
|
||||
|
||||
/** Entry shapes we read. Only the fields this file touches, taken from real session jsonl. */
|
||||
export interface Block {
|
||||
type: string;
|
||||
id?: string;
|
||||
text?: string;
|
||||
/** Set on `type: "thinking"` blocks. Empty when the provider redacted the reasoning. */
|
||||
thinking?: string;
|
||||
name?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
}
|
||||
export interface AgentMsg {
|
||||
role: "user" | "assistant" | "toolResult" | string;
|
||||
content?: string | Block[];
|
||||
toolName?: string;
|
||||
toolCallId?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
export interface Entry {
|
||||
type: string;
|
||||
message?: AgentMsg;
|
||||
/** ISO, written on every entry by the session manager (core/session-manager.d.ts:21). */
|
||||
timestamp?: string;
|
||||
/** Written by whichever compactor the worker runs. VCC's summary lands here too. */
|
||||
summary?: string;
|
||||
tokensBefore?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Milliseconds since the worker last put a message in its session.
|
||||
*
|
||||
* The clock a stuck worker shows on, and the only one that reads the same for both ways of being
|
||||
* stuck: sitting at the prompt, and inside one command that never returns. A supervisor directive
|
||||
* does not reset it. Time since the last look measures the supervisor instead, and understates a
|
||||
* worker that stopped hours before.
|
||||
*/
|
||||
export function sinceLastTurn(entries: Entry[], now = Date.now()): number {
|
||||
const last = [...entries].reverse().find((e) =>
|
||||
e.type === "message" && e.timestamp && !(e.message?.role === "user" && textOf(e.message).startsWith(SUPERVISOR_PREFIX))
|
||||
);
|
||||
return last ? now - Date.parse(last.timestamp!) : 0;
|
||||
}
|
||||
|
||||
/** A duration a supervisor can read at a glance: 2h27m, 45m, 30s. */
|
||||
export function age(ms: number): string {
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.round(s / 60);
|
||||
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
/** Extension channel payloads cap at 16 KiB, so the view must stay under it. */
|
||||
export const MAX_VIEW_BYTES = 15000;
|
||||
const GOAL_PREVIEW_CHARS = 160;
|
||||
|
||||
/** A long goal remains identifiable in every view without replaying its whole rubric. */
|
||||
export function goalPreview(goal: string): string {
|
||||
if (!goal.includes("\n")) return goal || "not set";
|
||||
const firstLine = goal.split("\n").find((line) => line.trim())?.trim() || "not set";
|
||||
return `${firstLine.slice(0, GOAL_PREVIEW_CHARS)} [...]`;
|
||||
}
|
||||
|
||||
function blocks(msg: AgentMsg): Block[] {
|
||||
return Array.isArray(msg.content) ? msg.content : [];
|
||||
}
|
||||
|
||||
function textOf(msg: AgentMsg): string {
|
||||
if (typeof msg.content === "string") return msg.content;
|
||||
return blocks(msg)
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => b.text ?? "")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool calls with no matching result on this branch. A settled worker with an unanswered
|
||||
* subagent call still has delegated work running, and "done" then means nothing.
|
||||
*/
|
||||
export function outstandingWork(entries: Entry[]): string[] {
|
||||
const called = new Map<string, string>();
|
||||
const answered = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const msg = entry.message;
|
||||
if (!msg) continue;
|
||||
for (const b of blocks(msg)) {
|
||||
if (b.type === "toolCall" && b.id) called.set(b.id, b.name ?? "?");
|
||||
}
|
||||
if (msg.role === "toolResult" && msg.toolCallId) answered.add(msg.toolCallId);
|
||||
}
|
||||
return [...called].filter(([id]) => !answered.has(id)).map(([, name]) => name);
|
||||
}
|
||||
|
||||
/** The summary written by whichever compactor the worker runs. Empty when it has not compacted. */
|
||||
export function compactionSummary(entries: Entry[]): string {
|
||||
let summary = "";
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "compaction" && entry.summary) summary = entry.summary;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the worker has changed: the files it wrote and the commits it made.
|
||||
*
|
||||
* Two reviews with the same key mean the last instruction produced neither. That is evidence for
|
||||
* the supervisor, not a rule: re-editing one file while a test still fails looks the same, and is
|
||||
* sometimes the right thing to be doing.
|
||||
*
|
||||
* Read from pi-vcc's extractor rather than from its rendered section, which caps the list at ten
|
||||
* paths and would freeze this key on any run long enough to matter.
|
||||
*/
|
||||
export function progressKey(entries: Entry[]): string {
|
||||
const blocks = normalize(messagesSince(entries) as any);
|
||||
const files = extractFiles(blocks);
|
||||
const commits = extractCommits(blocks).map((c) => c.hash ?? c.message);
|
||||
return [[...files.modified].sort(), [...files.created].sort(), commits].map((p) => p.join(",")).join("||");
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages after the worker's last compaction.
|
||||
*
|
||||
* getBranch keeps the entries a compaction replaced, so handing every message to compile alongside
|
||||
* the summary would send the supervisor both copies and spend the byte budget twice. Supervisor
|
||||
* directives already live in the supervisor transcript, so exclude their worker-session echo.
|
||||
*/
|
||||
function messagesSince(entries: Entry[]): AgentMsg[] {
|
||||
const lastCompaction = entries.map((e) => e.type).lastIndexOf("compaction");
|
||||
return entries
|
||||
.slice(lastCompaction + 1)
|
||||
.filter((e) => e.type === "message" && e.message)
|
||||
.map((e) => e.message!)
|
||||
.filter((message) => message.role !== "user" || !textOf(message).startsWith(SUPERVISOR_PREFIX));
|
||||
}
|
||||
|
||||
/** What the caller records after a view goes out, and hands back as `since` on the next one. */
|
||||
export function turnsSince(entries: Entry[]): number {
|
||||
return messagesSince(entries).length;
|
||||
}
|
||||
|
||||
/** Reasoning blocks kept, newest first, and the tail kept from each. A block ends on a decision. */
|
||||
const THINKING_BLOCKS = 2;
|
||||
const THINKING_CHARS = 400;
|
||||
|
||||
/**
|
||||
* Keep the last few reasoning blocks by rewriting them as text, and let pi-vcc drop the rest.
|
||||
*
|
||||
* normalize() keeps only text and toolCall blocks from an assistant message, so reasoning never
|
||||
* reaches the supervisor although you see it on screen. Rewriting in place leaves each thought
|
||||
* next to the tool call it produced, which is the order you read a session in. A separate section
|
||||
* at the top of the view would divorce the thought from what it did.
|
||||
*
|
||||
* Only the last two, because one worker session here held 161 reasoning blocks and all of them
|
||||
* would make the view a second transcript. Everything older needs no work: pi-vcc drops it.
|
||||
*/
|
||||
function keepRecentThinking(msgs: AgentMsg[]): AgentMsg[] {
|
||||
const keep = new Set<string>();
|
||||
outer: for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const content = msgs[i].content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (let j = content.length - 1; j >= 0; j--) {
|
||||
if (content[j].type !== "thinking" || !content[j].thinking) continue;
|
||||
keep.add(`${i}:${j}`);
|
||||
if (keep.size === THINKING_BLOCKS) break outer;
|
||||
}
|
||||
}
|
||||
if (!keep.size) return msgs;
|
||||
return msgs.map((msg, i) =>
|
||||
Array.isArray(msg.content)
|
||||
? {
|
||||
...msg,
|
||||
content: msg.content.map((b, j) =>
|
||||
keep.has(`${i}:${j}`) ? { type: "text", text: `(thinking) ${b.thinking!.slice(-THINKING_CHARS)}` } : b
|
||||
),
|
||||
}
|
||||
: msg
|
||||
);
|
||||
}
|
||||
|
||||
const VCC_SEPARATOR = "\n\n---\n\n";
|
||||
/** pi-vcc's section names, in the order formatSummary writes them (its format.ts). */
|
||||
const VCC_HEADERS = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"];
|
||||
|
||||
/**
|
||||
* pi-vcc's compiled summary, split into its header sections and its brief transcript.
|
||||
*
|
||||
* compile() writes `sections + "\n\n---\n\n" + brief`, and drops either part when it is empty, so
|
||||
* all four combinations are possible. Get this wrong and the header block lands in the transcript,
|
||||
* where the byte cut eats the newest turns instead of the oldest.
|
||||
*/
|
||||
function vccSections(fresh: AgentMsg[]): { headers: string; brief: string } {
|
||||
// No previousSummary: compile's merge reads the fresh brief with briefOf, which finds nothing
|
||||
// when the fresh messages produced no header sections, and the newest turns vanish. The
|
||||
// compaction summary goes into the view above this instead, which loses nothing.
|
||||
//
|
||||
// compile appends a note telling the reader to call vcc_recall, which the supervisor does not
|
||||
// have. Matched on the tool name because wrapLongLines rewraps the note before we see it.
|
||||
const compiled = compile({ messages: keepRecentThinking(fresh) as any })
|
||||
.replace(/\n*-*\n*Use `vcc_recall`[\s\S]*$/, "")
|
||||
.trim();
|
||||
if (!VCC_HEADERS.some((h) => compiled.startsWith(`[${h}]`))) return { headers: "", brief: compiled };
|
||||
const at = compiled.indexOf(VCC_SEPARATOR);
|
||||
if (at < 0) return { headers: compiled, brief: "" };
|
||||
return { headers: compiled.slice(0, at), brief: compiled.slice(at + VCC_SEPARATOR.length) };
|
||||
}
|
||||
|
||||
export interface ViewInput {
|
||||
goal: string;
|
||||
status: string;
|
||||
entries: Entry[];
|
||||
/**
|
||||
* Turns the supervisor has already been sent, from turnsSince() after the last view.
|
||||
*
|
||||
* The supervisor is a real session and keeps every view it has read, so re-sending the whole
|
||||
* transcript every time is a second copy of what it already has. This is a person glancing at a
|
||||
* screen: they read the new lines, not the scrollback. Past the compaction or a rewind this no
|
||||
* longer lines up, and the view says so and sends everything after the compaction.
|
||||
*/
|
||||
since?: number;
|
||||
/** Reviews in a row where progressKey did not change. 0 means something changed this time. */
|
||||
stale?: number;
|
||||
/** Child pi processes still running. A settled worker with one of these is still spending. */
|
||||
subagents?: number[];
|
||||
/**
|
||||
* The worker's model and how full its context is, from the intercom presence record.
|
||||
*
|
||||
* A supervisor steering a small fast model should give smaller steps than one steering a frontier
|
||||
* model, and a worker near the top of its context is about to compact and lose detail.
|
||||
*/
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Render the view, and cut it to MAX_VIEW_BYTES so the broker cannot reject it. */
|
||||
export function buildView({ goal, status, entries, since = 0, stale = 0, subagents = [], model = "" }: ViewInput): string {
|
||||
const messages = entries.filter((e) => e.type === "message" && e.message);
|
||||
const pending = outstandingWork(messages);
|
||||
const workerMessages = messagesSince(entries);
|
||||
const total = workerMessages.length;
|
||||
// A compaction or a rewind leaves the mark past the end. Restart from the compaction and say so,
|
||||
// otherwise the supervisor silently reads a slice of the wrong history.
|
||||
const restarted = since > total;
|
||||
const from = restarted ? 0 : since;
|
||||
const fresh = workerMessages.slice(from);
|
||||
const { headers, brief } = vccSections(fresh);
|
||||
const earlier = compactionSummary(entries);
|
||||
|
||||
const head = [
|
||||
// Short goals are the criterion on every review. A multi-line research rubric is reinserted
|
||||
// into the supervisor context at its own cadence, so this view carries only its locator.
|
||||
`<goal>`,
|
||||
goalPreview(goal),
|
||||
`</goal>`,
|
||||
``,
|
||||
`# Worker`,
|
||||
...(model ? [`model: ${model}`] : []),
|
||||
`status: ${status}`,
|
||||
`turns: ${workerMessages.length}`,
|
||||
`tool calls with no result: ${pending.length ? pending.join(", ") : "none"}`,
|
||||
`child pi processes still running: ${subagents.length ? subagents.join(", ") : "none"}`,
|
||||
...(stale > 0 ? [`no new file or commit for ${stale} reviews in a row`] : []),
|
||||
``,
|
||||
// Sent when this view starts at the compaction boundary, which is the first view and every
|
||||
// view after the worker compacts. In between the supervisor already has it.
|
||||
...(from === 0 && earlier
|
||||
? [
|
||||
restarted ? `# The worker compacted, so this view restarts. Everything before it:` : `# Earlier work, from the worker's own compaction summary`,
|
||||
earlier.slice(0, 6000),
|
||||
``,
|
||||
]
|
||||
: []),
|
||||
...(headers ? [`# Files, commits and context, from the new turns only`, headers, ``] : []),
|
||||
from > 0 ? `# New turns since your last look (${total - from} of ${total})` : `# Turns so far`,
|
||||
].join("\n");
|
||||
|
||||
// Oldest brief lines go first, because the newest turns are what the next instruction rests on.
|
||||
let lines = brief.split("\n");
|
||||
let view = `${head}\n${lines.join("\n")}\n`;
|
||||
while (Buffer.byteLength(view, "utf-8") > MAX_VIEW_BYTES && lines.length > 1) {
|
||||
lines = lines.slice(1);
|
||||
view = `${head}\n[earlier turns cut to fit the channel]\n${lines.join("\n")}\n`;
|
||||
}
|
||||
if (Buffer.byteLength(view, "utf-8") <= MAX_VIEW_BYTES) return view;
|
||||
// The head alone can overflow, on a long goal. The broker drops anything over
|
||||
// 16 KiB and never tells the extension, so the supervisor would go blind. Cut, and say so.
|
||||
return `${Buffer.from(view, "utf-8").subarray(0, MAX_VIEW_BYTES - 40).toString("utf-8")}\n[view cut here to fit the channel]\n`;
|
||||
}
|
||||
+31
-9
@@ -37,9 +37,12 @@ human would need to approve later. If any is uncertain, reduce uncertainty now:
|
||||
search the web when they can answer, then ask the human to confirm your interpretation, pin down the
|
||||
outcome or task, or approve an editorial or other preference choice. Do not present the review menu
|
||||
with a placeholder goal such as "work out the thing", "improve it", or "investigate".
|
||||
3. For independent high-impact questions, build a decision tree and ask the whole frontier in one
|
||||
round. Ask only questions worth the human's time, where the answer materially reduces uncertainty
|
||||
while discovering the right plan. Each question must be short and self-contained: state the relevant
|
||||
3. By default, before proposing a final plan, ask at least THREE distinct task-specific alignment
|
||||
questions in ONE chat round: test agreement about the expected result, scope and constraints, and
|
||||
success/failure criteria. Even if you think you understand, check how far apart your interpretations
|
||||
are. Wait for the human's answers and use them before declaring the plan final. Do not ask technical
|
||||
facts that read-only inspection can resolve, or use a generic ritual questionnaire. Additional
|
||||
questions should materially reduce uncertainty while discovering the right plan. Each question must be short and self-contained: state the relevant
|
||||
context, use the human's language and ASD-STE100
|
||||
Simple Technical English, and give a recommended answer. Record each answer in ## Interview. Do not
|
||||
make the plan final while material user decisions remain open.
|
||||
@@ -50,9 +53,12 @@ not replace, defer, or contradict it; ask the human if an inference would change
|
||||
5. When every goal has an object, observable result, settled scope, and required approval, draft the
|
||||
plan file and present it. It should be safe to work overnight and present the requested outcome.
|
||||
|
||||
How this mode ends: after each settled draft the human gets a menu (Ready / Refine / Edit / Cancel).
|
||||
Plan mode ends only when they pick Ready. Refine collects short revision notes. Edit opens the full
|
||||
plan. When a new requirement arrives, fold it in, say what changed, and present the plan again.
|
||||
How this mode ends: when alignment is complete and the plan is ready, call RequestPlanReview to
|
||||
show Ready / Discuss / Edit / Cancel. Only Ready ends planning. Discuss returns to normal chat:
|
||||
ask useful alignment questions, wait for answers, and continue discussing for as many turns as
|
||||
needed. Do not request review while awaiting answers. When discussion is finished, call
|
||||
RequestPlanReview again, even if the draft did not change. Edit opens the full plan directly.
|
||||
When a new requirement arrives, fold it in, say what changed, and request review when ready.
|
||||
Detail that doesn't change a goal or a discriminator belongs in the appendix, not in the goals.
|
||||
|
||||
Right-size it:
|
||||
@@ -140,7 +146,7 @@ Conventions:
|
||||
- Appendix: unlimited and unverified. Alternatives, links, dead ends, and the settled detail that
|
||||
is not part of the approved goals. Nothing here is approved and nothing here is checked.
|
||||
|
||||
When the goals are drafted, present them and say the plan is final. Do not begin execution.`;
|
||||
After the alignment answers are incorporated, present the final plan and call RequestPlanReview. Do not begin execution.`;
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 3. reminder — EXEC. Transient, never persisted, and only when the plan went stale for a couple of
|
||||
@@ -148,16 +154,32 @@ When the goals are drafted, present them and say the plan is final. Do not begin
|
||||
* model to ignore the task block" (tintinweb/pi-tasks CHANGELOG.md:149). Carries the folded plan
|
||||
* (above ## Log), because a nudge with no plan in it makes the model go read the file anyway.
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
export function planningState(planPath: string): string {
|
||||
/** A waiver belongs only to the current objective, never to an earlier plan's transcript. */
|
||||
export function waivesAlignment(objective: string): boolean {
|
||||
// Only standalone affirmative clauses, not negations or quoted feature names.
|
||||
const unquoted = objective.replace(/"[^"]*"|“[^”]*”|‘[^’]*’|`[^`]*`/g, " ").replace(/(^|\W)'[^'\n]*'(?=$|\W)/g, "$1 ");
|
||||
return unquoted.split(/[;,.!?\n]+/).some(clause => /^(?:please\s+)?(?:no[- ](?:questions|q['’]s)|skip(?:[- ](?:the|all))?[- ](?:questions|q['’]s)|(?:don['’]t|do not) ask(?: me)?(?: any)? questions)(?:\s+please)?(?:\s+(?:and|then)\b.+)?$/i.test(clause.trim()));
|
||||
}
|
||||
|
||||
export function alignmentPolicy(waived: boolean): string {
|
||||
return waived
|
||||
? "Current-plan alignment: the human explicitly waived questions in this objective. Skip the default three-question round for THIS plan only."
|
||||
: "Current-plan alignment: ask at least THREE task-specific questions in ONE chat round before the final plan; wait for answers and use them. Check the expected result, scope/constraints, and success/failure criteria. A waiver in any previous plan does NOT apply. Do not repeat questions already answered for this plan.";
|
||||
}
|
||||
|
||||
export const discussPlan = "Continue discussing this draft in normal chat. Ask useful, task-specific alignment questions to check where your understanding differs from the human's: expected result, scope/constraints, and success/failure criteria. Wait for answers; do not open an editor or request review yet. Keep the draft and incorporate answers. When discussion is finished and the plan is ready, call RequestPlanReview, even if the draft is unchanged.";
|
||||
|
||||
export function planningState(planPath: string, questionsWaived = false): string {
|
||||
return `\
|
||||
[PLANNING MODE]
|
||||
${alignmentPolicy(questionsWaived)}
|
||||
The plan at ${planPath} is the only file you may change. Use read-only repository tools or web search
|
||||
when either can resolve a fact. Ask the human to confirm unresolved interpretation, outcome, task,
|
||||
scope, or a choice that needs their approval. Batch independent high-impact questions in one short,
|
||||
self-contained round with relevant context and a recommendation. Do not draft a placeholder goal
|
||||
without a concrete object, observable result, settled scope, and required approval. Do not execute
|
||||
work, mark a goal [/] or [x], or sign off a goal. The plan is not approved until the human selects
|
||||
Ready.`;
|
||||
Ready. Call RequestPlanReview only when alignment is complete and the plan is ready, not while waiting for chat answers.`;
|
||||
}
|
||||
|
||||
export function reminder(foldedPlan: string, planRel: string): string {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export type ModelRole = "planning" | "worker" | "supervisor";
|
||||
export interface ModelChoice { provider: string; id: string }
|
||||
|
||||
/** One atomic file per role: a supervisor process cannot clobber the worker's choice. */
|
||||
export class RoleModels {
|
||||
private role: ModelRole | null = null;
|
||||
private automatic = 0;
|
||||
private available = true;
|
||||
|
||||
constructor(private pi: ExtensionAPI, private directory = join(getAgentDir(), "pi-goals")) {
|
||||
pi.on("model_select", (event, ctx) => {
|
||||
// setModel can emit "set" itself. Session restore is not a human preference either.
|
||||
if (this.automatic || event.source === "restore" || !this.role) return;
|
||||
try { this.save(this.role, event.model); this.available = true; }
|
||||
catch (error) { ctx.ui.notify(`Could not remember ${this.role} model: ${String(error)}`, "error"); }
|
||||
});
|
||||
}
|
||||
|
||||
get ready(): boolean { return this.available; }
|
||||
get activeRole(): ModelRole | null { return this.role; }
|
||||
get restoring(): boolean { return this.automatic > 0; }
|
||||
leave(): void { this.role = null; this.available = true; }
|
||||
|
||||
/** Explicit acknowledgement works even when Pi suppresses same-model model_select. */
|
||||
async useCurrent(ctx: ExtensionContext): Promise<boolean> {
|
||||
const role = this.role;
|
||||
if (!role || this.available) { ctx.ui.notify("No role model is paused.", "info"); return false; }
|
||||
this.automatic++;
|
||||
try {
|
||||
const model = ctx.model;
|
||||
if (!model || !await this.pi.setModel(model)) throw new Error("The current model is missing or unauthenticated");
|
||||
this.save(role, model);
|
||||
this.available = true;
|
||||
ctx.ui.notify(`Explicitly saved current model ${model.provider}/${model.id} for ${role}.`, "info");
|
||||
return true;
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`Could not recover ${role}: ${String(error)}. Saved choice unchanged.`, "error");
|
||||
return false;
|
||||
} finally { this.automatic--; }
|
||||
}
|
||||
|
||||
private path(role: ModelRole): string { return join(this.directory, `${role}-model.json`); }
|
||||
private read(role: ModelRole): ModelChoice | undefined {
|
||||
let raw: string;
|
||||
try { raw = readFileSync(this.path(role), "utf8"); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
|
||||
const value = JSON.parse(raw);
|
||||
if (!value || typeof value.provider !== "string" || !value.provider || typeof value.id !== "string" || !value.id) throw new Error(`Invalid model preference: ${this.path(role)}`);
|
||||
return { provider: value.provider, id: value.id };
|
||||
}
|
||||
private save(role: ModelRole, model: ModelChoice): void {
|
||||
mkdirSync(this.directory, { recursive: true });
|
||||
const temp = `${this.path(role)}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
writeFileSync(temp, `${JSON.stringify({ provider: model.provider, id: model.id })}\n`, { mode: 0o600 });
|
||||
renameSync(temp, this.path(role));
|
||||
} finally { rmSync(temp, { force: true }); }
|
||||
}
|
||||
|
||||
/** Failure keeps the saved choice and pauses the role; only an explicit selection replaces it. */
|
||||
async enter(role: ModelRole, ctx: ExtensionContext): Promise<boolean> {
|
||||
this.role = role;
|
||||
this.automatic++;
|
||||
try {
|
||||
const choice = this.read(role);
|
||||
if (!choice) {
|
||||
if (!ctx.model) throw new Error("No current model. Select one with /model first.");
|
||||
this.save(role, ctx.model);
|
||||
if (!await this.pi.setModel(ctx.model)) throw new Error(`Current model ${ctx.model.provider}/${ctx.model.id} has no authentication.`);
|
||||
} else {
|
||||
const model = ctx.modelRegistry.find(choice.provider, choice.id);
|
||||
if (!model) throw new Error(`Remembered model ${choice.provider}/${choice.id} is unavailable.`);
|
||||
if (!await this.pi.setModel(model)) throw new Error(`Remembered model ${choice.provider}/${choice.id} has no authentication.`);
|
||||
}
|
||||
this.available = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.available = false;
|
||||
ctx.ui.notify(`${role} model paused: ${String(error)} Saved choice unchanged; configure that model and retry, select a different model with /model, or explicitly use the current model for this paused role with /goals model current. Then retry Ready if work has not started.`, "error");
|
||||
return false;
|
||||
} finally { this.automatic--; }
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -29,7 +29,7 @@ export function supervisorRequest<T>(pi: ExtensionAPI, method: string, params: R
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = { version: 1, method, ...params, signal, handled: false, resolve, reject };
|
||||
pi.events.emit(PLAN_API, request);
|
||||
if (!request.handled) reject(new Error("Load the plan-aware pi-intercom-supervisor and pi-intercom packages in both sessions, then reload Pi."));
|
||||
if (!request.handled) reject(new Error("The internal supervisor is not registered. Load the pi-goals package directory (not only src/index.ts), then reload Pi."));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,9 +76,7 @@ export async function startSupervisor(
|
||||
const parent = ctx.sessionManager.getSessionFile();
|
||||
const leaf = ctx.sessionManager.getLeafId();
|
||||
if (!parent || !leaf) throw new Error("The planning session must be persisted before creating its supervisor fork.");
|
||||
const supervisorSource = pi.getCommands().find(command => command.name === "supervise")?.sourceInfo?.path;
|
||||
const intercomSource = pi.getAllTools().find(tool => tool.name === "intercom")?.sourceInfo?.path;
|
||||
if (!supervisorSource || !intercomSource) throw new Error("Cannot resolve the loaded supervisor and Intercom extensions; load both before Ready.");
|
||||
const packageRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
let binding = existing ?? {
|
||||
id: randomUUID(), planPath, workerSession: parent, workerPane: process.env.HERDR_PANE_ID,
|
||||
everyTurns: 50, intervalMs: 60 * 60_000, compactTokens: 100_000,
|
||||
@@ -115,7 +113,7 @@ export async function startSupervisor(
|
||||
void waiting.catch(() => {});
|
||||
try {
|
||||
await herdr(pi, ["agent", "start", `supervisor-${binding.id.slice(0, 8)}`, "--kind", "pi", "--pane", pane, "--", "--session", binding.supervisorSession!,
|
||||
"-e", supervisorSource, "-e", intercomSource, "-e", fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./index.ts" : "./index.js", import.meta.url))], signal);
|
||||
"-e", packageRoot], signal);
|
||||
return await waiting;
|
||||
} catch (error) {
|
||||
throw new Error(`Supervisor startup incomplete: ${String(error)}. Inspect the recorded pane, resolve startup, reload it, then retry Ready.`);
|
||||
|
||||
Reference in New Issue
Block a user