Make goals clear a silent reset and show mode-specific actions

This commit is contained in:
wassname2
2026-09-11 15:32:47 +08:00
parent 82b739bb0b
commit ec1cc34f73
4 changed files with 135 additions and 38 deletions
+2 -2
View File
@@ -144,13 +144,13 @@ pi -e ./src/index.ts
/goals
```
`/goals` opens the action menu. New plan enters plan mode and starts a conversation;
`/goals` shows actions for the current mode. Drafts offer Edit, Discuss and Approve. Quit (`exit` or `clear`) backs up the plan beside the original as a `.bak` file, removes this session's goal check-in, and clears goal state without a model call. Worker processes are unchanged; manage them through `/subagents`. New creates a separate draft without overwriting earlier plans.
## Context delivery
Startup and successful compaction mark the plan for a fresh read at the next ordinary prompt (`before_agent_start`). Upkeep becomes due after eight unchanged turns, but waits for that same prompt boundary. A full plan refresh replaces pending upkeep; edits, pause, exit and session navigation invalidate obsolete reminders. Failed or cancelled compaction does not schedule another refresh or consume pending upkeep. Missing plans are retried without discarding progress.
This is deliberately passive on Pi 0.85.1: tool-loop continuations, overflow retries and already-queued user messages keep Pi's existing role and compacted context, without an extra model turn just to repeat the plan. They do **not** receive a newly read plan until ordinary prompt preparation. Pi's `triggerTurn: false` mid-run path can save a message absent from the live request snapshot; steering can instead force an unwanted turn. We use neither path for upkeep. Passive pause/exit notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed.
This is deliberately passive on Pi 0.85.1: tool-loop continuations, overflow retries and already-queued user messages keep Pi's existing role and compacted context, without an extra model turn just to repeat the plan. They do **not** receive a newly read plan until ordinary prompt preparation. Pi's `triggerTurn: false` mid-run path can save a message absent from the live request snapshot; steering can instead force an unwanted turn. We use neither path for upkeep. Passive pause notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed. Quit sends no model message.
## Prompts
+50 -23
View File
@@ -1,8 +1,9 @@
// Pi/OpenAI: Plan and supervise in the main chat; delegate implementation to a visible worker.
import { createHash } from "node:crypto";
import { type FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, type FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { CronStorage } from "pi-schedule-prompt/src/storage.js";
import { Type } from "typebox";
import { foldPlan, GOAL_LINE } from "./plan.js";
import { planViews } from "./plan-view.js";
@@ -201,17 +202,17 @@ export default function mainSupervisor(pi: ExtensionAPI) {
generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${soloNotice(state.plan!)}`);
}
const help = "/goals new [initial idea] | review | ready | status | stop | resume | solo | exit | attach <plan.md> [solo] | model <model>\n/subagents opens the worker controls. Stop/exit pause this plan locally; worker termination must be confirmed through subagent_kill or its pane. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_prompt job; plan-change reviews are the plan-watcher event hook.";
async function ready(ctx: ExtensionContext, menu: boolean) {
const help = "/goals new [initial idea] | edit | discuss | review | ready | status | stop | resume | solo | attach <plan.md> [solo] | model <model> | quit (exit/clear)\n/subagents opens the worker controls. Stop pauses work. Quit/exit/clear backs up the plan and clears goal state without a model call; worker processes are unchanged. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_prompt job; plan-change reviews are the plan-watcher event hook.";
async function ready(ctx: ExtensionContext, menu: boolean, edit = false) {
if (state.mode !== "planning") { ctx.ui.notify("Ready applies to a draft; use status or resume.", "warning"); return; }
const text = planText();
const items = goals(text);
if (!items.length || new Set(items.map((g) => key(g.subject))).size !== items.length) {
if (!edit && (!items.length || new Set(items.map((g) => key(g.subject))).size !== items.length)) {
ctx.ui.notify("Write a plan with distinct '- [ ] goal: ...' subjects before Ready.", "warning"); return;
}
const stamp = generation;
if (menu) {
const choice = await ctx.ui.select(`Review ${state.plan}`, ["Ready", "Discuss", "Edit", "Cancel"]);
if (menu || edit) {
const choice = edit ? "Edit" : await ctx.ui.select(`Review ${state.plan}`, ["Ready", "Discuss", "Edit", "Cancel"]);
if (stamp !== generation || digest(planText()) !== digest(text)) { ctx.ui.notify("Plan changed during review. Review it again.", "warning"); return; }
if (choice === "Discuss") { send(discuss); return; }
if (choice === "Edit") {
@@ -313,14 +314,24 @@ export default function mainSupervisor(pi: ExtensionAPI) {
});
pi.registerCommand("goals", {
description: "Goal plan actions: new, review, ready, status, stop, resume, solo, attach, model, exit",
getArgumentCompletions: (prefix) => ["new", "review", "ready", "status", "stop", "resume", "solo", "attach", "model", "exit", "help"].filter((verb) => verb.startsWith(prefix)).map((verb) => ({ value: verb, label: verb })),
description: "Goal plan actions: new, edit, discuss, review, ready, status, stop, resume, solo, attach, model, quit (exit/clear)",
getArgumentCompletions: (prefix) => ["new", "attach", "edit", "discuss", "review", "ready", "status", "stop", "resume", "solo", "model", "help", "exit", "clear", "quit"].filter((verb) => verb.startsWith(prefix)).map((verb) => ({ value: verb, label: verb })),
handler: async (args, ctx) => {
try {
if (state.child) { ctx.ui.notify("This is the delegated worker. Goal approval belongs to its parent.", "info"); return; }
let command = args.trim();
if (!command) {
const actions = ["status — Show current plan", "new — New plan", "attach — Open an existing plan", "review — Review current plan", "ready — Approve draft", "stop — Pause work", "resume — Continue paused work", "solo — Work in this session", "model — Set worker model", "exit — Leave goal mode", "help — Show commands"];
const actions = [
...({
chat: ["new — New plan", "attach — Open plan…"],
planning: ["edit — Edit plan…", "discuss — Discuss changes to the plan", "ready — Approve draft"],
supervising: ["review — Check progress", "stop — Pause work"],
paused: ["resume — Resume work"],
solo: ["stop — Pause work"],
})[state.mode],
...(["planning", "supervising", "paused"].includes(state.mode) ? ["model — Settings: worker model"] : []),
"help — Show commands", "quit — Exit and clear goals (back up plan)",
];
const before = generation;
const choice = await ctx.ui.select("Goal plan actions", actions);
if (!choice || before !== generation) return;
@@ -331,6 +342,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
command += ` ${value.trim()}`;
}
}
if (command === "quit" || command === "clear") command = "exit";
if (command === "help") { ctx.ui.notify(help, "info"); return; }
if (command === "status") {
refresh(ctx);
@@ -346,8 +358,12 @@ export default function mainSupervisor(pi: ExtensionAPI) {
].filter(Boolean).join("\n"), "info");
return;
}
if (command === "discuss") {
if (state.mode !== "planning") { ctx.ui.notify("Discuss applies to a draft.", "warning"); return; }
send(discuss); return;
}
if (command === "review" && state.mode === "supervising") { notice = true; send(manualReview(state.plan ?? "")); return; }
if (command === "review" || command === "ready") { await ready(ctx, command === "review"); return; }
if (command === "edit" || command === "review" || command === "ready") { await ready(ctx, command === "review", command === "edit"); return; }
if (command === "model" || command.startsWith("model ")) {
if (!state.plan || !goals(planText()).length) { ctx.ui.notify("Register a goal plan first.", "warning"); return; }
const ref = command.slice("model".length).trim();
@@ -384,16 +400,24 @@ export default function mainSupervisor(pi: ExtensionAPI) {
else send(attachNotice(target, false, noted));
return;
}
if (command === "stop" || command === "exit") {
if (state.mode === "planning") {
if (command === "stop") { ctx.ui.notify("A draft cannot pause; use /goals exit to leave planning with the draft preserved.", "warning"); return; }
// Planning exit must not get the model trapped re-planning or lose the draft.
state.mode = "chat"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
ctx.ui.notify(`Planning exited; draft preserved at ${state.plan}. No implementation was approved or started. Reconnect with /goals attach ${state.plan}.`, "info");
return;
if (command === "exit") {
const backup = state.plan && existsSync(state.plan) ? `${state.plan}.${randomUUID()}.bak` : undefined;
if (backup) writeFileSync(backup, readFileSync(state.plan!), { flag: "wx" });
const storage = new CronStorage(ctx.cwd);
const session = ctx.sessionManager.getSessionId();
for (const job of storage.getAllJobs().filter(j => j.name === `goals-${session}` && j.session === session)) {
storage.removeJob(job.id); // Scheduler re-reads storage before firing; removed jobs cannot prompt.
pi.events.emit("cron:change", { type: "remove", jobId: job.id });
}
state.mode = command === "stop" ? "paused" : "chat"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
const pause = pauseExitNotice(state.worker, command === "exit");
state = initial(); generation++; workerRevision++; pendingLaunches = 0; pendingUpkeep = undefined; notice = true;
save(); refresh(ctx); watchPlan(ctx);
ctx.ui.notify(`Goals cleared.${backup ? ` Plan backed up to ${backup}.` : ""}`, "info");
return;
}
if (command === "stop") {
if (state.mode === "planning") { ctx.ui.notify("A draft cannot pause; use /goals quit to back up and clear it.", "warning"); return; }
state.mode = "paused"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
const pause = pauseExitNotice(state.worker, false);
const requestCleanup = Boolean(state.worker) || hasScheduleTool();
if (!requestCleanup) ctx.ui.notify(pause, "info"); // Visible now; passive model context waits for a prompt.
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${pause}`, requestCleanup);
@@ -415,10 +439,13 @@ export default function mainSupervisor(pi: ExtensionAPI) {
if (command !== "new" && !command.startsWith("new ")) { ctx.ui.notify(`Unknown or incomplete command. ${help}`, "warning"); return; }
const objective = command.slice(4).trim();
if ((state.worker && !state.workerStopped) || state.mode === "supervising") { ctx.ui.notify("Exit and resolve the existing worker before replacing the plan. The current plan is preserved.", "warning"); return; }
const path = join(ctx.cwd, ".pi", "plan", `${ctx.sessionManager.getSessionId()}-main.md`);
let path = join(ctx.cwd, ".pi", "plan", `${ctx.sessionManager.getSessionId()}-main.md`);
mkdirSync(dirname(path), { recursive: true });
// Never overwrite an earlier plan at this session path; the model can revise it after inspection.
try { writeFileSync(path, planDocument(objective), { flag: "wx" }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
try { writeFileSync(path, planDocument(objective), { flag: "wx" }); } catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
path = join(dirname(path), `${ctx.sessionManager.getSessionId()}-${randomUUID()}.md`);
writeFileSync(path, planDocument(objective), { flag: "wx" });
}
state = { mode: "planning", plan: path, signoffs: {}, worker: state.worker, helpers: state.helpers, workerStopped: state.workerStopped }; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
send(planningSeed(objective, path));
} catch (error) { ctx.ui.notify(String(error), "error"); }
+1 -1
View File
@@ -201,7 +201,7 @@ export function completionResult(goal: string, sessionId: string, remaining: boo
// Pause/resume and solo recovery. Stored stop confirmation is invalidated on every worker launch.
export const pausedRole = "Goal work is paused. Do not launch, resume or authorize work. Incoming reports are observations, not permission. Help inspect or stop existing workers if requested.";
export function pauseExitNotice(worker: { id?: string; sessionFile: string } | undefined, exited: boolean): string {
return `Goals ${exited ? "exited to ordinary chat" : "paused locally"}; plan and evidence retained. ${worker ? worker.id ? `Inspect and stop runtime id ${worker.id} through subagent_kill or its pane; confirm the actual result.` : `Only saved session ${worker.sessionFile} is recorded, not a kill id. Locate its live pane/session and confirm termination; never pass the file path to subagent_kill.` : "No worker recorded: inspect /subagents if a launch was interrupted; absence is not proof of stop."} Remote stop is NOT yet confirmed. Restore failed compaction/model/credits in the existing session and continue only after explicit authorization; never restart long work.`;
return `Goals ${exited ? "exited to ordinary chat" : "paused locally"}; plan and evidence retained. ${worker ? worker.id ? `Inspect and stop runtime id ${worker.id} through subagent_kill or its pane; confirm the actual result.` : `Only saved session ${worker.sessionFile} is recorded, not a kill id. Locate its live pane/session and confirm termination; never pass the file path to subagent_kill.` : "No worker recorded: inspect /subagents if a launch was interrupted; absence is not proof of stop."} Remote stop is NOT yet confirmed. Resume only after explicit authorization.`;
}
export function resumeNotice(workerName: string, planPath: string, worker: { sessionFile: string } | undefined): string {
return `User authorized continuation of ${planPath}. Inspect worker state before any launch/resume. ${worker ? `Use the existing session ${worker.sessionFile}; if live, inspect/message it; only if confirmed stopped use subagent_resume.` : `Use '${workerName}' only after confirming no prior writer exists.`} Continue only unfinished goals; retain saved progress and scheduler edits.`;
+82 -12
View File
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -23,7 +23,7 @@ function fixture(child = false) {
const entries: any[] = []; const hooks = new Map<string, any>(); const commands = new Map<string, any>(); const tools = new Map<string, any>();
const messages: any[] = [];
const ctx = { cwd, sessionManager: { getBranch: () => entries, getSessionId: () => "copy-only" }, hasUI: true, ui: {
theme: { fg: (_color: string, text: string) => text }, notify: vi.fn(), setStatus: vi.fn(), setWidget: vi.fn(), select: vi.fn(async () => "Ready"), editor: vi.fn(),
theme: { fg: (_color: string, text: string) => text }, notify: vi.fn(), setStatus: vi.fn(), setWidget: vi.fn(), select: vi.fn(async (_title: string, _options: string[]) => "Ready"), editor: vi.fn(),
} };
const pi = {
on: (event: string, hook: any) => hooks.set(event, hook),
@@ -32,6 +32,7 @@ function fixture(child = false) {
registerTool: (definition: any) => tools.set(definition.name, definition),
sendMessage: (message: any, options: any) => messages.push({ message, options }),
sendUserMessage: (content: string, options: any) => messages.push({ message: { content }, options, savedPrompt: true }),
events: { emit: vi.fn() },
getAllTools: vi.fn(() => [
{ name: "subagent", parameters: { properties: { agent: {}, title: {} } } },
{ name: "subagent_resume", parameters: { properties: { sessionFile: {} } } },
@@ -56,12 +57,25 @@ function fixture(child = false) {
return { ctx, pi, hooks, tools, commands, messages, command, path, plan, draft, shutdown, changed, atomicWrite, entries };
}
it("shows action choices and autocomplete without starting work", async () => {
it.each([
["chat", ["new", "attach", "help", "quit"]],
["planning", ["edit", "discuss", "ready", "model", "help", "quit"]],
["supervising", ["review", "stop", "model", "help", "quit"]],
["paused", ["resume", "model", "help", "quit"]],
["solo", ["stop", "help", "quit"]],
])("shows only applicable %s actions without starting work", async (mode, expected) => {
const f = fixture();
if (mode !== "chat") await f.draft();
if (mode === "supervising" || mode === "paused") await f.command("ready");
if (mode === "paused") await f.command("stop");
if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); }
const before = f.messages.length;
f.ctx.ui.select.mockResolvedValueOnce(undefined as any);
await f.command("");
expect(f.ctx.ui.select).toHaveBeenCalledWith("Goal plan actions", expect.arrayContaining(["new — New plan", "resume — Continue paused work"]));
expect(f.messages).toHaveLength(0);
const actions = f.ctx.ui.select.mock.calls.at(-1)![1];
expect(actions.map(action => action.split(" — ")[0])).toEqual(expected);
expect(actions.at(-1)).toBe("quit — Exit and clear goals (back up plan)");
expect(f.messages).toHaveLength(before);
expect(f.commands.get("goals").getArgumentCompletions("res")).toEqual([{ value: "resume", label: "resume" }]);
});
@@ -92,6 +106,59 @@ it.each(["menu", "command"])("enters planning conversation through %s without an
expect(f.hooks.get("tool_call")({ toolName: "subagent" }).block).toBe(true);
});
it("edits even an empty draft directly without a model call", async () => {
const f = fixture(); await f.command("new"); const before = f.messages.length;
f.ctx.ui.editor.mockResolvedValueOnce(f.plan);
f.ctx.ui.select.mockResolvedValueOnce("edit — Edit plan…"); await f.command("");
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
expect(f.entries.at(-1).data.mode).toBe("planning");
expect(f.messages).toHaveLength(before);
});
it("clear backs up the plan, drops stale bindings and allows a separate new draft", async () => {
const f = fixture(); await f.draft(); await f.command("ready");
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "stale", sessionFile: "/tmp/old-worker.jsonl" } });
const jobs = [
{ id: "owned", name: "goals-copy-only", session: "copy-only", enabled: true },
{ id: "older", name: "older-plan", session: "copy-only", enabled: true },
{ id: "foreign", name: "goals-copy-only", session: "other", enabled: true },
{ id: "unbound", name: "goals-copy-only", enabled: true },
];
const schedule = join(f.ctx.cwd, ".pi/schedule-prompts.json"); writeFileSync(schedule, JSON.stringify({ version: 1, jobs }));
const before = f.messages.length; await f.command("clear");
expect(f.messages).toHaveLength(before);
expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} });
expect(JSON.parse(readFileSync(schedule, "utf8")).jobs).toEqual(jobs.slice(1));
expect(f.pi.events.emit).toHaveBeenCalledWith("cron:change", { type: "remove", jobId: "owned" });
const directory = join(f.ctx.cwd, ".pi/plan");
const backup = readdirSync(directory).find(name => name.endsWith(".bak"))!;
expect(readFileSync(join(directory, backup), "utf8")).toBe(f.plan);
await f.command("new a different objective");
const next = f.entries.at(-1).data;
expect(next.mode).toBe("planning"); expect(next.worker).toBeUndefined(); expect(next.plan).not.toBe(f.path);
expect(readFileSync(next.plan, "utf8")).toContain("a different objective");
expect(readFileSync(next.plan, "utf8")).not.toContain("first output");
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
});
it.each(["missing", "empty"])("clear resets a %s plan without a model call", async kind => {
const f = fixture(); await f.draft(); const before = f.messages.length;
if (kind === "missing") rmSync(f.path); else writeFileSync(f.path, "");
await f.command("clear");
expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} });
expect(f.messages).toHaveLength(before);
});
it("discusses plan changes only during planning", async () => {
const f = fixture(); await f.command("discuss"); expect(f.messages).toHaveLength(0);
await f.draft();
f.ctx.ui.select.mockResolvedValueOnce("discuss — Discuss changes to the plan"); await f.command("");
expect(f.messages.at(-1).message.content).toContain("Discuss the current draft");
expect(f.entries.at(-1).data.mode).toBe("planning");
await f.command("ready"); const before = f.messages.length;
await f.command("discuss"); expect(f.messages).toHaveLength(before);
});
it("automatically proposes a changed settled draft once and preserves Discuss", async () => {
const f = fixture(); await f.draft();
f.ctx.ui.select.mockResolvedValueOnce("Discuss");
@@ -241,7 +308,7 @@ it("does not retrigger a review for its own CompleteGoal plan write", async () =
f.shutdown();
});
it("gives pause/exit the session-bound scheduler job removal guidance", async () => {
it("gives pause scheduler guidance but clears on exit without a model prompt", async () => {
const f = fixture(); await f.draft(); await f.command("ready");
await f.command("stop");
const stop = f.messages.at(-1).message.content;
@@ -250,9 +317,10 @@ it("gives pause/exit the session-bound scheduler job removal guidance", async ()
expect(stop).not.toContain("interval '1h'");
expect(stop).toContain("Remote stop is NOT yet confirmed");
await f.command("resume");
const before = f.messages.length;
await f.command("exit");
expect(f.messages.at(-1).message.content).toContain('goals-copy-only"');
expect(f.entries.at(-1).data.mode).toBe("chat");
expect(f.messages).toHaveLength(before);
expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} });
});
it("tells the model to remove only its own job after the final review", async () => {
@@ -350,13 +418,15 @@ it("rejects attaching a missing or goal-less file", async () => {
expect(f.entries).toEqual([]); // nothing saved: the session was not attached
});
it("exits planning with the draft preserved and nothing implemented", async () => {
it.each(["exit", "quit", "clear", "menu"])("%s exits planning with the draft preserved and nothing implemented", async command => {
const f = fixture(); await f.draft();
await f.command("stop");
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("A draft cannot pause"), "warning");
const before = f.messages.length;
await f.command("exit");
if (command === "menu") f.ctx.ui.select.mockResolvedValueOnce("quit — Exit and clear goals (back up plan)");
await f.command(command === "menu" ? "" : command);
expect(f.entries.at(-1).data.mode).toBe("chat");
expect(f.ctx.ui.setWidget).toHaveBeenLastCalledWith("goals", undefined);
expect(readFileSync(f.path, "utf8")).toContain("first output");
expect(f.messages.length).toBe(before); // notify only, no model turn started
f.ctx.ui.select.mockResolvedValueOnce("Previous supervisor confirmed stopped");
@@ -687,10 +757,10 @@ it("coalesces pending upkeep with a repaired post-compaction plan, retaining the
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
});
it.each(["stop", "exit"])("passive %s is visible immediately while its model notice waits safely for the next prompt", async command => {
it("passive pause is visible immediately while its model notice waits safely for the next prompt", async () => {
const f = fixture(); await f.draft();
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
await f.command(command);
await f.command("stop");
expect(f.messages.at(-1).options).toEqual({ deliverAs: "nextTurn" });
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Remote stop is NOT yet confirmed"), "info");
});