From d56fc55242c1e2e941f4ecf7a3448652c4b2d256 Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:56:06 +0800 Subject: [PATCH] Replace nested workers with visible supervisor session Co-Authored-By: Pi <288921227+claudypoo@users.noreply.github.com> --- README.md | 145 ++------ agents/pi-goals-worker-v1.md | 22 -- package.json | 11 +- src/herdr.ts | 77 ++++ src/index.ts | 387 ++++---------------- src/prompts.ts | 34 +- src/supervisor-runtime.ts | 179 --------- src/supervisor-session.ts | 154 ++++++++ src/worker.ts | 186 ---------- test/goals-flow.test.ts | 629 +++++--------------------------- test/herdr.test.ts | 22 ++ test/package-agent.test.ts | 13 +- test/prompts.test.ts | 9 +- test/supervisor-runtime.test.ts | 153 -------- test/supervisor-session.test.ts | 110 ++++++ test/worker.test.ts | 119 ------ 16 files changed, 603 insertions(+), 1647 deletions(-) delete mode 100644 agents/pi-goals-worker-v1.md create mode 100644 src/herdr.ts delete mode 100644 src/supervisor-runtime.ts create mode 100644 src/supervisor-session.ts delete mode 100644 src/worker.ts create mode 100644 test/herdr.test.ts delete mode 100644 test/supervisor-runtime.test.ts create mode 100644 test/supervisor-session.test.ts delete mode 100644 test/worker.test.ts diff --git a/README.md b/README.md index bc5eaa1..950b2f8 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,70 @@ # pi-goals -Make a short list of goals in one Markdown plan file. The main Pi agent is a thin coordinator for a retained supervisor, which runs one foreground implementation worker at a time through pi-subagents. +Plan in one Pi session, then do the work there while a stronger visible Pi session supervises it. -The plan file looks like this: +## How it works -```md -## +1. `/goals ` creates `.pi/plan/-vN.md` and enters read-only plan mode. +2. Pi asks only material questions, writes the plan, and shows **Ready / Refine / Edit / Cancel**. +3. **Ready** opens a second Herdr pane. The new Pi session explicitly forks the planning session and compacts that fork. +4. The original session becomes the implementation worker. It keeps the full conversation and normal tools. +5. The fork becomes a read-only supervisor. `pi-supervise` gives it compact worker views and carries its instructions to the worker through `pi-intercom`. +6. The supervisor compacts again when its context reaches 100k tokens. +7. The supervisor records a private approval only after it sees a stopped worker, no active work, a clean commit, evidence, and saved verification output. `CompleteGoal` checks that approval against the exact plan block and Git tree before it ticks `[x]`. - - -### User-visible result - - - -### User voice - -- │ "" - -### Goals - -1. [ ] goal: -- subtle failure mode: -- discriminator: -- tasks: - 1. [ ] -- evidence: (empty until sign-off) - -### Future work / out of scope - -### Log - -### Interview - -### Learnings - -### Papercuts - problems, gotchas, suggestions -``` - -![the widget: live goals from the session's plan file, with the active goal's open subtasks](media/screenshot.png) - -## Related work - -Like [pi-milestones](https://github.com/Neuron-Mr-White/UniPi/tree/main/packages/milestone) and -[burneikis/pi-plan](https://github.com/burneikis/pi-plan), it guides rather than guards. The -plan resync after compaction follows [tmonk/pi-goal-x](https://github.com/tmonk/pi-goal-x). +The two Pi sessions are visible. You can switch to the supervisor pane and talk to it directly. ## Install -Requires `pi-subagents` 0.65.1 or newer. Install `pi-processes` so the supervisor can check managed processes. +This branch requires Herdr 0.7.5 or newer and these Pi packages: ```bash -pi install npm:pi-subagents -pi install npm:@aliou/pi-processes pi install npm:@wassname2/pi-goals +pi install npm:@wassname2/pi-supervise +pi install npm:pi-intercom ``` -Or for development: +For a local checkout: ```bash -git clone https://github.com/wassname/pi-goals && cd pi-goals && npm install -pi -e npm:pi-subagents -e . +pi -e . ``` -## Use +Run Pi from the Git repository that the plan will change. **Ready** fails if the current directory is not inside a Git repository; this prevents approval from checking the wrong repository. -``` -/goals CSV export for the report view +## Commands + +```text +/goals create a new plan +/goals model select the visible supervisor model +/goals model use Pi's current default model +/goals clear close the supervisor pane and disconnect the plan ``` -`/goals` enters plan mode and starts a conversation; the objective is an optional seed. From there: +`/goals clear` keeps the plan file. Starting another plan also keeps older versions. -1. Plan. The agent explores read-only and drafts the plan. -2. Review. After Pi settles, the full plan is printed in the transcript. Check that User-visible - result names the final artifact or behavior you expect. Ready forks the retained supervisor and - preserves the main context. Ready (compact) forks the supervisor, compacts its planning history - before its first turn, then compacts the main session. The implementation worker starts later. - Refine collects short notes. Edit opens the full plan in Pi's editor. -3. Work. The topology is: +## Plan format - ```text - main coordinator - └── retained supervisor - └── foreground implementation worker - ``` +A goal is a checkbox line whose text starts with `goal:`: - The retained `goal-supervisor` rereads the full current plan on each direction or review and runs - its packaged `pi-goals-worker-v1` in the foreground. The worker must finish before the supervisor - can inspect the repository and evidence or write a private approval checkpoint in - `.pi/pi-goals/approvals/`. A correction starts a new foreground worker. Pi-goals stores no nested - worker ID or status. The versioned name avoids ordinary name collisions. A user or project agent - with the same name still overrides the package agent. The worker is the implementation writer. - Main and supervisor block direct - `edit`, `write`, and write-like shell commands, but can inspect and run standard verification - commands. This is not a filesystem sandbox: allowed scripts and custom tools can still mutate. - `CompleteGoal` is mechanical. It checks that worker/supervisor/process work is idle and that the - latest review ID, goal block, clean worktree, and committed HEAD/tree still match. The review ID - prevents stale approval; it is not a security boundary against a worker that deliberately writes Pi state. - `CheckGoalWork`, FleetView, and `/subagents-fleet` inspect the retained tree and transcripts. Every - human reply and Refine note in plan mode is saved verbatim under `## Interview`. Pi and pi-subagents - own normal compaction and retained-run recovery. +```md +1. [ ] goal: Produce the report + - subtle failure mode: the report exists but uses stale data + - discriminator: the report cites the current input and the saved check confirms it + - verify: `just verify` + - evidence: (empty until sign-off) +``` -Other commands: `/goals clear` stops the retained tree and disconnects the active plan, preserving -its file. `/goals auto [minutes|off]` changes the check interval; Ready enables 60 minutes. -`/goals model ` sets the supervisor model; `/goals worker-model ` separately -sets the implementation-worker model. Checks continue until all goals close, `auto off`, or clear. +The worker adds durable evidence and commits it. The supervisor calls `ApproveGoal`; the worker then calls `CompleteGoal` with the exact goal text. -## Prompts - -Planning and coordinator sign-off prompts live in [`src/prompts.ts`](src/prompts.ts). Supervisor registration and RPC calls live in [`src/worker.ts`](src/worker.ts). The packaged worker contract lives in [`agents/pi-goals-worker-v1.md`](agents/pi-goals-worker-v1.md). The supervisor-only launch and approval gates live in [`src/supervisor-runtime.ts`](src/supervisor-runtime.ts). - -## Manual check - -1. Reload pi-goals with pi-subagents, create a small plan, and choose **Ready**. Open FleetView or run - `subagent({ action: "status", view: "fleet" })`. It should show `goal-supervisor` and its foreground - `pi-goals-worker-v1`, not sibling runs from the main session. -2. Ask the main session to edit a project file. Its direct `edit`, `write`, or shell redirection call - should be blocked. Call `CompleteGoal` before a supervisor review. It should fail because no matching - private approval exists. -3. Let the worker implement, commit, and save verify output. Ask the supervisor to inspect the plan, - repository, evidence, and output. Its nested worker instruction should appear in the nested - transcript. After it calls `ApproveGoal`, inspect the JSON under `.pi/pi-goals/approvals/`. -4. Call `CompleteGoal` with the exact goal text. It should tick only while the checkpoint's goal-block - hash and committed clean repository still match. Change the plan block or worktree and retry; it - should fail closed until a new supervisor review. - -## Develop +## Development ```bash -pi -e . # after installing pi-subagents above -npm test # all unit, flow, and Pi RPC tests -npm run test:rpc # Pi RPC review flow with a local offline model +npm test npm run typecheck npm run lint ``` -## License +`test/rpc-review.test.ts` runs the planning review flow through Pi's real RPC protocol with a local deterministic model. The Herdr launcher and visible supervisor bootstrap have focused tests; use a real Herdr session for the final two-pane check. -MIT +-- PI[gpt-5.6-sol] diff --git a/agents/pi-goals-worker-v1.md b/agents/pi-goals-worker-v1.md deleted file mode 100644 index 42ecb60..0000000 --- a/agents/pi-goals-worker-v1.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: pi-goals-worker-v1 -description: Foreground implementation worker for the retained pi-goals supervisor -thinking: high -systemPromptMode: replace -inheritProjectContext: true -inheritGlobalContext: true -inheritSkills: true -tools: read, grep, find, ls, bash, edit, write -excludeTools: contact_supervisor, subagent -defaultContext: fork -async: false -defaultProgress: true ---- - -You are the implementation worker for one retained goal supervisor. - -Work autonomously from the approved plan. Keep the plan current, run the real checks, commit the implementation, and leave specific evidence in its Log. The human's latest message outranks the plan; update affected goals instead of defending an obsolete decision. The retained goal supervisor owns direction and approval. - -Do not ask for routine decisions or start subagents. Finish the task or return one concrete blocker. Do not claim a goal is complete; report the evidence and let the supervisor decide. - --- Pi/Codex diff --git a/package.json b/package.json index 177688a..937b14a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@wassname2/pi-goals", "version": "0.2.2", - "description": "One plan file per session with a main coordinator, retained supervisor, and nested pi-subagents worker.", + "description": "Plan in one Pi session, then work under a visible forked supervisor.", "author": "wassname", "license": "MIT", "type": "module", @@ -19,7 +19,8 @@ "uat", "evidence", "supervisor", - "subagent" + "pi-intercom", + "herdr" ], "peerDependencies": { "@earendil-works/pi-coding-agent": "*", @@ -27,7 +28,6 @@ }, "files": [ "src", - "agents", "README.md" ], "publishConfig": { @@ -55,11 +55,6 @@ "extensions": [ "./src/index.ts" ], - "subagents": { - "agents": [ - "./agents" - ] - }, "image": "https://cdn.jsdelivr.net/gh/wassname/pi-goals@main/media/screenshot.png" } } diff --git a/src/herdr.ts b/src/herdr.ts new file mode 100644 index 0000000..2f75e09 --- /dev/null +++ b/src/herdr.ts @@ -0,0 +1,77 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +interface LaunchSupervisorInput { + cwd: string; + sourceSessionFile: string; + workerSessionId: string; + planPath: string; + approvalId: string; + extensionPath: string; + model: string | null; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function findPaneId(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + for (const key of ["pane_id", "paneId"]) { + if (typeof record[key] === "string") return record[key]; + } + for (const child of Object.values(record)) { + const found = findPaneId(child); + if (found) return found; + } + return null; +} + +async function herdr(args: string[]): Promise { + const bin = process.env.HERDR_BIN_PATH ?? "herdr"; + const { stdout } = await execFileAsync(bin, args, { encoding: "utf8", timeout: 15_000 }); + return stdout.trim() ? JSON.parse(stdout) : {}; +} + +export function supervisorCommand(input: LaunchSupervisorInput): string { + const env = [ + "PI_GOALS_ROLE=supervisor", + `PI_GOALS_WORKER_ID=${input.workerSessionId}`, + `PI_GOALS_PLAN_PATH=${input.planPath}`, + `PI_GOALS_APPROVAL_ID=${input.approvalId}`, + `PI_GOALS_OWNER_SESSION_ID=${input.workerSessionId}`, + ]; + const args = [ + "pi", + "--no-extensions", + "-e", input.extensionPath, + "-e", "npm:pi-intercom", + "-e", "npm:@wassname2/pi-supervise", + "--fork", input.sourceSessionFile, + "--name", `goals-supervisor-${input.workerSessionId.slice(0, 8)}`, + ]; + if (input.model) args.push("--model", input.model); + return ["env", ...env, ...args].map(shellQuote).join(" "); +} + +export async function openSupervisorPane(input: LaunchSupervisorInput): Promise { + if (process.env.HERDR_ENV !== "1") throw new Error("Ready needs a Herdr session so pi-goals can open the supervisor session."); + await herdr(["--version"]); + const split = await herdr(["pane", "split", "--current", "--direction", "right", "--cwd", input.cwd, "--no-focus"]); + const paneId = findPaneId(split); + if (!paneId) throw new Error("Herdr did not return the new supervisor pane ID."); + try { + await herdr(["pane", "run", paneId, supervisorCommand(input)]); + return paneId; + } catch (error) { + await herdr(["pane", "close", paneId]); + throw error; + } +} + +export async function closeSupervisorPane(paneId: string): Promise { + await herdr(["pane", "close", paneId]); +} diff --git a/src/index.ts b/src/index.ts index 7229c5f..77ba679 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,36 +1,29 @@ /** - * PI: pi-goals owns one versioned plan per session. The main agent is a thin coordinator for a - * retained pi-subagents supervisor, which owns one foreground implementation worker at a time and approval. + * PI: pi-goals owns one versioned plan per session. After Ready, the main session implements the + * plan while a compacted, visible fork supervises it through pi-supervise. * * Each /goals call makes `.pi/plan/-vN.md`. The selected version survives resume and * compaction. Old plans stay on disk but inactive. A session with no selected plan has no widget, - * supervision, worker, or CompleteGoal sign-off. + * supervision, or CompleteGoal sign-off. * * TypeScript reads only goal checkbox lines for the widget. Models read the plan as prose. The * worker edits the project and records evidence. The supervisor inspects it and writes a private - * approval checkpoint. pi-subagents owns the supervisor and worker sessions, forks, resume, events, - * and Fleet controls. + * approval checkpoint. * * -- Pi/Codex */ +import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { approvalMatches, approvalPath, goalBlock, hashGoalBlock, readApproval, repositoryState } from "./approval.js"; +import { closeSupervisorPane, openSupervisorPane } from "./herdr.js"; import { completeGoalDescription, completeGoalParamDescription, planDrafting, planningState, resync } from "./prompts.js"; -import { - processWorkState, - registerGoalSupervisor, - resumeGoalSupervisor, - startGoalSupervisor, - steerGoalSupervisor, - stopGoalSupervisor, - subagentWorkState, - terminalSteerError, -} from "./worker.js"; +import { isVisibleSupervisor, registerVisibleSupervisor } from "./supervisor-session.js"; const STATE = "pi-goals-state"; const STATUS_KEY = "pi-goals"; @@ -41,7 +34,6 @@ const PLAN_DIR = ".pi/plan"; const PLAN_SHAPE = `${PLAN_DIR}/-vN.md`; // Plan mode blocks edit/write except for its plan file. bash remains available for read-only inspection. -- Pi/Codex const PLAN_MODE_BLOCKED_TOOLS = ["edit", "write"]; -const AUTO_DEFAULT_INTERVAL_MS = 60 * 60 * 1_000; // A checkbox line beginning "goal:", used by the widget and supervisor scheduling. // Everything else reads the file as prose. @@ -93,43 +85,32 @@ export function nextPlanVersion(planNames: string[], sessionId: string): number type Phase = "planning" | "working" | null; -/** Goal workers run in child Pi sessions, so they must not receive the main coordinator's tool gate. */ -export function isSupervisorProcess(isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1"): boolean { - return !isSubagentChild; +export function isMainSession(isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1"): boolean { + return !isSubagentChild && !isVisibleSupervisor(); } interface PlanState { phase: Phase; supervisorModel: string | null; - workerModel: string | null; - workerRunId: string | null; - workerPending: boolean; + supervisorPaneId: string | null; approvalId: string | null; planVersion: number | null; - autoIntervalMs: number | null; } export default function piGoalsExtension(pi: ExtensionAPI): void { - if (!isSupervisorProcess()) return; + if (isVisibleSupervisor()) { + registerVisibleSupervisor(pi); + return; + } + if (!isMainSession()) return; let state: PlanState = { phase: null, supervisorModel: null, - workerModel: null, - workerRunId: null, - workerPending: false, + supervisorPaneId: null, approvalId: null, planVersion: null, - autoIntervalMs: null, }; let planningContextPending = false; - let autoTimer: ReturnType | null = null; - let supervisorWakePending = false; - let workerRegistration: { dispose(): void } | null = null; - let workerRegistrationError: string | null = null; - let unsubscribeWorkerCompletion: (() => void) | null = null; - let workerLaunchPending = false; - const workerCompletionsDuringLaunch = new Set(); - // Set on session start and after compaction; the next supervisor call receives the whole plan. let resyncReason: string | null = "New session."; const planRel = (ctx: ExtensionContext) => (state.planVersion === null ? PLAN_SHAPE : `${PLAN_DIR}/${ctx.sessionManager.getSessionId()}-v${state.planVersion}.md`); @@ -149,23 +130,6 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { pi.appendEntry(STATE, state); } - function setupWorker(ctx: ExtensionContext): void { - workerRegistration?.dispose(); - workerRegistration = null; - workerRegistrationError = null; - try { - workerRegistration = registerGoalSupervisor(pi.events, state.supervisorModel); - } catch (error) { - workerRegistrationError = error instanceof Error ? error.message : String(error); - if (state.phase === "working") ctx.ui.notify(`Goal supervisor unavailable: ${workerRegistrationError}`, "warning"); - } - } - - function rememberWorkerRun(runId: string): void { - state = { ...state, workerRunId: runId, workerPending: !workerCompletionsDuringLaunch.delete(runId) }; - persist(); - } - function beginReview(ctx: ExtensionContext): void { for (const goal of scanGoals(readPlan(ctx))) { rmSync(approvalPath(ctx.cwd, ctx.sessionManager.getSessionId(), goal.subject), { force: true }); @@ -174,12 +138,35 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { persist(); } - async function stopWorker(): Promise { - clearAutoTimer(); - if (!state.workerRunId) return !state.workerPending; + function repositoryRoot(cwd: string): string { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim(); + } + + async function startSupervisor(ctx: ExtensionContext): Promise { + repositoryRoot(ctx.cwd); + const sourceSessionFile = ctx.sessionManager.getSessionFile(); + if (!sourceSessionFile) throw new Error("The current session is not persisted, so it cannot be forked."); + beginReview(ctx); + state = { + ...state, + supervisorPaneId: await openSupervisorPane({ + cwd: ctx.cwd, + sourceSessionFile, + workerSessionId: ctx.sessionManager.getSessionId(), + planPath: planPath(ctx), + approvalId: state.approvalId!, + extensionPath: fileURLToPath(import.meta.url), + model: state.supervisorModel, + }), + }; + persist(); + } + + async function stopSupervisor(): Promise { + if (!state.supervisorPaneId) return true; try { - await stopGoalSupervisor(pi.events, state.workerRunId); - state = { ...state, workerPending: false }; + await closeSupervisorPane(state.supervisorPaneId); + state = { ...state, supervisorPaneId: null }; persist(); return true; } catch { @@ -187,93 +174,6 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } } - async function startOrResumeWorker(ctx: ExtensionContext, task: string, compactPlanning: boolean, signal?: AbortSignal): Promise { - if (workerLaunchPending) throw new Error("A goal-worker launch is already in progress."); - if (!workerRegistration) setupWorker(ctx); - if (!workerRegistration) throw new Error(`Goal worker unavailable: ${workerRegistrationError ?? "pi-subagents is not ready"}.`); - workerLaunchPending = true; - workerCompletionsDuringLaunch.clear(); - try { - const runId = state.workerRunId - ? await resumeGoalSupervisor(pi.events, state.workerRunId, task, signal) - : await startGoalSupervisor(pi.events, ctx.cwd, task, compactPlanning, state.workerModel, signal); - rememberWorkerRun(runId); - return runId; - } finally { - workerLaunchPending = false; - workerCompletionsDuringLaunch.clear(); - } - } - - function watchWorkerCompletion(): void { - unsubscribeWorkerCompletion?.(); - unsubscribeWorkerCompletion = pi.events.on("subagent:async-complete", (raw) => { - if (!raw || typeof raw !== "object") return; - const runId = (raw as { runId?: string }).runId; - if (!runId) return; - if (runId !== state.workerRunId) { - if (workerLaunchPending) workerCompletionsDuringLaunch.add(runId); - return; - } - state = { ...state, workerPending: false }; - persist(); - }); - } - - function clearAutoTimer(): void { - if (autoTimer !== null) clearTimeout(autoTimer); - autoTimer = null; - } - - function activeGoals(ctx: ExtensionContext): boolean { - return scanGoals(readPlan(ctx)).some((goal) => goal.status === "active" || goal.status === "open"); - } - - function supervisorTask(ctx: ExtensionContext, instruction: string): string { - const plan = readPlan(ctx); - const checkpoints = scanGoals(plan) - .filter((goal) => goal.status === "active" || goal.status === "open") - .map((goal) => `- ${JSON.stringify(goal.subject)}: ${approvalPath(ctx.cwd, ctx.sessionManager.getSessionId(), goal.subject)}`) - .join("\n"); - return `${instruction}\n\nYou are the retained goal-supervisor. Here is the complete current plan; inspect its exact goal blocks and cited evidence before directing or approving work.\nPlan path: ${planPath(ctx)}\nApproval ID: ${state.approvalId}\nNested worker model: ${state.workerModel ?? "pi-subagents default"}\nPass the exact approval ID to ApproveGoal. Keep checkpoint paths and the approval ID from the nested worker.\nPrivate approval checkpoints, one per current goal:\n${checkpoints || "(no open goals)"}\n\n${plan}`; - } - - async function directSupervisor(ctx: ExtensionContext, instruction: string, signal?: AbortSignal, compactPlanning = false): Promise { - beginReview(ctx); - const task = supervisorTask(ctx, instruction); - if (state.workerPending && state.workerRunId) { - try { - await steerGoalSupervisor(pi.events, state.workerRunId, task, signal); - return state.workerRunId; - } catch (error) { - if (!terminalSteerError(error)) throw error; - state = { ...state, workerPending: false }; - persist(); - } - } - return startOrResumeWorker(ctx, task, compactPlanning, signal); - } - - function wakeSupervisor(ctx: ExtensionContext, reason: string): void { - if (supervisorWakePending || state.phase !== "working" || !activeGoals(ctx)) return; - supervisorWakePending = true; - void directSupervisor(ctx, `${reason}\nReview the current goal and either continue, redirect, or approve it through ApproveGoal.`) - .catch((error) => ctx.ui.notify(`Goal supervisor check failed: ${error instanceof Error ? error.message : String(error)}`, "warning")) - .finally(() => { - supervisorWakePending = false; - }); - } - - function scheduleSupervisorCheck(ctx: ExtensionContext): void { - if (autoTimer !== null || state.phase !== "working" || state.autoIntervalMs === null || !activeGoals(ctx)) return; - autoTimer = setTimeout(() => { - autoTimer = null; - scheduleSupervisorCheck(ctx); - wakeSupervisor(ctx, `The ${state.autoIntervalMs! / 60_000}-minute supervisor check is due.`); - }, state.autoIntervalMs); - autoTimer.unref(); - } - function updateWidget(ctx: ExtensionContext): void { if (state.phase === "planning") { ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "planning")); @@ -288,9 +188,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } const done = goals.filter((g) => g.status === "done").length; const liveGoals = goals.filter((g) => g.status === "active" || g.status === "open"); - const stateLabel = liveGoals.length > 0 ? " · supervising…" : " · complete"; - const auto = liveGoals.length > 0 && state.autoIntervalMs !== null ? ` · supervise ${state.autoIntervalMs / 60_000}m` : ""; - ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals${stateLabel}${auto}`)); + const stateLabel = liveGoals.length > 0 ? " · supervised" : " · complete"; + ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals${stateLabel}`)); const mark: Record = { done: "✔", active: "▸", open: "◻", cancelled: "✗" }; // Only live goals get lines so finished work never pushes current work off screen. The active // goal also shows its open subtasks: this file is the task list, so the widget is the task list. @@ -307,7 +206,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { // --- /goals: enter plan mode or configure supervision -- Pi/Codex ----------------------------- pi.registerCommand("goals", { - description: `Plan goals, then supervise a retained worker. /goals | clear | auto [minutes|off] | model | worker-model `, + description: `Plan goals, then open a visible supervisor session. /goals | clear | model `, handler: async (args, ctx) => { const arg = args.trim(); if (arg === "clear") { @@ -316,62 +215,36 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { return; } const currentPlan = planRel(ctx); - if (!(await stopWorker())) { - ctx.ui.notify("Could not stop the retained supervisor; the plan remains connected.", "warning"); + if (!(await stopSupervisor())) { + ctx.ui.notify("Could not close the visible supervisor; the plan remains connected.", "warning"); return; } - state = { ...state, phase: null, workerRunId: null, workerPending: false, approvalId: null, planVersion: null, autoIntervalMs: null }; + state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null }; persist(); updateWidget(ctx); ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info"); return; } - if (arg === "auto" || arg.startsWith("auto ")) { - const value = arg.slice("auto".length).trim(); - if (value === "off") { - clearAutoTimer(); - state = { ...state, autoIntervalMs: null }; - persist(); - updateWidget(ctx); - ctx.ui.notify("Hourly goal supervision disabled.", "info"); + if (arg === "model" || arg.startsWith("model ")) { + if (state.phase === "working") { + ctx.ui.notify("Run /goals clear before changing the active supervisor model.", "warning"); return; } - if (state.phase !== "working") { - ctx.ui.notify("Approve a plan with Ready before enabling supervision.", "warning"); + if (!(await stopSupervisor())) { + ctx.ui.notify("Could not close the visible supervisor; its model was not changed.", "warning"); return; } - const minutes = value ? Number(value) : AUTO_DEFAULT_INTERVAL_MS / 60_000; - if (!Number.isInteger(minutes) || minutes < 1) { - ctx.ui.notify("Use /goals auto [whole minutes], or /goals auto off.", "warning"); - return; - } - clearAutoTimer(); - state = { ...state, autoIntervalMs: minutes * 60_000 }; + const ref = arg.slice("model".length).trim(); + state = { ...state, supervisorModel: ref || null, supervisorPaneId: null, approvalId: null }; persist(); - updateWidget(ctx); - scheduleSupervisorCheck(ctx); - ctx.ui.notify(`Goal supervision will check every ${minutes}m.`, "info"); + ctx.ui.notify(`Goal-supervisor model ${ref ? `set to ${ref}` : "reset to the current Pi default"}.`, "info"); return; } - if (arg === "model" || arg.startsWith("model ") || arg === "worker-model" || arg.startsWith("worker-model ")) { - if (!(await stopWorker())) { - ctx.ui.notify("Could not stop the retained supervisor; models were not changed.", "warning"); - return; - } - const worker = arg === "worker-model" || arg.startsWith("worker-model "); - const command = worker ? "worker-model" : "model"; - const ref = arg.slice(command.length).trim(); - state = { ...state, [worker ? "workerModel" : "supervisorModel"]: ref || null, workerRunId: null, workerPending: false, approvalId: null }; - persist(); - setupWorker(ctx); - ctx.ui.notify(`${worker ? "Implementation-worker" : "Goal-supervisor"} model ${ref ? `set to ${ref}` : "reset to pi-subagents default"}.`, "info"); + if (!(await stopSupervisor())) { + ctx.ui.notify("Could not close the visible supervisor; no new plan was started.", "warning"); return; } - if (!(await stopWorker())) { - ctx.ui.notify("Could not stop the retained supervisor; no new plan was started.", "warning"); - return; - } - state = { ...state, phase: "planning", workerRunId: null, workerPending: false, approvalId: null, planVersion: nextVersion(ctx), autoIntervalMs: null }; + state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx) }; planningContextPending = true; resyncReason = null; writePlan(ctx, ""); @@ -399,10 +272,9 @@ 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) => { - supervisorWakePending = false; if (state.phase === "working") { return { - systemPrompt: `${ctx.getSystemPrompt()}\n\nYou are the thin human-facing coordinator for ${planRel(ctx)}. The retained goal-supervisor owns nested-worker control and acceptance. Keep the human intent stable, inspect progress with read-only tools, and direct the supervisor through GuideGoalWorker. Built-in edit/write and write-like shell commands are blocked. CompleteGoal is a mechanical sign-off only: it fails closed unless the supervisor's private approval checkpoint still matches the exact goal, plan block, committed HEAD/tree, and clean worktree. This is not a filesystem sandbox: allowed verification scripts and other custom tools can still mutate. Do not approve implementation by prose alone. -- Pi/Codex`, + 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-supervise 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/Codex`, }; } if (!planningContextPending) return; @@ -434,12 +306,6 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { }); pi.on("tool_call", async (event, ctx) => { - if (state.phase === "working" && event.toolName === "subagent" && !["list", "status"].includes(String((event.input as { action?: unknown }).action))) { - return { block: true, reason: "The main coordinator may list or inspect subagents; pi-goals owns supervisor lifecycle and delegation." }; - } - if (state.phase === "working" && event.toolName === "subagent_supervisor") { - return { block: true, reason: "Direct the retained supervisor through GuideGoalWorker." }; - } if (state.phase === "planning") { if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) { const target = (event.input as { path?: string }).path; @@ -451,14 +317,6 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { } return; } - if (state.phase === "working") { - if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) { - return { block: true, reason: "Working supervision is read-only: direct implementation and evidence writes to GuideGoalWorker. CompleteGoal is the explicit sign-off control." }; - } - if (event.toolName === "bash" && !isSupervisorReadOnlyCommand(String((event.input as { command?: string }).command))) { - return { block: true, reason: "Working supervision allows inspection and standard verification commands only. Direct file changes belong to GuideGoalWorker; this is not a full sandbox for custom tools or allowed scripts." }; - } - } }); // A compaction loses context, so restore either the planning snapshot or the working plan once. @@ -469,10 +327,6 @@ 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) => { - if (state.phase === "working") { - scheduleSupervisorCheck(ctx); - return; - } if (state.phase !== "planning" || !ctx.hasUI) return; let printed = ""; while (true) { @@ -485,7 +339,7 @@ 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", "Ready (compact)", "Refine", "Edit", "Cancel"]); + 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; @@ -500,46 +354,28 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { continue; } if (choice === "Cancel") { - if (!(await stopWorker())) { - ctx.ui.notify("Could not stop the retained supervisor; the plan was not discarded.", "warning"); - return; - } rmSync(planPath(ctx), { force: true }); - state = { ...state, phase: null, workerRunId: null, workerPending: false, approvalId: null, planVersion: null, autoIntervalMs: null }; + state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null }; persist(); updateWidget(ctx); ctx.ui.notify("Plan discarded.", "info"); return; } - if (choice !== "Ready" && choice !== "Ready (compact)") return; - const startWorking = async (): Promise => { - state = { ...state, phase: "working", autoIntervalMs: AUTO_DEFAULT_INTERVAL_MS }; + if (choice !== "Ready") return; + try { + await startSupervisor(ctx); + state = { ...state, phase: "working" }; resyncReason = "The plan was approved."; persist(); updateWidget(ctx); - try { - await directSupervisor(ctx, "Start by launching the foreground implementation worker. Then supervise the current plan.", undefined, choice === "Ready (compact)"); - scheduleSupervisorCheck(ctx); - return true; - } catch (error) { - ctx.ui.notify(`Goal supervisor could not start: ${error instanceof Error ? error.message : String(error)}`, "warning"); - state = { ...state, phase: "planning", autoIntervalMs: null }; - persist(); - updateWidget(ctx); - return false; - } - }; - const started = await startWorking(); - if (!started || choice === "Ready") return; - ctx.compact({ - onComplete: () => { - resyncReason = "The main coordinator was compacted after the retained supervisor started."; - ctx.ui.notify("Main-session compaction completed; the retained supervisor and worker kept their contexts.", "info"); - }, - onError: (error) => { - ctx.ui.notify(`Main-session compaction failed; the retained supervisor continues: ${error.message}`, "warning"); - }, - }); + ctx.ui.notify(`Visible supervisor opened in Herdr pane ${state.supervisorPaneId}.`, "info"); + pi.sendUserMessage("The plan is approved. Begin implementation as the worker."); + } catch (error) { + ctx.ui.notify(`Goal supervisor could not start: ${error instanceof Error ? error.message : String(error)}`, "warning"); + state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null }; + persist(); + updateWidget(ctx); + } return; } }); @@ -552,65 +388,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { state = { phase: last?.data?.phase ?? null, supervisorModel: last?.data?.supervisorModel ?? null, - workerModel: last?.data?.workerModel ?? null, - workerRunId: last?.data?.workerRunId ?? null, - workerPending: last?.data?.workerPending ?? false, + supervisorPaneId: last?.data?.supervisorPaneId ?? null, approvalId: last?.data?.approvalId ?? null, planVersion: last?.data?.planVersion ?? null, - autoIntervalMs: last?.data?.autoIntervalMs ?? null, }; - watchWorkerCompletion(); - setupWorker(ctx); - if (state.workerPending && await subagentWorkState(pi.events) === "idle") { - state = { ...state, workerPending: false }; - persist(); - } planningContextPending = state.phase === "planning"; resyncReason = state.phase === "working" ? "New session." : null; updateWidget(ctx); - scheduleSupervisorCheck(ctx); - }); - - pi.on("session_shutdown", async () => { - await stopWorker(); - workerRegistration?.dispose(); - workerRegistration = null; - unsubscribeWorkerCompletion?.(); - unsubscribeWorkerCompletion = null; - }); - - pi.registerTool({ - name: "CheckGoalWork", - label: "Check goal work", - description: "Check whether pi-subagents or pi-processes still has active work before deciding that the goal worker stopped.", - parameters: Type.Object({}), - async execute(_id, _params, _signal, _onUpdate, _ctx) { - try { - const [subagents, processes] = await Promise.all([subagentWorkState(pi.events), Promise.resolve(processWorkState(pi.events))]); - const unknown = subagents === "unknown" || processes === "unknown"; - return result(`subagents=${subagents}; processes=${processes}`, unknown); - } catch (error) { - return result(`Goal work status failed: ${error instanceof Error ? error.message : String(error)}`, true); - } - }, - }); - - pi.registerTool({ - name: "GuideGoalWorker", - label: "Guide goal supervisor", - description: "Send one concrete instruction to the retained goal-supervisor. A live supervisor is steered; a completed supervisor is resumed with its saved context and the full current plan.", - parameters: Type.Object({ - instruction: Type.String({ description: "The next research or implementation action, with the evidence that should distinguish success from failure." }), - }), - async execute(_id, params, signal, _onUpdate, ctx) { - if (state.phase !== "working") return result("Approve a plan with Ready before directing the goal supervisor.", true); - try { - const runId = await directSupervisor(ctx, params.instruction, signal); - return result(`Instruction delivered to goal supervisor ${runId}.`); - } catch (error) { - return result(`Goal-supervisor guidance failed: ${error instanceof Error ? error.message : String(error)}`, true); - } - }, }); pi.registerTool({ @@ -622,9 +406,6 @@ 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 (state.workerPending) return result("Goal sign-off blocked while the retained supervisor is pending.", true); - const [subagents, processes] = await Promise.all([subagentWorkState(pi.events), Promise.resolve(processWorkState(pi.events))]); - if (subagents !== "idle" || processes !== "idle") return result(`Goal sign-off blocked: subagents=${subagents}; processes=${processes}.`, true); if (!state.approvalId) return result("Goal sign-off blocked: no current supervisor review.", true); const plan = readPlan(ctx); if (!plan.trim()) return result(`No plan file at ${planRel(ctx)}. Run /goals to draft one.`, true); @@ -676,18 +457,6 @@ function isPlanningReadOnlyCommand(command: string): boolean { }); } -/** This blocks direct writes, not side effects hidden in allowed project scripts or custom tools. -- PI[gpt-5.6-sol] */ -export function isSupervisorReadOnlyCommand(command: string): boolean { - if (/[|><`$\n\r]/.test(command)) return false; - const safeArgs = "(?:\\s+[A-Za-z0-9_./:=,'\"@+%-]+)*"; - const inspection = new RegExp(`^(?:cd|pwd|ls|rg|grep|find|head|tail|wc|stat|test)${safeArgs}$`); - const git = new RegExp(`^git\\s+(?:status|log|diff|show|branch|ls-files|grep|check-ignore)${safeArgs}$`); - const verification = new RegExp(`^(?:npm\\s+test|npm\\s+run\\s+(?:test|typecheck|lint)|npx\\s+tsc\\s+--noEmit)${safeArgs}$`); - return command.split(/&&|;/).every((raw) => { - const part = raw.trim(); - return !mutatingReadCommand(part) && (inspection.test(part) || git.test(part) || verification.test(part)); - }); -} /** Local time, not UTC: agents freehand-stamp their manual ## Log lines from the local clock they * see, so a UTC tool stamp made the trail read as two different afternoons (dogfood finding). */ diff --git a/src/prompts.ts b/src/prompts.ts index 61a6333..2874ce5 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -2,9 +2,8 @@ * pi-goals v2 — all model-facing text, in flow order. * * Design: the plan file is for LLMs and the human, not for TypeScript. No parser and no schema; - * the skeleton below is a convention the drafting prompt teaches, the worker maintains with its - * normal Edit tool, and the retained goal-supervisor reads natively. The harness provides format - * guidance, one full-plan resync after context loss, and retained pi-subagents supervisor and worker sessions. + * the skeleton below is a convention the drafting prompt teaches. The main session implements it, + * while a visible forked Pi session supervises through pi-supervise. * * THE FOLD: everything above "## Log" is the short current-goal section. Everything below it * (Log, Learnings, Appendix) is durable memory: unlimited, read on demand, and sent in full at @@ -13,8 +12,8 @@ * Flow: * SETUP (plan mode) 1. planDrafting — draft goals into the plan file (read-only), sent once * EXEC, after compact 2. resync — the WHOLE file back, once - * SIGN-OFF, agent-side 3. completeGoal* — the one blessed tool's description - * SUPERVISION worker.ts - retained supervisor and foreground worker + * SIGN-OFF, worker-side 3. completeGoal* — the one blessed tool's description + * SUPERVISION supervisor-session.ts — visible read-only supervisor * * The goal's test is the DISCRIMINATOR: the concrete observation that tells real success from the * named subtle failure mode. Evidence is empty at planning and filled at sign-off. @@ -69,7 +68,7 @@ Style: Make it easy for a busy and forgetfull user to review. Use ASD-STE100 Sim the same word for the same thing, and define a new terms at first use. Use redundant context for skim readers e.g. "our output - the cells, CV tag" is easy to read and reminds context. This covers the context paragraph and the appendix too, not just the checklist. No all-caps headers and no bold spam. Just write less, add your voice less, persuade less, and burden the reader less. -Write the plan file in roughly this shape -- the file is read directly by the human and the retained supervisor, so clarity beats conformance; small deviations are fine): +Write the plan file in roughly this shape -- the file is read directly by the human and the visible supervisor, so clarity beats conformance; small deviations are fine): # @@ -89,7 +88,7 @@ Write the plan file in roughly this shape -- the file is read directly by the hu - subtle failure mode: - discriminator: - verify: + testable. The worker runs it and saves its output; the visible supervisor reads the evidence> - tasks: 1. [ ] - evidence: (empty until sign-off) @@ -119,7 +118,7 @@ Conventions: none of the failure modes could fake. Ruling out failures is necessary, not sufficient. - Make the discriminator a concrete, checkable observation about a real artifact (a file, a test result, a committed diff, a metric), never about the plan file's own checkbox. -- evidence stays empty at planning; the worker fills it and the retained supervisor checks it. +- evidence stays empty at planning; the worker fills it and the visible supervisor checks it. Cite durable artifacts a future reader can open: committed files, test names, git diffs. .pi/ is usually gitignored, so files there prove things only at supervisor review time, not in history. - User-visible result: restate the original deliverable, not the proposed implementation. Every goal @@ -159,11 +158,10 @@ Ready.`; export function resync(plan: string, planRel: string, why: string): string { return `\ -${why} This is the whole plan file (${planRel}), appendix included. You are the main coordinator. -Keep the high-level goal and human intent stable; direct the retained goal-supervisor through -GuideGoalWorker rather than doing implementation. The supervisor directs and approves the nested -worker. The human's latest message outranks the plan: if it changes scope, direct the supervisor to -have the worker amend the plan rather than preserving an obsolete decision. +${why} This is the whole plan file (${planRel}), appendix included. You are the implementation worker. +Keep the high-level goal and human intent stable and do the work directly. A visible read-only Pi +session supervises you through pi-supervise. The human's latest message outranks the plan: if it +changes scope, amend the plan rather than preserving an obsolete decision. ${plan} `; @@ -180,11 +178,11 @@ export const completeGoalDescription = "output, rerun it or write that you couldn't -- an honest gap beats a plausible fabrication. If " + "the goal names a verify: command, direct the worker to run it and save its output to a file cited " + "in the evidence. The supervisor may run an allowed read-only verification command, but must not " + - "create the evidence file itself. The retained goal-supervisor must reject a claimed pass with no " + - "saved output. The read must show success POSITIVELY happened, not just that failures were avoided. " + - "The supervisor records an approval checkpoint only after it inspected the current plan, repository, " + - "evidence, and verify output with no active nested worker and a clean committed worktree. Then the main " + - "coordinator calls this tool with the exact goal text. This tool independently checks that checkpoint " + + "create the evidence file itself. The visible supervisor must reject a claimed pass with no saved " + + "output. The read must show success POSITIVELY happened, not just that failures were avoided. The " + + "supervisor records an approval checkpoint only after it inspected the current plan, repository, " + + "evidence, verify output, and a stopped worker view with no active work. Then the worker calls this " + + "tool with the exact goal text. This tool independently checks that checkpoint " + "against the exact current goal block, HEAD/tree, and clean worktree before it appends the sign-off to " + "## Log and ticks the goal [x]. If any check differs, it fails closed and requires a fresh supervisor review."; diff --git a/src/supervisor-runtime.ts b/src/supervisor-runtime.ts deleted file mode 100644 index 073a820..0000000 --- a/src/supervisor-runtime.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { Type } from "typebox"; -import { goalBlock, hashGoalBlock, repositoryState, writeApproval } from "./approval.js"; -import { isSupervisorReadOnlyCommand } from "./index.js"; -import { GOAL_WORKER_AGENT, processWorkState } from "./worker.js"; - -const COMPACTED_STATE = "pi-goals-supervisor-compacted"; - -function result(text: string, isError = false) { - return { content: [{ type: "text" as const, text }], details: {}, isError }; -} - -interface GoalBindings { - compactPlanning?: boolean; - workerModel?: string | null; -} - -function goalBindings(): GoalBindings { - const raw = process.env.PI_SUBAGENT_EXTENSION_BINDINGS; - if (!raw) return {}; - const binding = (JSON.parse(raw) as { "pi-goals/1"?: GoalBindings })["pi-goals/1"] ?? {}; - if (binding.workerModel !== undefined && binding.workerModel !== null && typeof binding.workerModel !== "string") throw new Error("pi-goals workerModel binding must be a string or null."); - return binding; -} - -function messageLaunchesWorker(ctx: { sessionManager: { getBranch(): unknown[] } }): boolean { - const entry = [...ctx.sessionManager.getBranch()].reverse().find((candidate) => { - const value = candidate as { type?: unknown; message?: { role?: unknown } }; - return value.type === "message" && value.message?.role === "assistant"; - }) as { message?: { content?: unknown } } | undefined; - if (!Array.isArray(entry?.message?.content)) return false; - return entry.message.content.some((part) => { - const value = part as { type?: unknown; name?: unknown; arguments?: Record }; - return value.type === "toolCall" && value.name === "subagent" && value.arguments?.agent === GOAL_WORKER_AGENT; - }); -} - -export default function goalSupervisorRuntime(pi: ExtensionAPI): void { - let compacting = false; - let compactionDone = Promise.resolve(); - let currentTurn = -1; - let completedWorkerTurn: number | null = null; - let workerModel: string | null = null; - const activeWorkerCalls = new Set(); - - pi.on("session_before_compact", async (event) => { - if (!compacting) return; - const branchEntries = event.branchEntries as Array<{ id?: string; type?: string; message?: { role?: string } }>; - const latestMessage = [...branchEntries].reverse().find((entry) => entry.type === "message" && ["user", "assistant"].includes(entry.message?.role ?? "")); - return { - compaction: { - summary: "Planning is complete. The latest retained goal-supervisor task contains the current plan and approval paths; use it as the source of truth. -- PI[gpt-5.6-sol]", - firstKeptEntryId: latestMessage?.id ?? event.preparation.firstKeptEntryId, - tokensBefore: event.preparation.tokensBefore, - details: { source: "pi-goals-plan-handoff" }, - }, - }; - }); - - pi.on("session_start", async (_event, ctx) => { - const entries = ctx.sessionManager.getEntries(); - const bindings = goalBindings(); - workerModel = bindings.workerModel ?? null; - if (bindings.compactPlanning !== true) return; - if (entries.some((entry: { type?: string; customType?: string }) => entry.type === "custom" && entry.customType === COMPACTED_STATE)) return; - compacting = true; - compactionDone = new Promise((resolvePromise, reject) => { - ctx.compact({ - onComplete: () => { - compacting = false; - pi.appendEntry(COMPACTED_STATE, { version: 1 }); - resolvePromise(); - }, - onError: (error) => { - compacting = false; - reject(error); - }, - }); - }); - }); - - pi.on("before_agent_start", async () => { - await compactionDone; - }); - - pi.on("turn_start", async (event) => { - activeWorkerCalls.clear(); - currentTurn = event.turnIndex; - }); - - pi.on("tool_call", async (event) => { - if (event.toolName === "edit" || event.toolName === "write") { - return { block: true, reason: "Goal supervision is read-only. Direct project changes to the nested goal-worker." }; - } - if (event.toolName === "bash" && !isSupervisorReadOnlyCommand(String((event.input as { command?: string }).command))) { - return { block: true, reason: "Goal supervision allows inspection and standard verification commands only." }; - } - if (event.toolName !== "subagent") return; - const input = event.input as Record; - const allowedKeys = new Set(["agent", "task", "async", "context", ...(workerModel ? ["model"] : [])]); - const unexpectedKeys = Object.keys(input).filter((key) => !allowedKeys.has(key)); - const validWorker = input.agent === GOAL_WORKER_AGENT - && typeof input.task === "string" - && input.task.trim().length > 0 - && input.async === false - && input.context === "fork" - && (workerModel ? input.model === workerModel : input.model === undefined) - && unexpectedKeys.length === 0; - if (!validWorker) { - const model = workerModel ? `, model:${JSON.stringify(workerModel)}` : ""; - return { block: true, reason: `Launch only ${GOAL_WORKER_AGENT} with task, async:false, context:"fork"${model}, and no other fields.` }; - } - if (activeWorkerCalls.size > 0) return { block: true, reason: "A foreground goal-worker is already running." }; - activeWorkerCalls.add(event.toolCallId); - completedWorkerTurn = null; - }); - - pi.on("tool_result", async (event) => { - if (!activeWorkerCalls.delete(event.toolCallId)) return; - if (!event.isError) completedWorkerTurn = currentTurn; - }); - - pi.registerTool({ - name: "ApproveGoal", - label: "Approve goal", - executionMode: "sequential", - description: "Record approval after inspecting the plan, repository, evidence, and saved verification output. Active or unknown work blocks approval.", - parameters: Type.Object({ - approvalId: Type.String({ minLength: 1, description: "Exact approval ID from the latest main-coordinator direction." }), - goal: Type.String({ description: "Exact current goal text from the approved plan." }), - planPath: Type.String({ description: "Absolute path to the current plan file." }), - checkpointPath: Type.String({ description: "Exact private approval-record path supplied by the main coordinator." }), - inspectedPlan: Type.Literal(true), - inspectedRepository: Type.Literal(true), - inspectedEvidence: Type.Literal(true), - inspectedVerifyOutput: Type.Literal(true), - }), - async execute(_id, params, _signal, _onUpdate, ctx) { - if (messageLaunchesWorker(ctx) || activeWorkerCalls.size > 0 || completedWorkerTurn === null || completedWorkerTurn >= currentTurn) { - return result("Cannot approve in a worker-launch message or before reviewing a finished worker on a later turn.", true); - } - const processes = processWorkState(pi.events); - if (processes !== "idle") return result(`Cannot approve: processes=${processes}.`, true); - const planPath = resolve(params.planPath); - let plan: string; - let repository: ReturnType; - try { - plan = readFileSync(planPath, "utf8"); - repository = repositoryState(ctx.cwd); - } catch (error) { - return result(`Cannot inspect approval inputs: ${error instanceof Error ? error.message : String(error)}`, true); - } - if (!repository.cleanWorktree) return result("Cannot approve with a dirty worktree. Commit the worker changes first.", true); - const block = goalBlock(plan, params.goal); - if (!block) return result(`Cannot approve: no unique open goal matches "${params.goal}".`, true); - const path = resolve(params.checkpointPath); - const approvalRoot = resolve(ctx.cwd, ".pi", "pi-goals", "approvals"); - if (!path.startsWith(`${approvalRoot}/`)) return result("Approval checkpoint must stay in private .pi/pi-goals/approvals state.", true); - writeApproval(path, { - version: 2, - verdict: "accept", - approvalId: params.approvalId, - goal: params.goal, - planPath, - goalBlockHash: hashGoalBlock(block), - repoRoot: repository.repoRoot, - head: repository.head, - tree: repository.tree, - cleanWorktree: true, - inspected: { plan: true, repository: true, evidence: true, verifyOutput: true }, - supervisor: { sessionId: ctx.sessionManager.getSessionId(), runId: process.env.PI_SUBAGENT_RUN_ID ?? null }, - timestamp: new Date().toISOString(), - }); - return result(`Approval recorded at ${path} for "${params.goal}".`); - }, - }); -} diff --git a/src/supervisor-session.ts b/src/supervisor-session.ts new file mode 100644 index 0000000..0e297a5 --- /dev/null +++ b/src/supervisor-session.ts @@ -0,0 +1,154 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { approvalPath, goalBlock, hashGoalBlock, repositoryState, writeApproval } from "./approval.js"; + +const BOOTSTRAPPED = "pi-goals-visible-supervisor-v1"; +const COMPACT_AT_TOKENS = 100_000; + +interface SupervisorConfig { + workerSessionId: string; + ownerSessionId: string; + planPath: string; + approvalId: string; +} + +function result(text: string, isError = false) { + return { content: [{ type: "text" as const, text }], details: {}, isError }; +} + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required in a pi-goals supervisor session.`); + return value; +} + +function config(): SupervisorConfig { + return { + workerSessionId: requiredEnv("PI_GOALS_WORKER_ID"), + ownerSessionId: requiredEnv("PI_GOALS_OWNER_SESSION_ID"), + planPath: resolve(requiredEnv("PI_GOALS_PLAN_PATH")), + approvalId: requiredEnv("PI_GOALS_APPROVAL_ID"), + }; +} + +function latestWorkerView(ctx: ExtensionContext): string | null { + for (const entry of [...ctx.sessionManager.getBranch()].reverse()) { + const message = (entry as { type?: string; message?: { role?: string; content?: unknown[] } }).message; + if ((entry as { type?: string }).type !== "message" || message?.role !== "user" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + const text = (part as { type?: string; text?: string }).type === "text" ? (part as { text?: string }).text : undefined; + if (text?.startsWith("The worker ")) return text; + } + } + return null; +} + +function supervisorPrompt(settings: SupervisorConfig): string { + return `You are the visible pi-goals supervisor for ${settings.planPath}. You are a stronger, read-only reviewer. The other Pi session is the implementation worker and keeps the full conversation. You keep the high-level intent from the compacted planning conversation, the complete plan, and pi-supervise worker views. + +Use pi-supervise to inspect and steer the worker. Give one concrete instruction when work is incomplete. Do not edit files. For each open goal, inspect its exact plan block, repository state, cited evidence, and saved verify output. When its discriminator is positively satisfied and the worker view says no work is active, call ApproveGoal. Then call steer and tell the worker to call CompleteGoal with the exact goal text. Do not call done until every plan goal is [x]. -- PI[gpt-5.6-sol]`; +} + +export function isVisibleSupervisor(): boolean { + return process.env.PI_GOALS_ROLE === "supervisor"; +} + +export function registerVisibleSupervisor(pi: ExtensionAPI): void { + const settings = config(); + let compacting = false; + + pi.on("before_agent_start", async (_event, ctx) => ({ + systemPrompt: `${ctx.getSystemPrompt()}\n\n${supervisorPrompt(settings)}`, + })); + + pi.on("session_start", async (_event, ctx) => { + const entries = ctx.sessionManager.getEntries(); + if (entries.some((entry: { type?: string; customType?: string }) => entry.type === "custom" && entry.customType === BOOTSTRAPPED)) return; + if (!pi.getCommands().some((command) => command.name === "supervise" && command.source === "extension")) { + ctx.ui.notify("pi-goals supervisor needs the @wassname2/pi-supervise extension.", "error"); + return; + } + compacting = true; + ctx.compact({ + customInstructions: `Preserve the user's decisions, preferences, and high-level objective from planning. Preserve unresolved risks and the plan path ${settings.planPath}. Remove implementation chatter. This summary is for a read-only supervisor that will judge and steer another Pi session.`, + onComplete: () => { + compacting = false; + pi.appendEntry(BOOTSTRAPPED, { version: 1, workerSessionId: settings.workerSessionId, planPath: settings.planPath }); + const sendCommand = pi.sendUserMessage as (content: string, options: { expandPromptTemplates: boolean }) => void; + sendCommand(`/supervise @${settings.workerSessionId} ${settings.planPath}`, { expandPromptTemplates: true }); + }, + onError: (error) => { + compacting = false; + ctx.ui.notify(`Supervisor compaction failed: ${error.message}`, "error"); + }, + }); + }); + + pi.on("agent_settled", async (_event, ctx) => { + if (compacting || (ctx.getContextUsage()?.tokens ?? 0) < COMPACT_AT_TOKENS) return; + compacting = true; + ctx.compact({ + customInstructions: `Keep the user's high-level intent, current plan state, unresolved risks, approval decisions, and the supervisor's own concise findings. Remove old worker views and implementation detail.`, + onComplete: () => { + compacting = false; + ctx.ui.notify("Supervisor context compacted at 100k tokens.", "info"); + }, + onError: (error) => { + compacting = false; + ctx.ui.notify(`Supervisor compaction failed: ${error.message}`, "error"); + }, + }); + }); + + pi.registerTool({ + name: "ApproveGoal", + label: "Approve goal", + executionMode: "sequential", + description: "Record approval after inspecting the current goal, repository, evidence, saved verify output, and a stopped worker view with no active work.", + parameters: Type.Object({ + goal: Type.String({ description: "Exact text after goal: in the plan." }), + inspectedPlan: Type.Literal(true), + inspectedRepository: Type.Literal(true), + inspectedEvidence: Type.Literal(true), + inspectedVerifyOutput: Type.Literal(true), + }), + async execute(_id, params, _signal, _onUpdate, ctx) { + const view = latestWorkerView(ctx); + if (!view?.startsWith("The worker stopped.")) return result("Cannot approve without a current stopped-worker view.", true); + const pendingTool = view.match(/^tool calls with no result: (?!none$)(.+)$/m); + const pendingChild = view.match(/^child pi processes still running: (?!none$)(.+)$/m); + if (pendingTool || pendingChild) return result(`Cannot approve while work is active: ${(pendingTool ?? pendingChild)![1]}`, true); + let plan: string; + let repository: ReturnType; + try { + plan = readFileSync(settings.planPath, "utf8"); + repository = repositoryState(ctx.cwd); + } catch (error) { + return result(`Cannot inspect approval inputs: ${error instanceof Error ? error.message : String(error)}`, true); + } + if (!repository.cleanWorktree) return result("Cannot approve with a dirty worktree. Commit the worker changes first.", true); + const block = goalBlock(plan, params.goal); + if (!block) return result(`Cannot approve: no unique open goal matches "${params.goal}".`, true); + if (/evidence:\s*\(empty until sign-off\)/i.test(block)) return result("Cannot approve while the goal evidence is empty.", true); + const path = approvalPath(ctx.cwd, settings.ownerSessionId, params.goal); + writeApproval(path, { + version: 2, + verdict: "accept", + approvalId: settings.approvalId, + goal: params.goal, + planPath: settings.planPath, + goalBlockHash: hashGoalBlock(block), + repoRoot: repository.repoRoot, + head: repository.head, + tree: repository.tree, + cleanWorktree: true, + inspected: { plan: true, repository: true, evidence: true, verifyOutput: true }, + supervisor: { sessionId: ctx.sessionManager.getSessionId(), runId: null }, + timestamp: new Date().toISOString(), + }); + return result(`Approval recorded for "${params.goal}". Now steer the worker to call CompleteGoal.`); + }, + }); +} diff --git a/src/worker.ts b/src/worker.ts deleted file mode 100644 index 47c1b4d..0000000 --- a/src/worker.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { fileURLToPath } from "node:url"; - -const REGISTER_EVENT = "pi-subagents:runtime-agent-register:v1"; -const RPC_REQUEST_EVENT = "subagents:rpc:v1:request"; -const RPC_REPLY_PREFIX = "subagents:rpc:v1:reply:"; -const RPC_VERSION = 1; -const RPC_TIMEOUT_MS = 15_000; -export const SUPERVISOR_AGENT = "goal-supervisor"; -export const GOAL_WORKER_AGENT = "pi-goals-worker-v1"; - -interface EventBus { - on(event: string, handler: (data: unknown) => void): () => void; - emit(event: string, data: unknown): void; -} - -interface Registration { - dispose(): void; -} - -interface RpcData { - text: string; - details?: Record; - asyncSnapshot?: AsyncSnapshot; -} - -interface AsyncNode { - id: string; - state: string; - children?: AsyncNode[]; -} - -interface AsyncSnapshot { - kind: string; - version: number; - omitted: { runs: number; children: number; byteLimitExceeded: boolean }; - runs: AsyncNode[]; -} - -export type WorkState = "active" | "idle" | "unknown"; - -export const supervisorSystemPrompt = `You are the retained goal supervisor. The main Pi session only coordinates with the human. -Your forked planning history may be compacted before your first turn. Launch ${GOAL_WORKER_AGENT} in the foreground with exactly -agent, task, async:false, context:"fork", and, when named in the current direction, that worker model. Wait for its result; do not use -bg_wait or worker run IDs. Read the current plan, repository, cited evidence, and saved verification output yourself after the -worker finishes. Do not edit project files. Use read/search and standard verification commands only. The worker must commit its -changes before approval. If the evidence needs a correction, launch a new foreground ${GOAL_WORKER_AGENT} with one concrete task -and wait for it. On a later turn, when HEAD is committed, the worktree is clean, and the evidence proves the discriminator, call -ApproveGoal with the current approval ID. Only ApproveGoal creates acceptance. -- Pi/Codex`; - -function registerRuntimeAgent(events: EventBus, name: string, definition: Record): Registration { - const request: Record = { version: 1, name, definition }; - events.emit(REGISTER_EVENT, request); - const result = request.result as { ok?: boolean; registration?: Registration; error?: Error } | undefined; - if (!result) throw new Error("pi-subagents is not installed or not ready."); - if (!result.ok || !result.registration) throw result.error ?? new Error(`pi-subagents rejected the ${name} agent.`); - return result.registration; -} - -export function registerGoalSupervisor(events: EventBus, model: string | null): Registration { - const supervisorRuntime = fileURLToPath(new URL("./supervisor-runtime.ts", import.meta.url)); - return registerRuntimeAgent(events, SUPERVISOR_AGENT, { - description: "Read-only supervisor that owns a foreground implementation worker.", - systemPrompt: supervisorSystemPrompt, - tools: ["read", "grep", "find", "ls", "bash", "subagent", "ApproveGoal"], - allowNestedSubagents: true, - subagentOnlyExtensions: [supervisorRuntime], - ...(model ? { model } : {}), - systemPromptMode: "replace", - thinking: "low", - inheritProjectContext: false, - inheritGlobalContext: false, - inheritSkills: false, - defaultContext: "fork", - defaultAsync: true, - defaultProgress: true, - }); -} - -async function rpc(events: EventBus, method: "spawn" | "resume" | "steer" | "status" | "stop", params: Record, signal?: AbortSignal): Promise { - if (signal?.aborted) throw new Error("Goal-worker request aborted."); - const requestId = randomUUID(); - return new Promise((resolve, reject) => { - let timer: ReturnType; - const replyEvent = `${RPC_REPLY_PREFIX}${requestId}`; - const cleanup = () => { - clearTimeout(timer); - unsubscribe(); - signal?.removeEventListener("abort", onAbort); - }; - const onAbort = () => { - cleanup(); - reject(new Error("Goal-worker request aborted.")); - }; - const unsubscribe = events.on(replyEvent, (raw) => { - const reply = raw as { success?: boolean; data?: RpcData; error?: { message?: string } }; - cleanup(); - if (!reply.success || !reply.data) reject(new Error(reply.error?.message ?? `pi-subagents ${method} failed.`)); - else resolve(reply.data); - }); - timer = setTimeout(() => { - cleanup(); - reject(new Error(`pi-subagents ${method} did not reply within ${RPC_TIMEOUT_MS / 1000}s.`)); - }, RPC_TIMEOUT_MS); - timer.unref(); - signal?.addEventListener("abort", onAbort, { once: true }); - events.emit(RPC_REQUEST_EVENT, { version: RPC_VERSION, requestId, method, params, source: { extension: "pi-goals" } }); - }); -} - -function asyncRunId(data: RpcData): string { - const runId = data.details?.asyncId ?? data.details?.runId; - if (typeof runId !== "string" || !runId) throw new Error("pi-subagents returned no async run ID."); - return runId; -} - -export async function startGoalSupervisor(events: EventBus, cwd: string, task: string, compactPlanning: boolean, workerModel: string | null, signal?: AbortSignal): Promise { - const data = await rpc(events, "spawn", { - agent: SUPERVISOR_AGENT, - task, - cwd, - context: "fork", - async: true, - mission: false, - extensionBindings: { "pi-goals/1": { compactPlanning, workerModel } }, - }, signal); - return asyncRunId(data); -} - -export async function resumeGoalSupervisor(events: EventBus, runId: string, task: string, signal?: AbortSignal): Promise { - return asyncRunId(await rpc(events, "resume", { id: runId, message: task }, signal)); -} - -export async function steerGoalSupervisor(events: EventBus, runId: string, task: string, signal?: AbortSignal): Promise { - await rpc(events, "steer", { id: runId, message: task, mode: "steer" }, signal); -} - -export async function stopGoalSupervisor(events: EventBus, runId: string): Promise { - try { - await rpc(events, "stop", { id: runId }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!/not found|already completed|\bis (?:complete|completed|failed|partial|paused|stopped|rejected)\b/i.test(message)) throw error; - } -} - -export function terminalSteerError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return /not found|already completed|not running|\bis (?:complete|completed|failed|partial|paused|stopped|rejected)\b/i.test(message); -} - -function activeNode(node: AsyncNode): boolean { - return node.state === "queued" || node.state === "running" || node.state === "stopping" || Boolean(node.children?.some(activeNode)); -} - -function validSnapshot(snapshot: AsyncSnapshot | undefined): snapshot is AsyncSnapshot { - return snapshot?.kind === "pi-subagents.async-status-snapshot" && snapshot.version === 1 && snapshot.omitted.runs === 0 && snapshot.omitted.children === 0 && !snapshot.omitted.byteLimitExceeded; -} - -async function asyncSnapshot(events: EventBus): Promise { - return (await rpc(events, "status", {})).asyncSnapshot; -} - -export async function subagentWorkState(events: EventBus): Promise { - const snapshot = await asyncSnapshot(events); - if (!validSnapshot(snapshot)) return "unknown"; - return snapshot.runs.some(activeNode) ? "active" : "idle"; -} - -export interface ProcessInfo { - status: string; -} - -export function processWorkState(events: EventBus): WorkState { - let replied = false; - let processes: ProcessInfo[] = []; - events.emit("processes:request:list", { - reply(value: ProcessInfo[]) { - replied = true; - processes = value; - }, - }); - if (!replied || !Array.isArray(processes)) return "unknown"; - const terminal = new Set(["finished", "failed", "exited", "killed"]); - return processes.every((process) => terminal.has(process.status)) ? "idle" : "active"; -} diff --git a/test/goals-flow.test.ts b/test/goals-flow.test.ts index ad77ede..5741357 100644 --- a/test/goals-flow.test.ts +++ b/test/goals-flow.test.ts @@ -1,20 +1,18 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { approvalPath, goalBlock, hashGoalBlock, repositoryState, writeApproval } from "../src/approval.js"; -import piGoalsExtension, { isSupervisorProcess } from "../src/index.js"; -function setup( - selectChoices: Array, - editorChoices: Array = [], - editPlan?: () => Promise, - contextTokens = 0, - completeWorkerBeforeReply = false, - compactError?: Error, -) { +const openSupervisorPane = vi.fn(async () => "pane-2"); +const closeSupervisorPane = vi.fn(async () => undefined); +vi.mock("../src/herdr.js", () => ({ openSupervisorPane, closeSupervisorPane })); + +const { default: piGoalsExtension, isMainSession } = await import("../src/index.js"); + +function setup(selectChoices: Array, editorChoices: Array = []) { const cwd = mkdtempSync(join(tmpdir(), "pi-goals-flow-")); writeFileSync(join(cwd, ".gitignore"), ".pi/\n"); execFileSync("git", ["init", "-q"], { cwd }); @@ -24,573 +22,142 @@ function setup( const hooks = new Map(); const tools = new Map(); const entries: Array<{ type: string; customType: string; data: unknown }> = []; - const eventLog: string[] = []; const messages: Array<{ content: string; display?: boolean }> = []; - const rpcRequests: any[] = []; - const compactCalls: any[] = []; const notifications: string[] = []; - const statuses: Array = []; - const widgets: Array = []; - const eventHandlers = new Map void>>(); - const eventBus = { - on(name: string, handler: (data: unknown) => void) { - const handlers = eventHandlers.get(name) ?? new Set(); - handlers.add(handler); - eventHandlers.set(name, handlers); - return () => handlers.delete(handler); - }, - emit(name: string, data: unknown) { - for (const handler of [...(eventHandlers.get(name) ?? [])]) handler(data); - }, - }; - let asyncRun = 0; - eventBus.on("pi-subagents:runtime-agent-register:v1", (raw) => { - (raw as any).result = { ok: true, registration: { dispose() {} } }; - }); - eventBus.on("subagents:rpc:v1:request", (raw) => { - const request = raw as any; - rpcRequests.push(request); - if (request.method === "status") { - eventBus.emit(`subagents:rpc:v1:reply:${request.requestId}`, { - success: true, - data: { - text: "status", - asyncSnapshot: { kind: "pi-subagents.async-status-snapshot", version: 1, omitted: { runs: 0, children: 0, byteLimitExceeded: false }, runs: [] }, - }, - }); - return; - } - asyncRun++; - if (completeWorkerBeforeReply) eventBus.emit("subagent:async-complete", { runId: `worker-${asyncRun}`, results: [{ success: true }] }); - eventBus.emit(`subagents:rpc:v1:reply:${request.requestId}`, { success: true, data: { text: "started", details: { asyncId: `worker-${asyncRun}` } } }); - }); - eventBus.on("processes:request:list", (raw) => { - (raw as { reply(value: object[]): void }).reply([]); - }); const ctx = { cwd, hasUI: true, - isIdle: () => true, - getContextUsage: () => ({ tokens: contextTokens }), - compact: (options: any) => { - compactCalls.push(options); - if (compactError) options.onError?.(compactError); - else options.onComplete?.({ summary: "summary" }); - }, getSystemPrompt: () => "base prompt", - sessionManager: { getSessionId: () => "session-a", getEntries: () => entries }, + sessionManager: { + getSessionId: () => "session-a", + getSessionFile: () => join(cwd, "session.jsonl"), + getEntries: () => entries, + }, ui: { theme: { fg: (_kind: string, text: string) => text }, - setStatus: (_key: string, text: string | undefined) => statuses.push(text), - setWidget: (_key: string, lines: string[] | undefined) => widgets.push(lines), + setStatus: vi.fn(), + setWidget: vi.fn(), notify: (text: string) => notifications.push(text), - select: async () => { - eventLog.push("select"); - return selectChoices.shift(); - }, - editor: async () => { - eventLog.push("editor"); - return editPlan ? editPlan() : editorChoices.shift(); - }, + select: async () => selectChoices.shift(), + editor: async () => editorChoices.shift(), }, }; const pi = { - events: eventBus, registerCommand: (name: string, command: any) => commands.set(name, command), on: (name: string, handler: any) => hooks.set(name, handler), appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }), registerTool: (tool: any) => tools.set(tool.name, tool), - sendMessage: (message: { content: string; display?: boolean }) => { - eventLog.push("display"); - messages.push(message); - }, - sendUserMessage: (message: string) => messages.push({ content: message }), + sendMessage: (message: { content: string; display?: boolean }) => messages.push(message), + sendUserMessage: (content: string) => messages.push({ content }), }; piGoalsExtension(pi as unknown as ExtensionAPI); - return { commands, compactCalls, ctx, cwd, entries, events: eventLog, eventBus, hooks, messages, notifications, rpcRequests, statuses, tools, widgets }; + return { commands, ctx, cwd, entries, hooks, messages, notifications, tools }; } -function writeSupervisorApproval(flow: ReturnType, goal: string): void { - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - const plan = readFileSync(planPath, "utf8"); - const block = goalBlock(plan, goal); - if (!block) throw new Error("test plan has no open goal"); - const state = flow.entries.at(-1)?.data as { approvalId: string; workerRunId: string }; - const repository = repositoryState(flow.cwd); - writeApproval(approvalPath(flow.cwd, "session-a", goal), { - version: 2, - verdict: "accept", - approvalId: state.approvalId, - goal, - planPath, - goalBlockHash: hashGoalBlock(block), - repoRoot: repository.repoRoot, - head: repository.head, - tree: repository.tree, - cleanWorktree: true, - inspected: { plan: true, repository: true, evidence: true, verifyOutput: true }, - supervisor: { sessionId: "supervisor-session", runId: state.workerRunId }, - timestamp: "2026-09-05T00:00:00.000Z", - }); +function writePlan(cwd: string, content: string): string { + const path = join(cwd, ".pi/plan/session-a-v1.md"); + mkdirSync(join(cwd, ".pi/plan"), { recursive: true }); + writeFileSync(path, content); + return path; } -describe("/goals draft flow", () => { - it("preserves prior drafts, displays the plan before Refine, and records editor notes", async () => { - const flow = setup(["Refine"], ["Keep two columns.\nDo not add a filter."]); +function approvedPlan(cwd: string): string { + return writePlan(cwd, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n - discriminator: output exists\n - evidence:\n - `result.txt`: contains ok\n\n## Log\n"); +} + +afterEach(() => { + openSupervisorPane.mockClear(); + closeSupervisorPane.mockClear(); +}); + +describe("/goals flow", () => { + it("preserves drafts, records the interview, and keeps planning read-only", async () => { + const flow = setup(["Refine"], ["Keep two columns."]); try { - const legacy = join(flow.cwd, ".pi/plan/session-a.md"); - mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true }); - writeFileSync(legacy, "old plan"); await flow.commands.get("goals").handler("first objective", flow.ctx); - const v1 = join(flow.cwd, ".pi/plan/session-a-v1.md"); - expect(readFileSync(v1, "utf-8")).toBe(""); - expect(readFileSync(legacy, "utf-8")).toBe("old plan"); - const plan = "# First plan\n\n## Goals\n\n1. [ ] goal: preserve this\n\n## Appendix (context, not approved)\nold context\n"; - mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true }); - writeFileSync(v1, plan); - await flow.hooks.get("input")({ text: "The result must preserve column order.", source: "interactive" }, flow.ctx); - + const first = writePlan(flow.cwd, "# Plan\n\n## Goals\n\n1. [ ] goal: preserve this\n\n## Interview\n"); + await flow.hooks.get("input")({ text: "Preserve column order.", source: "interactive" }, flow.ctx); await flow.hooks.get("agent_settled")({}, flow.ctx); - expect(flow.events).toEqual(["display", "select", "editor"]); + expect(readFileSync(first, "utf8")).toContain("> Preserve column order."); + expect(readFileSync(first, "utf8")).toContain("> Keep two columns."); expect(flow.messages.at(-1)?.content).toContain("Revise the plan at"); - expect(flow.messages.find((message) => message.display)?.content).toContain("goal: preserve this"); - const interviewedPlan = readFileSync(v1, "utf-8"); - expect(interviewedPlan).toContain("> The result must preserve column order."); - expect(interviewedPlan).toMatch(/## Interview\n\n### .+\n\n> The result must preserve column order\.[\s\S]+> Keep two columns\.\n> Do not add a filter\./); - const refineSnapshot = await flow.hooks.get("before_agent_start")({}, flow.ctx); - expect(refineSnapshot.message.content).toContain("[PLANNING MODE]"); - const blocked = await flow.hooks.get("tool_call")({ toolName: "edit", input: { path: "README.md" } }, flow.ctx); - expect(blocked?.block).toBe(true); + expect((await flow.hooks.get("tool_call")({ toolName: "edit", input: { path: "README.md" } }, flow.ctx))?.block).toBe(true); await flow.commands.get("goals").handler("second objective", flow.ctx); - expect(readFileSync(v1, "utf-8")).toBe(interviewedPlan); - expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v2.md"), "utf-8")).toBe(""); + expect(readFileSync(first, "utf8")).toContain("preserve this"); expect(flow.messages.at(-1)?.content).toContain("session-a-v2.md"); - - await flow.commands.get("goals").handler("judge the vendor options", flow.ctx); - expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v3.md"), "utf-8")).toBe(""); - expect(flow.messages.at(-1)?.content).toContain("Objective: judge the vendor options"); } finally { rmSync(flow.cwd, { recursive: true, force: true }); } }); - it("disconnects without deleting the active plan", async () => { - const flow = setup([]); + it("forks a visible supervisor on Ready and keeps the main session as worker", async () => { + const flow = setup(["Ready"]); try { - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: preserve this\n"); + await flow.commands.get("goals").handler("make the file", flow.ctx); + const planPath = approvedPlan(flow.cwd); + await flow.hooks.get("agent_settled")({}, flow.ctx); + expect(openSupervisorPane).toHaveBeenCalledWith(expect.objectContaining({ + cwd: flow.cwd, + sourceSessionFile: join(flow.cwd, "session.jsonl"), + workerSessionId: "session-a", + planPath, + })); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", supervisorPaneId: "pane-2" }); + expect(flow.messages.at(-1)?.content).toBe("The plan is approved. Begin implementation as the worker."); + const prompt = await flow.hooks.get("before_agent_start")({}, flow.ctx); + expect(prompt.systemPrompt).toContain("implementation worker"); + expect(prompt.systemPrompt).toContain("stronger read-only supervisor"); + } finally { + rmSync(flow.cwd, { recursive: true, force: true }); + } + }); + it("closes the supervisor on clear but keeps the plan file", async () => { + const flow = setup(["Ready"]); + try { + await flow.commands.get("goals").handler("make the file", flow.ctx); + const planPath = approvedPlan(flow.cwd); + await flow.hooks.get("agent_settled")({}, flow.ctx); await flow.commands.get("goals").handler("clear", flow.ctx); - - expect(readFileSync(planPath, "utf-8")).toContain("goal: preserve this"); - expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, planVersion: null }); - - await flow.commands.get("goals").handler("next objective", flow.ctx); - expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v2.md"), "utf-8")).toBe(""); + expect(closeSupervisorPane).toHaveBeenCalledWith("pane-2"); + expect(readFileSync(planPath, "utf8")).toContain("make the file"); + expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, supervisorPaneId: null, planVersion: null }); } finally { rmSync(flow.cwd, { recursive: true, force: true }); } }); - it("keeps supervisor and implementation-worker models separate", async () => { + it("signs off only an approval for the exact clean commit and goal block", async () => { const flow = setup(["Ready"]); try { - await flow.commands.get("goals").handler("model provider/supervisor", flow.ctx); - await flow.commands.get("goals").handler("worker-model provider/worker", flow.ctx); - expect(flow.entries.at(-1)?.data).toMatchObject({ supervisorModel: "provider/supervisor", workerModel: "provider/worker" }); - await flow.commands.get("goals").handler("objective", flow.ctx); - writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n## Goals\n\n1. [/] goal: work\n"); + await flow.commands.get("goals").handler("make the file", flow.ctx); + const planPath = approvedPlan(flow.cwd); await flow.hooks.get("agent_settled")({}, flow.ctx); - expect(flow.rpcRequests.at(-1)).toMatchObject({ params: { extensionBindings: { "pi-goals/1": { workerModel: "provider/worker" } } } }); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("stops the retained supervisor before clearing an active plan", async () => { - const flow = setup(["Ready"]); - try { - await flow.hooks.get("session_start")({}, flow.ctx); - await flow.commands.get("goals").handler("objective", flow.ctx); - writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n## Goals\n\n1. [/] goal: work\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - - await flow.commands.get("goals").handler("clear", flow.ctx); - - expect(flow.rpcRequests.at(-1)).toMatchObject({ method: "stop", params: { id: "worker-1" } }); - expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, workerRunId: null, workerPending: false }); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("waits for Refine notes before starting a revision turn", async () => { - let submitNotes: (notes: string) => void; - const flow = setup(["Refine"], [], () => new Promise((resolve) => { - submitNotes = resolve; - })); - try { - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make this specific\n"); - - const review = flow.hooks.get("agent_settled")({}, flow.ctx); - await new Promise((resolve) => setImmediate(resolve)); - expect(flow.events).toEqual(["display", "select", "editor"]); - expect(flow.messages.filter((message) => !message.display)).toHaveLength(1); - - submitNotes!("Name the output artifact."); - await review; - expect(flow.messages.at(-1)?.content).toContain("Revise the plan at"); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("starts work only when the human chooses Ready", async () => { - const flow = setup(["Ready"]); - try { - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: work on this\n"); - - await flow.hooks.get("agent_settled")({}, flow.ctx); - - expect(flow.events).toEqual(["display", "select"]); - expect(flow.rpcRequests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", context: "fork" } }); - expect(flow.messages.filter((message) => !message.display)).toHaveLength(1); - const supervisor = await flow.hooks.get("before_agent_start")({}, flow.ctx); - expect(supervisor.systemPrompt).toContain("thin human-facing coordinator"); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("edits a plan in Pi and cancels without starting work", async () => { - const original = "# Plan\n\n## Goals\n\n1. [ ] goal: original\n"; - const edited = "# Plan\n\n## Goals\n\n1. [ ] goal: edited\n"; - const flow = setup(["Edit", "Cancel"], [edited]); - try { - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, original); - - await flow.hooks.get("agent_settled")({}, flow.ctx); - - expect(flow.events).toEqual(["display", "select", "editor", "display", "select"]); - expect(() => readFileSync(planPath, "utf-8")).toThrow(); - expect(flow.messages.filter((message) => !message.display)).toHaveLength(1); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("resyncs the whole plan once without telling the supervisor to implement it", async () => { - const flow = setup(["Ready"]); - try { - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n\n## Log\n- worker evidence\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - - const resync = await flow.hooks.get("context")({ messages: [] }, flow.ctx); - expect(resync.messages.at(-1).content[0].text).toContain("worker evidence"); - expect(resync.messages.at(-1).content[0].text).not.toContain("Keep it current as you work"); - expect(await flow.hooks.get("context")({ messages: [] }, flow.ctx)).toBeUndefined(); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("checks every interval without pausing after unchanged work", async () => { - vi.useFakeTimers(); - const flow = setup(["Ready"]); - try { - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - await flow.commands.get("goals").handler("auto 1", flow.ctx); - - await vi.advanceTimersByTimeAsync(30_000); - await flow.hooks.get("agent_settled")({}, flow.ctx); - await vi.advanceTimersByTimeAsync(30_000); - expect(flow.rpcRequests).toHaveLength(2); - expect(flow.rpcRequests.at(-1)).toMatchObject({ method: "steer", params: { id: "worker-1", message: expect.stringContaining("supervisor check is due") } }); - - for (let n = 3; n <= 4; n++) { - await vi.advanceTimersByTimeAsync(60_000); - expect(flow.rpcRequests).toHaveLength(n); - await flow.hooks.get("agent_settled")({}, flow.ctx); - } - } finally { - vi.useRealTimers(); - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("starts the supervisor before Ready (compact), while Ready preserves main context", async () => { - const ready = setup(["Ready"]); - const compacted = setup(["Ready (compact)"]); - try { - for (const flow of [ready, compacted]) { - await flow.commands.get("goals").handler("objective", flow.ctx); - writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - } - - expect(ready.compactCalls).toHaveLength(0); - expect(ready.rpcRequests).toHaveLength(1); - expect(ready.rpcRequests[0]).toMatchObject({ params: { extensionBindings: { "pi-goals/1": { compactPlanning: false } } } }); - expect(compacted.compactCalls).toHaveLength(1); - expect(compacted.rpcRequests).toHaveLength(1); - expect(compacted.rpcRequests[0]).toMatchObject({ params: { extensionBindings: { "pi-goals/1": { compactPlanning: true } } } }); - const resync = await compacted.hooks.get("context")({ messages: [] }, compacted.ctx); - expect(resync.messages.at(-1).content[0].text).toContain("The main coordinator was compacted after the retained supervisor started."); - } finally { - rmSync(ready.cwd, { recursive: true, force: true }); - rmSync(compacted.cwd, { recursive: true, force: true }); - } - }); - - it("keeps the already-started supervisor when requested main compaction fails", async () => { - const flow = setup(["Ready (compact)"], [], undefined, 0, false, new Error("compactor unavailable")); - try { - await flow.commands.get("goals").handler("objective", flow.ctx); - writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - - expect(flow.compactCalls).toHaveLength(1); - expect(flow.rpcRequests).toHaveLength(1); - expect(flow.notifications).toContain("Main-session compaction failed; the retained supervisor continues: compactor unavailable"); - const working = await flow.hooks.get("before_agent_start")({}, flow.ctx); - expect(working.systemPrompt).toContain("thin human-facing coordinator"); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("checks exact subagent and process status after native worker completion", async () => { - const flow = setup(["Ready"]); - try { - await flow.hooks.get("session_start")({}, flow.ctx); - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - flow.eventBus.emit("subagent:async-complete", { runId: "worker-1", results: [{ success: true }] }); - - const status = await flow.tools.get("CheckGoalWork").execute("", {}, undefined, undefined, flow.ctx); - expect(status.isError).toBe(false); - expect(status.content[0].text).toBe("subagents=idle; processes=idle"); - expect(flow.messages.some((message) => message.content.includes("worker stopped"))).toBe(false); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("does not checkpoint after every goal is closed", async () => { - const flow = setup(["Ready"]); - try { - await flow.hooks.get("session_start")({}, flow.ctx); - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: produce report\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - flow.eventBus.emit("subagent:async-complete", { runId: "worker-1" }); - - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [x] goal: produce report\n"); - await flow.hooks.get("turn_end")({}, flow.ctx); - for (let turn = 0; turn < 8; turn++) await flow.hooks.get("turn_end")({}, flow.ctx); - await flow.hooks.get("agent_settled")({}, flow.ctx); - - expect(flow.rpcRequests).toHaveLength(1); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("records a worker that completes before its launch RPC reply as stopped", async () => { - const flow = setup(["Ready"], [], undefined, 0, true); - try { - await flow.hooks.get("session_start")({}, flow.ctx); - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: finish quickly\n"); - - await flow.hooks.get("agent_settled")({}, flow.ctx); - - expect(flow.entries.at(-1)?.data).toMatchObject({ workerRunId: "worker-1", workerPending: false }); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("resumes the retained supervisor and lets the coordinator mechanically sign off", async () => { - const flow = setup(["Ready"]); - try { - await flow.hooks.get("session_start")({}, flow.ctx); - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: produce report\n - discriminator: report.txt contains PASS\n - evidence:\n - report.txt: `PASS`\n\n## Log\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - expect(flow.rpcRequests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", context: "fork" } }); - flow.eventBus.emit("subagent:async-complete", { runId: "worker-1", results: [{ success: true }] }); - - const resumed = await flow.tools.get("GuideGoalWorker").execute("", { instruction: "Verify report.txt." }, undefined, undefined, flow.ctx); - expect(resumed.isError).toBe(false); - expect(flow.rpcRequests[1]).toMatchObject({ method: "resume", params: { id: "worker-1", message: expect.stringContaining("Verify report.txt.") } }); - expect(flow.entries.at(-1)?.data).toMatchObject({ workerRunId: "worker-2", workerPending: true }); - - flow.eventBus.emit("subagent:async-complete", { runId: "worker-2", results: [{ success: true }] }); - writeSupervisorApproval(flow, "produce report"); - const signoff = await flow.tools.get("CompleteGoal").execute("", { goal: "produce report" }, undefined, undefined, flow.ctx); - expect(signoff.isError).toBe(false); - expect(readFileSync(planPath, "utf-8")).toContain("1. [x] goal: produce report"); - expect(readFileSync(planPath, "utf-8")).toContain("mechanically signed off \"produce report\" after matching supervisor approval"); - - await flow.hooks.get("session_start")({}, flow.ctx); - await flow.tools.get("GuideGoalWorker").execute("", { instruction: "Report current status." }, undefined, undefined, flow.ctx); - expect(flow.rpcRequests.at(-1)).toMatchObject({ method: "resume", params: { id: "worker-2", message: expect.stringContaining("Report current status.") } }); - expect(existsSync(approvalPath(flow.cwd, "session-a", "produce report"))).toBe(false); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("blocks main implementation while allowing supervisor inspection, control, and sign-off", async () => { - const flow = setup(["Ready"]); - try { - await flow.hooks.get("session_start")({}, flow.ctx); - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n\n## Log\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - - const edit = await flow.hooks.get("tool_call")({ toolName: "edit", input: { path: "README.md" } }, flow.ctx); - const write = await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "README.md" } }, flow.ctx); - const shellWrite = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "printf changed > README.md" } }, flow.ctx); - const findDelete = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "find . -delete" } }, flow.ctx); - const gitOutput = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "git diff --output=README.md" } }, flow.ctx); - const gitBranch = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "git branch new-name" } }, flow.ctx); - const sibling = await flow.hooks.get("tool_call")({ toolName: "subagent", input: { agent: "worker" } }, flow.ctx); - const status = await flow.hooks.get("tool_call")({ toolName: "subagent", input: { action: "status", view: "fleet" } }, flow.ctx); - const inspect = await flow.hooks.get("tool_call")({ toolName: "read", input: { path: "README.md" } }, flow.ctx); - const verify = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "git status && npm test && npm run typecheck && npm run lint" } }, flow.ctx); - const work = await flow.tools.get("CheckGoalWork").execute("", {}, undefined, undefined, flow.ctx); - const guide = await flow.tools.get("GuideGoalWorker").execute("", { instruction: "Save the verification output." }, undefined, undefined, flow.ctx); - flow.eventBus.emit("subagent:async-complete", { runId: "worker-1", results: [{ success: true }] }); - writeSupervisorApproval(flow, "make the output"); - const signoff = await flow.tools.get("CompleteGoal").execute("", { goal: "make the output" }, undefined, undefined, flow.ctx); - - expect(edit?.block).toBe(true); - expect(write?.block).toBe(true); - expect(shellWrite?.block).toBe(true); - expect(findDelete?.block).toBe(true); - expect(gitOutput?.block).toBe(true); - expect(gitBranch?.block).toBe(true); - expect(sibling?.block).toBe(true); - expect(status).toBeUndefined(); - expect(inspect).toBeUndefined(); - expect(verify).toBeUndefined(); - expect(work.isError).toBe(false); - expect(guide.isError).toBe(false); - expect(signoff.isError).toBe(false); - expect(readFileSync(planPath, "utf-8")).toContain("1. [x] goal: make the output"); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("fails closed without a matching supervisor approval checkpoint", async () => { - const flow = setup(["Ready"]); - try { - await flow.hooks.get("session_start")({}, flow.ctx); - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n - evidence: verify.log: PASS\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - flow.eventBus.emit("subagent:async-complete", { runId: "worker-1", results: [{ success: true }] }); - - const missing = await flow.tools.get("CompleteGoal").execute("", { goal: "make the output" }, undefined, undefined, flow.ctx); - expect(missing.isError).toBe(true); - expect(missing.content[0].text).toContain("no matching supervisor approval checkpoint"); - - writeSupervisorApproval(flow, "make the output"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n - evidence: verify.log: PASS after rerun\n"); - const stale = await flow.tools.get("CompleteGoal").execute("", { goal: "make the output" }, undefined, undefined, flow.ctx); - expect(stale.isError).toBe(true); - expect(stale.content[0].text).toContain("no matching supervisor approval checkpoint"); - - writeSupervisorApproval(flow, "make the output"); - writeFileSync(join(flow.cwd, "uncommitted.txt"), "dirty\n"); - const dirty = await flow.tools.get("CompleteGoal").execute("", { goal: "make the output" }, undefined, undefined, flow.ctx); - expect(dirty.isError).toBe(true); - expect(dirty.content[0].text).toContain("worktree is dirty"); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("labels live goals supervising and all-done goals complete", async () => { - const flow = setup(["Ready"]); - try { - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n"); - await flow.hooks.get("agent_settled")({}, flow.ctx); - await flow.hooks.get("turn_end")({}, flow.ctx); - expect(flow.widgets.at(-1)).toEqual(["▸ supervising… make the output"]); - expect(flow.statuses.at(-1)).toContain("supervising…"); - - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [x] goal: make the output\n"); - await flow.hooks.get("turn_end")({}, flow.ctx); - expect(flow.widgets.at(-1)).toEqual(["✔ complete"]); - expect(flow.statuses.at(-1)).toContain("1/1 goals · complete"); - expect(flow.statuses.at(-1)).not.toContain("supervising…"); - } finally { - rmSync(flow.cwd, { recursive: true, force: true }); - } - }); - - it("does not install the supervisor gate in a worker child", () => { - expect(isSupervisorProcess(false)).toBe(true); - expect(isSupervisorProcess(true)).toBe(false); - }); - - it("gives the agent a planning snapshot and blocks work routes", async () => { - const flow = setup([]); - try { - await flow.commands.get("goals").handler("objective", flow.ctx); - const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); - expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning" }); - await flow.hooks.get("session_start")({}, flow.ctx); - const snapshot = await flow.hooks.get("before_agent_start")({}, flow.ctx); - expect(snapshot.message.content).toContain("[PLANNING MODE]"); - expect(snapshot.message.content).toContain(planPath); - - const writePlan = await flow.hooks.get("tool_call")({ toolName: "write", input: { path: planPath } }, flow.ctx); - const writeCode = await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "README.md" } }, flow.ctx); - const readShell = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "pwd && ls && git log" } }, flow.ctx); - const changeDirectoryThenRead = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "cd . && ls -la" } }, flow.ctx); - const pipeShell = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "ls | head" } }, flow.ctx); - const pythonWrite = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "python -c \"open('README.md', 'w')\"" } }, flow.ctx); - const signoff = await flow.tools.get("CompleteGoal").execute("", { goal: "work" }, undefined, undefined, flow.ctx); - await flow.hooks.get("session_compact")({}, flow.ctx); - const compacted = await flow.hooks.get("context")({ messages: [] }, flow.ctx); - - expect(writePlan).toBeUndefined(); - expect(writeCode?.block).toBe(true); - expect(readShell).toBeUndefined(); - expect(changeDirectoryThenRead).toBeUndefined(); - expect(pipeShell?.block).toBe(true); - expect(pythonWrite?.block).toBe(true); - expect(signoff.isError).toBe(true); - expect(compacted.messages.at(-1).content[0].text).toContain("[PLANNING MODE]"); + const goal = "make the file"; + const plan = readFileSync(planPath, "utf8"); + const block = goalBlock(plan, goal)!; + const repository = repositoryState(flow.cwd); + const approvalId = (flow.entries.at(-1)?.data as { approvalId: string }).approvalId; + writeApproval(approvalPath(flow.cwd, "session-a", goal), { + version: 2, verdict: "accept", approvalId, goal, planPath, + goalBlockHash: hashGoalBlock(block), repoRoot: repository.repoRoot, + head: repository.head, tree: repository.tree, cleanWorktree: true, + inspected: { plan: true, repository: true, evidence: true, verifyOutput: true }, + supervisor: { sessionId: "supervisor", runId: null }, timestamp: new Date().toISOString(), + }); + const signed = await flow.tools.get("CompleteGoal").execute("id", { goal }, undefined, undefined, flow.ctx); + expect(signed.isError).toBe(false); + expect(readFileSync(planPath, "utf8")).toContain("1. [x] goal: make the file"); } finally { rmSync(flow.cwd, { recursive: true, force: true }); } }); }); + +describe("process role", () => { + it("keeps subagent children and visible supervisors out of the worker extension", () => { + expect(isMainSession(false)).toBe(true); + expect(isMainSession(true)).toBe(false); + }); +}); diff --git a/test/herdr.test.ts b/test/herdr.test.ts new file mode 100644 index 0000000..36f0cf8 --- /dev/null +++ b/test/herdr.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { supervisorCommand } from "../src/herdr.js"; + +describe("supervisor pane command", () => { + it("forks the planning session with an explicit supervisor role and model", () => { + const command = supervisorCommand({ + cwd: "/repo", + sourceSessionFile: "/sessions/worker.jsonl", + workerSessionId: "worker-12345678", + planPath: "/repo/.pi/plan/worker-v1.md", + approvalId: "approval-1", + extensionPath: "/repo/src/index.ts", + model: "provider/supervisor", + }); + expect(command).toContain("'PI_GOALS_ROLE=supervisor'"); + expect(command).toContain("'pi' '--no-extensions' '-e' '/repo/src/index.ts'"); + expect(command).toContain("'-e' 'npm:pi-intercom' '-e' 'npm:@wassname2/pi-supervise'"); + expect(command).toContain("'--fork' '/sessions/worker.jsonl'"); + expect(command).toContain("'--model' 'provider/supervisor'"); + expect(command).not.toContain("pi-subagents"); + }); +}); diff --git a/test/package-agent.test.ts b/test/package-agent.test.ts index 2a5b607..63ba02e 100644 --- a/test/package-agent.test.ts +++ b/test/package-agent.test.ts @@ -4,20 +4,15 @@ import { describe, expect, it } from "vitest"; interface PackageManifest { files: string[]; - pi: { extensions: string[]; subagents: { agents: string[] } }; + pi: { extensions: string[]; subagents?: unknown }; } describe("package manifest", () => { - it("includes the versioned foreground worker for child-process discovery", () => { + it("includes the extension without registering a packaged subagent", () => { const root = resolve(import.meta.dirname, ".."); const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as PackageManifest; - const worker = readFileSync(resolve(root, "agents/pi-goals-worker-v1.md"), "utf8"); - expect(manifest.files).toEqual(["src", "agents", "README.md"]); + expect(manifest.files).toEqual(["src", "README.md"]); expect(manifest.pi.extensions).toEqual(["./src/index.ts"]); - expect(manifest.pi.subagents.agents).toEqual(["./agents"]); - expect(worker).toContain("name: pi-goals-worker-v1"); - expect(worker).toContain("async: false"); - expect(worker).toContain("tools: read, grep, find, ls, bash, edit, write"); - expect(worker).toContain("excludeTools: contact_supervisor, subagent"); + expect(manifest.pi.subagents).toBeUndefined(); }); }); diff --git a/test/prompts.test.ts b/test/prompts.test.ts index 66da8ac..0646de0 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { completeGoalDescription, planDrafting, planningState, resync } from "../src/prompts.js"; @@ -25,9 +23,8 @@ describe("planning prompt", () => { expect(planDrafting).toContain("Take it from the original request, not from your implementation plan"); expect(planDrafting).toContain("Future work may not defer any artifact or action named there"); expect(resync("plan", ".pi/plan/test.md", "Compacted.")).toContain("amend the plan rather than preserving an obsolete decision"); - const worker = readFileSync(resolve(import.meta.dirname, "../agents/pi-goals-worker-v1.md"), "utf8"); - expect(worker).toContain("human's latest message outranks the plan"); - expect(worker).toContain("retained goal supervisor owns direction and approval"); - expect(completeGoalDescription).toContain("approval checkpoint only after it inspected"); + expect(resync("plan", ".pi/plan/test.md", "Compacted.")).toContain("implementation worker"); + expect(completeGoalDescription).toContain("visible supervisor"); + expect(completeGoalDescription).toContain("stopped worker view with no active work"); }); }); diff --git a/test/supervisor-runtime.test.ts b/test/supervisor-runtime.test.ts deleted file mode 100644 index 1227020..0000000 --- a/test/supervisor-runtime.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { approvalPath, readApproval } from "../src/approval.js"; -import supervisorRuntime from "../src/supervisor-runtime.js"; -import { GOAL_WORKER_AGENT } from "../src/worker.js"; - -class Events { - private handlers = new Map void>>(); - - on(event: string, handler: (data: unknown) => void): () => void { - const handlers = this.handlers.get(event) ?? new Set(); - handlers.add(handler); - this.handlers.set(event, handlers); - return () => handlers.delete(handler); - } - - emit(event: string, data: unknown): void { - for (const handler of [...(this.handlers.get(event) ?? [])]) handler(data); - } -} - -function setup() { - const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-")); - writeFileSync(join(cwd, "README.md"), "test\n"); - execFileSync("git", ["init", "-q"], { cwd }); - execFileSync("git", ["add", "README.md"], { cwd }); - execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-qm", "initial"], { cwd }); - const hooks = new Map(); - const tools = new Map(); - const entries: any[] = []; - const branch: any[] = []; - const compactCalls: any[] = []; - const events = new Events(); - events.on("processes:request:list", (raw) => { - (raw as { reply(value: object[]): void }).reply([]); - }); - const ctx = { - cwd, - sessionManager: { getSessionId: () => "supervisor-session", getEntries: () => entries, getBranch: () => branch }, - compact: (options: any) => compactCalls.push(options), - ui: { notify() {} }, - }; - const pi = { - events, - on: (name: string, handler: any) => hooks.set(name, handler), - appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }), - registerTool: (tool: any) => tools.set(tool.name, tool), - }; - supervisorRuntime(pi as any); - return { cwd, ctx, events, hooks, tools, entries, branch, compactCalls }; -} - -describe("supervisor-only runtime", () => { - it("blocks direct supervisor writes", async () => { - const runtime = setup(); - try { - expect((await runtime.hooks.get("tool_call")({ toolName: "edit", input: { path: "README.md" } }, runtime.ctx))?.block).toBe(true); - expect((await runtime.hooks.get("tool_call")({ toolName: "bash", input: { command: "git branch new-name" } }, runtime.ctx))?.block).toBe(true); - expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "wrong", input: { agent: "goal-worker", task: "work", async: false, context: "fork" } }, runtime.ctx))?.block).toBe(true); - expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "implicit", input: { agent: GOAL_WORKER_AGENT, task: "work" } }, runtime.ctx))?.block).toBe(true); - expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "model", input: { agent: GOAL_WORKER_AGENT, task: "work", async: false, context: "fork", model: "other/model" } }, runtime.ctx))?.block).toBe(true); - expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "override", input: { agent: GOAL_WORKER_AGENT, task: "work", async: false, context: "fork", worktree: true } }, runtime.ctx))?.block).toBe(true); - expect(await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "worker", input: { agent: GOAL_WORKER_AGENT, task: "work", async: false, context: "fork" } }, runtime.ctx)).toBeUndefined(); - expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "duplicate", input: { agent: GOAL_WORKER_AGENT, task: "work", async: false, context: "fork" } }, runtime.ctx))?.block).toBe(true); - await runtime.hooks.get("tool_result")({ toolName: "subagent", toolCallId: "worker", isError: true }, runtime.ctx); - expect(await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "stale", input: { agent: GOAL_WORKER_AGENT, task: "work", async: false, context: "fork" } }, runtime.ctx)).toBeUndefined(); - await runtime.hooks.get("turn_start")({ turnIndex: 1 }, runtime.ctx); - expect(await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "recovered", input: { agent: GOAL_WORKER_AGENT, task: "work", async: false, context: "fork" } }, runtime.ctx)).toBeUndefined(); - await runtime.hooks.get("tool_result")({ toolName: "subagent", toolCallId: "recovered", isError: true }, runtime.ctx); - expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "async", input: { agent: GOAL_WORKER_AGENT, task: "work", async: true, context: "fork" } }, runtime.ctx))?.block).toBe(true); - expect((await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "resume", input: { action: "resume", id: "nested-1" } }, runtime.ctx))?.block).toBe(true); - expect((await runtime.hooks.get("tool_call")({ toolName: "bash", input: { command: "git status && npm test" } }, runtime.ctx))).toBeUndefined(); - } finally { - rmSync(runtime.cwd, { recursive: true, force: true }); - } - }); - - it("compacts a requested fork before the first supervisor turn", async () => { - const previous = process.env.PI_SUBAGENT_EXTENSION_BINDINGS; - process.env.PI_SUBAGENT_EXTENSION_BINDINGS = JSON.stringify({ "pi-goals/1": { compactPlanning: true, workerModel: "provider/worker" } }); - const runtime = setup(); - try { - await runtime.hooks.get("session_start")({}, runtime.ctx); - expect(await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "wrong-model", input: { agent: GOAL_WORKER_AGENT, task: "work", async: false, context: "fork", model: "other/model" } }, runtime.ctx)).toMatchObject({ block: true }); - expect(await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "worker", input: { agent: GOAL_WORKER_AGENT, task: "work", async: false, context: "fork", model: "provider/worker" } }, runtime.ctx)).toBeUndefined(); - await runtime.hooks.get("tool_result")({ toolName: "subagent", toolCallId: "worker", isError: true }, runtime.ctx); - expect(runtime.compactCalls).toHaveLength(1); - const replacement = await runtime.hooks.get("session_before_compact")({ - preparation: { firstKeptEntryId: "old", tokensBefore: 70_000 }, - branchEntries: [{ id: "recent", type: "message", message: { role: "assistant" } }], - }, runtime.ctx); - expect(replacement.compaction).toMatchObject({ firstKeptEntryId: "recent", tokensBefore: 70_000 }); - runtime.compactCalls[0].onComplete({}); - await runtime.hooks.get("before_agent_start")({}, runtime.ctx); - expect(runtime.entries).toContainEqual({ type: "custom", customType: "pi-goals-supervisor-compacted", data: { version: 1 } }); - } finally { - if (previous === undefined) delete process.env.PI_SUBAGENT_EXTENSION_BINDINGS; - else process.env.PI_SUBAGENT_EXTENSION_BINDINGS = previous; - rmSync(runtime.cwd, { recursive: true, force: true }); - } - }); - - it("writes an approval only after inspecting the plan and confirming a clean worktree at a commit", async () => { - const runtime = setup(); - const previousRunId = process.env.PI_SUBAGENT_RUN_ID; - process.env.PI_SUBAGENT_RUN_ID = "supervisor-run"; - try { - const planPath = join(runtime.cwd, ".pi/plan/session-a-v1.md"); - mkdirSync(join(runtime.cwd, ".pi/plan"), { recursive: true }); - writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: ship it\n - evidence: verify.log: PASS\n"); - const checkpoint = approvalPath(runtime.cwd, "main-session", "ship it"); - const params = { - approvalId: "review-1", - goal: "ship it", - planPath, - checkpointPath: checkpoint, - inspectedPlan: true, - inspectedRepository: true, - inspectedEvidence: true, - inspectedVerifyOutput: true, - }; - await runtime.hooks.get("turn_start")({ turnIndex: 0 }, runtime.ctx); - await runtime.hooks.get("tool_call")({ toolName: "subagent", toolCallId: "worker", input: { agent: GOAL_WORKER_AGENT, task: "work", async: false, context: "fork" } }, runtime.ctx); - expect((await runtime.tools.get("ApproveGoal").execute("", params, undefined, undefined, runtime.ctx)).isError).toBe(true); - await runtime.hooks.get("tool_result")({ toolName: "subagent", toolCallId: "worker", isError: false }, runtime.ctx); - expect((await runtime.tools.get("ApproveGoal").execute("", params, undefined, undefined, runtime.ctx)).isError).toBe(true); - await runtime.hooks.get("turn_start")({ turnIndex: 1 }, runtime.ctx); - runtime.branch.push({ - type: "message", - message: { role: "assistant", content: [ - { type: "toolCall", name: "ApproveGoal", arguments: params }, - { type: "toolCall", name: "subagent", arguments: { agent: GOAL_WORKER_AGENT, task: "more work", async: false, context: "fork" } }, - ] }, - }); - expect((await runtime.tools.get("ApproveGoal").execute("", params, undefined, undefined, runtime.ctx)).isError).toBe(true); - await runtime.hooks.get("turn_start")({ turnIndex: 2 }, runtime.ctx); - runtime.branch.push({ type: "message", message: { role: "assistant", content: [{ type: "toolCall", name: "ApproveGoal", arguments: params }] } }); - const accepted = await runtime.tools.get("ApproveGoal").execute("", params, undefined, undefined, runtime.ctx); - - expect(runtime.tools.get("ApproveGoal").executionMode).toBe("sequential"); - expect(accepted.isError).toBe(false); - expect(readApproval(checkpoint)).toMatchObject({ version: 2, approvalId: "review-1", goal: "ship it", supervisor: { sessionId: "supervisor-session", runId: "supervisor-run" } }); - expect(readFileSync(checkpoint, "utf8")).toContain('"goalBlockHash"'); - } finally { - if (previousRunId === undefined) delete process.env.PI_SUBAGENT_RUN_ID; - else process.env.PI_SUBAGENT_RUN_ID = previousRunId; - rmSync(runtime.cwd, { recursive: true, force: true }); - } - }); -}); diff --git a/test/supervisor-session.test.ts b/test/supervisor-session.test.ts new file mode 100644 index 0000000..11b809c --- /dev/null +++ b/test/supervisor-session.test.ts @@ -0,0 +1,110 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { approvalPath } from "../src/approval.js"; +import { registerVisibleSupervisor } from "../src/supervisor-session.js"; + +function setup(cwd: string, planPath: string) { + vi.stubEnv("PI_GOALS_WORKER_ID", "worker-session"); + vi.stubEnv("PI_GOALS_OWNER_SESSION_ID", "worker-session"); + vi.stubEnv("PI_GOALS_PLAN_PATH", planPath); + vi.stubEnv("PI_GOALS_APPROVAL_ID", "approval-1"); + const hooks = new Map(); + const tools = new Map(); + const entries: any[] = []; + const sent: Array<{ content: string; options?: unknown }> = []; + let branch: any[] = []; + const ctx = { + cwd, + getSystemPrompt: () => "base", + getContextUsage: () => ({ tokens: 10 }), + compact: vi.fn((options: any) => options.onComplete()), + sessionManager: { + getEntries: () => entries, + getBranch: () => branch, + getSessionId: () => "supervisor-session", + }, + ui: { notify: vi.fn() }, + }; + const pi = { + on: (name: string, handler: any) => hooks.set(name, handler), + registerTool: (tool: any) => tools.set(tool.name, tool), + appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }), + getCommands: () => [{ name: "supervise", source: "extension" }], + sendUserMessage: (content: string, options?: unknown) => sent.push({ content, options }), + }; + registerVisibleSupervisor(pi as unknown as ExtensionAPI); + return { branch: (value: any[]) => { branch = value; }, ctx, entries, hooks, sent, tools }; +} + +afterEach(() => vi.unstubAllEnvs()); + +describe("visible supervisor session", () => { + it("compacts the fork before pairing it with the worker", async () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-")); + try { + const runtime = setup(cwd, join(cwd, ".pi/plan/worker-v1.md")); + await runtime.hooks.get("session_start")({}, runtime.ctx); + expect(runtime.ctx.compact).toHaveBeenCalledOnce(); + expect(runtime.entries.at(-1)).toMatchObject({ customType: "pi-goals-visible-supervisor-v1" }); + expect(runtime.sent).toEqual([{ + content: `/supervise @worker-session ${join(cwd, ".pi/plan/worker-v1.md")}`, + options: { expandPromptTemplates: true }, + }]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("records approval only from a stopped view with evidence and no active work", async () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-")); + try { + writeFileSync(join(cwd, ".gitignore"), ".pi/\n"); + execFileSync("git", ["init", "-q"], { cwd }); + execFileSync("git", ["add", ".gitignore"], { cwd }); + execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-qm", "initial"], { cwd }); + const planPath = join(cwd, ".pi/plan/worker-v1.md"); + execFileSync("mkdir", ["-p", join(cwd, ".pi/plan")]); + writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n - discriminator: output exists\n - evidence:\n - `result.txt`: contains ok\n\n## Log\n"); + const runtime = setup(cwd, planPath); + runtime.branch([{ + type: "message", + message: { role: "user", content: [{ type: "text", text: "The worker stopped.\n\ntool calls with no result: none\nchild pi processes still running: none" }] }, + }]); + const approved = await runtime.tools.get("ApproveGoal").execute("id", { + goal: "make the file", + inspectedPlan: true, + inspectedRepository: true, + inspectedEvidence: true, + inspectedVerifyOutput: true, + }, undefined, undefined, runtime.ctx); + expect(approved.isError).toBe(false); + expect(existsSync(approvalPath(cwd, "worker-session", "make the file"))).toBe(true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("rejects approval while the worker view has an unfinished tool call", async () => { + const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-")); + try { + const planPath = join(cwd, "plan.md"); + writeFileSync(planPath, "1. [ ] goal: wait\n - evidence:\n - result\n"); + const runtime = setup(cwd, planPath); + runtime.branch([{ + type: "message", + message: { role: "user", content: [{ type: "text", text: "The worker stopped.\n\ntool calls with no result: bash\nchild pi processes still running: none" }] }, + }]); + const rejected = await runtime.tools.get("ApproveGoal").execute("id", { + goal: "wait", inspectedPlan: true, inspectedRepository: true, inspectedEvidence: true, inspectedVerifyOutput: true, + }, undefined, undefined, runtime.ctx); + expect(rejected.isError).toBe(true); + expect(rejected.content[0].text).toContain("bash"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/test/worker.test.ts b/test/worker.test.ts deleted file mode 100644 index d22482d..0000000 --- a/test/worker.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - GOAL_WORKER_AGENT, - processWorkState, - registerGoalSupervisor, - resumeGoalSupervisor, - startGoalSupervisor, - steerGoalSupervisor, - stopGoalSupervisor, - subagentWorkState, - supervisorSystemPrompt, - terminalSteerError, -} from "../src/worker.js"; - -class Events { - private handlers = new Map void>>(); - - on(event: string, handler: (data: unknown) => void): () => void { - const handlers = this.handlers.get(event) ?? new Set(); - handlers.add(handler); - this.handlers.set(event, handlers); - return () => handlers.delete(handler); - } - - emit(event: string, data: unknown): void { - for (const handler of [...(this.handlers.get(event) ?? [])]) handler(data); - } -} - -function replyToRpc(events: Events, inspect: (request: any) => object): void { - events.on("subagents:rpc:v1:request", (raw) => { - const request = raw as any; - events.emit(`subagents:rpc:v1:reply:${request.requestId}`, { success: true, data: inspect(request) }); - }); -} - -describe("goal hierarchy registration", () => { - it("registers the supervisor contract and names its packaged foreground worker", () => { - const events = new Events(); - const definitions = new Map>(); - events.on("pi-subagents:runtime-agent-register:v1", (raw) => { - const request = raw as { name: string; definition: Record; result?: unknown }; - definitions.set(request.name, request.definition); - request.result = { ok: true, registration: { dispose() {} } }; - }); - - registerGoalSupervisor(events, "provider/supervisor"); - - const supervisor = definitions.get("goal-supervisor"); - expect(supervisor).toMatchObject({ - model: "provider/supervisor", - defaultContext: "fork", - defaultAsync: true, - thinking: "low", - inheritProjectContext: false, - inheritGlobalContext: false, - inheritSkills: false, - defaultProgress: true, - allowNestedSubagents: true, - tools: ["read", "grep", "find", "ls", "bash", "subagent", "ApproveGoal"], - }); - expect(supervisor?.subagentOnlyExtensions).toEqual([expect.stringContaining("supervisor-runtime.ts")]); - expect(supervisorSystemPrompt).toContain(GOAL_WORKER_AGENT); - expect(supervisorSystemPrompt).toContain("async:false"); - expect(supervisorSystemPrompt).toContain("ApproveGoal"); - expect(definitions.has(GOAL_WORKER_AGENT)).toBe(false); - }); -}); - -describe("goal worker RPC", () => { - it("starts from a fork, resumes retained context, and steers a live run", async () => { - const events = new Events(); - const requests: any[] = []; - replyToRpc(events, (request) => { - requests.push(request); - return { text: "ok", details: { asyncId: `run-${requests.length}` } }; - }); - - await expect(startGoalSupervisor(events, "/repo", "start", true, "provider/worker")).resolves.toBe("run-1"); - await expect(resumeGoalSupervisor(events, "run-1", "continue")).resolves.toBe("run-2"); - await steerGoalSupervisor(events, "run-2", "report"); - await stopGoalSupervisor(events, "run-2"); - - expect(requests[0]).toMatchObject({ method: "spawn", params: { agent: "goal-supervisor", cwd: "/repo", context: "fork", async: true, extensionBindings: { "pi-goals/1": { compactPlanning: true, workerModel: "provider/worker" } } } }); - expect(requests[1]).toMatchObject({ method: "resume", params: { id: "run-1", message: "continue" } }); - expect(requests[2]).toMatchObject({ method: "steer", params: { id: "run-2", message: "report", mode: "steer" } }); - expect(requests[3]).toMatchObject({ method: "stop", params: { id: "run-2" } }); - expect(terminalSteerError(new Error("Async run is completed"))).toBe(true); - }); - - it("reports active, idle, and incomplete status snapshots", async () => { - for (const [snapshot, expected] of [ - [{ kind: "pi-subagents.async-status-snapshot", version: 1, omitted: { runs: 0, children: 0, byteLimitExceeded: false }, runs: [{ id: "worker", state: "running" }] }, "active"], - [{ kind: "pi-subagents.async-status-snapshot", version: 1, omitted: { runs: 0, children: 0, byteLimitExceeded: false }, runs: [{ id: "worker", state: "complete", children: [{ id: "nested", state: "running" }] }] }, "active"], - [{ kind: "pi-subagents.async-status-snapshot", version: 1, omitted: { runs: 0, children: 0, byteLimitExceeded: false }, runs: [{ id: "worker", state: "complete" }] }, "idle"], - [{ kind: "pi-subagents.async-status-snapshot", version: 1, omitted: { runs: 1, children: 0, byteLimitExceeded: false }, runs: [] }, "unknown"], - ] as const) { - const events = new Events(); - replyToRpc(events, () => ({ text: "status", asyncSnapshot: snapshot })); - await expect(subagentWorkState(events)).resolves.toBe(expected); - } - }); -}); - -describe("managed process status", () => { - it("does not treat a missing process extension as idle", () => { - expect(processWorkState(new Events())).toBe("unknown"); - }); - - it("uses pi-processes live statuses", () => { - for (const [status, expected] of [["finished", "idle"], ["running", "active"], ["terminate_timeout", "active"], ["new-status", "active"]] as const) { - const events = new Events(); - events.on("processes:request:list", (raw) => { - (raw as { reply(value: object[]): void }).reply([{ status }]); - }); - expect(processWorkState(events)).toBe(expected); - } - }); -});