@hank-warren/pi-plan-mode 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plan-mode.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { watch } from "node:fs";
2
- import { basename, dirname } from "node:path";
3
1
  import type {
4
2
  ExtensionAPI,
5
3
  ExtensionCommandContext,
@@ -13,21 +11,20 @@ import {
13
11
  planModeCompleted,
14
12
  renderPlanModeCompletion,
15
13
  } from "./completion-tool.js";
16
- import {
17
- isStaleExtensionContextError,
18
- onAgentSettled,
19
- setPlanThinkingLevel,
20
- } from "./extension-runtime.js";
14
+ import { isStaleExtensionContextError, onAgentSettled } from "./extension-runtime.js";
21
15
  import {
22
16
  formatImplementationHandoff,
23
17
  startFreshImplementationFromState,
24
18
  } from "./fresh-implementation.js";
19
+ import { createLifecycle, type LifecycleScope } from "./lifecycle.js";
25
20
  import { deletePlanFile, planFilePathForSession, readPlanFile, writePlanFile } from "./plan-file.js";
26
21
  import { createPlanActionController } from "./plan-action-controller.js";
27
22
  import { createPlanExportController } from "./plan-export-controller.js";
28
23
  import {
29
24
  clearPlanModeUi,
30
25
  planModeStatusText as formatPlanModeStatusText,
26
+ registerPlanModeCardRenderer,
27
+ showPlanModePlan,
31
28
  showStoredPlan,
32
29
  updatePlanModeUi,
33
30
  } from "./presentation.js";
@@ -50,9 +47,11 @@ import {
50
47
  planModeSettingsPath,
51
48
  readPlanModeSettings,
52
49
  } from "./settings.js";
53
- import { type PlanModeState, readLegacyThinkingCapture, restorePlanModeState } from "./state.js";
50
+ import { createSettingsWatcher } from "./settings-watch.js";
51
+ import { type PlanModeState, restorePlanModeState } from "./state.js";
54
52
 
55
53
  const STATE_ENTRY_TYPE = "plan-mode-state";
54
+ const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
56
55
  /**
57
56
  * Plan mode's entire enforcement surface. Everything else — bash, subagents,
58
57
  * MCP, and other extension tools — is left to the session's normal permission
@@ -64,46 +63,22 @@ const STATE_ENTRY_TYPE = "plan-mode-state";
64
63
  * was once listed here; it was a pre-1.0 upstream tool that no longer exists.)
65
64
  */
66
65
  const BLOCKED_TOOLS = new Set(["edit", "write"]);
67
- /**
68
- * One hand-edit or menu save fans out into several filesystem events (temp file
69
- * created, renamed into place). Collapsing them into one re-read keeps a save
70
- * to a single load.
71
- */
66
+ /** Long enough to collapse one save's burst of filesystem events into one read. */
72
67
  const SETTINGS_RELOAD_DEBOUNCE_MS = 75;
73
68
 
74
69
  /**
75
- * Which question tool the prompt should name this turn.
76
- *
77
- * Detection is by tool NAME only, with no dependency on
78
- * `@hank-warren/pi-ask-user-question`: any extension registering
79
- * `ask_user_question` is treated as the preferred implementation. It offers
80
- * previews, notes, question tabs, digit hotkeys and checkbox multi-select;
81
- * `plan_mode_question` renders through plain `ctx.ui.select` + `ctx.ui.editor`
82
- * and has none of them.
83
- *
84
- * Evaluated per turn rather than once at mode entry, so the prompt cannot go
85
- * stale if the tool set changes mid-session.
86
- */
87
- function preferredQuestionTool(pi: ExtensionAPI): string {
88
- return pi.getActiveTools().includes(ASK_USER_QUESTION_TOOL)
89
- ? ASK_USER_QUESTION_TOOL
90
- : PLAN_MODE_QUESTION_TOOL;
91
- }
92
-
93
- /**
94
- * Hide `plan_mode_question` from the model whenever the better tool is present,
95
- * so it never sees two overlapping question tools and cannot call the weaker
96
- * one. The tool stays *registered* either way, so a historical transcript still
97
- * resolves it, and a host without `ask_user_question` keeps it fully functional.
70
+ * Which question tool the prompt may name this turn, read from the tool set
71
+ * the model will actually see.
98
72
  *
99
- * Idempotent, and writes only when something actually changes: siblings are
100
- * untouched, and repeated `before_agent_start` events are free.
73
+ * `null` means neither is active a headless run, where both interactive
74
+ * tools are deliberately stripped. Naming one there would send the model after
75
+ * a tool it cannot call, so the prompt switches to asking in plain text.
101
76
  */
102
- function reconcileQuestionTool(pi: ExtensionAPI, preferred: string): void {
103
- if (preferred === PLAN_MODE_QUESTION_TOOL) return;
77
+ function preferredQuestionTool(pi: ExtensionAPI): string | null {
104
78
  const active = pi.getActiveTools();
105
- if (!active.includes(PLAN_MODE_QUESTION_TOOL)) return;
106
- pi.setActiveTools(active.filter((name) => name !== PLAN_MODE_QUESTION_TOOL));
79
+ if (active.includes(ASK_USER_QUESTION_TOOL)) return ASK_USER_QUESTION_TOOL;
80
+ if (active.includes(PLAN_MODE_QUESTION_TOOL)) return PLAN_MODE_QUESTION_TOOL;
81
+ return null;
107
82
  }
108
83
 
109
84
  type InteractiveUi = typeof import("./interactive-ui.js");
@@ -132,13 +107,44 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
132
107
  let readyPresentationNonce = 0;
133
108
  let pendingReadyNonce: number | undefined;
134
109
  let latestCommandContext: ExtensionCommandContext | undefined;
135
- let menuGeneration = 0;
136
- let workflowGeneration = 0;
137
110
  let refreshStateBeforeFirstAgentStart = false;
138
- let menuController = new AbortController();
139
- let settingsWatch: ReturnType<typeof watch> | undefined;
140
- let settingsReloadTimer: ReturnType<typeof setTimeout> | undefined;
111
+ const lifecycle = createLifecycle();
112
+ let settingsWatcher: ReturnType<typeof createSettingsWatcher> | undefined;
113
+ let planToolsActivated = false;
114
+ let currentHasUI = false;
115
+ let globalQuestionAvailable = false;
141
116
  const persistState = () => pi.appendEntry<PlanModeState>(STATE_ENTRY_TYPE, state);
117
+
118
+ const reconcilePlanToolSurface = (hasUI: boolean, availability?: boolean) => {
119
+ currentHasUI = hasUI;
120
+ const active = pi.getActiveTools();
121
+ globalQuestionAvailable = availability ?? (hasUI && active.includes(ASK_USER_QUESTION_TOOL));
122
+ const wanted = new Set(active);
123
+ const completeWanted = planToolsActivated;
124
+ const fallbackWanted = planToolsActivated && hasUI && !globalQuestionAvailable;
125
+ if (completeWanted) wanted.add(PLAN_MODE_COMPLETE_TOOL_NAME);
126
+ else wanted.delete(PLAN_MODE_COMPLETE_TOOL_NAME);
127
+ if (fallbackWanted) wanted.add(PLAN_MODE_QUESTION_TOOL);
128
+ else wanted.delete(PLAN_MODE_QUESTION_TOOL);
129
+ const next = [...wanted];
130
+ if (next.length !== active.length || next.some((name, index) => name !== active[index])) {
131
+ pi.setActiveTools(next);
132
+ }
133
+ };
134
+ const activatePlanTools = (hasUI: boolean) => {
135
+ planToolsActivated = true;
136
+ reconcilePlanToolSurface(hasUI);
137
+ };
138
+
139
+ registerPlanModeCardRenderer(pi);
140
+ pi.events.on(ASK_USER_AVAILABILITY_EVENT, (payload: unknown) => {
141
+ const available =
142
+ typeof payload === "object" && payload !== null &&
143
+ typeof (payload as { available?: unknown }).available === "boolean"
144
+ ? (payload as { available: boolean }).available
145
+ : undefined;
146
+ if (available !== undefined) reconcilePlanToolSurface(currentHasUI, available);
147
+ });
142
148
  const planExports = createPlanExportController({
143
149
  getState: () => state,
144
150
  getSettings: () => settings,
@@ -149,7 +155,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
149
155
  const planActions = createPlanActionController({
150
156
  loadInteractiveUi,
151
157
  getState: () => state,
152
- captureLifecycle: captureMenuLifecycle,
158
+ captureLifecycle: () => lifecycle.capture(),
153
159
  statusText: planStatusText,
154
160
  planPathLine: () => (state.planPath ? `Plan file: ${state.planPath}` : undefined),
155
161
  getExportDestination: (ctx) => planExports.getDestination(ctx),
@@ -162,13 +168,11 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
162
168
  exitReady: (ctx) => {
163
169
  // Same had-plan branching as the /plan exit command: the menu must not
164
170
  // claim a plan was discarded when none was ever completed.
165
- const hadPlan = state.planPath !== undefined;
166
- void exitPlanMode(ctx).then(() => {
167
- ctx.ui.notify(
168
- hadPlan ? "Plan mode disabled. Proposed plan discarded." : "Plan mode disabled.",
169
- "info",
170
- );
171
- });
171
+ const text =
172
+ state.planPath !== undefined
173
+ ? "Plan mode disabled. Proposed plan discarded."
174
+ : "Plan mode disabled.";
175
+ void exitAndNotify(ctx, text);
172
176
  },
173
177
  });
174
178
 
@@ -183,12 +187,14 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
183
187
  label: "Plan question",
184
188
  description:
185
189
  "Ask the user one to three Plan-mode clarification questions with meaningful options, then wait for the answer. Only available while Plan mode is active.",
190
+ // Kept, now that the tool is staged: this guidance reaches the model only
191
+ // in a session that has actually entered Plan mode.
186
192
  promptSnippet: "Ask user decision questions while Plan mode is active",
187
193
  promptGuidelines: [
188
194
  "In Plan mode, use plan_mode_question for important preferences, tradeoffs, or assumptions that cannot be discovered from read-only exploration.",
189
195
  ],
190
196
  parameters: PLAN_MODE_QUESTION_PARAMS,
191
- async execute(_toolCallId, params: unknown, _signal, _onUpdate, ctx) {
197
+ async execute(_toolCallId, params: unknown, signal, _onUpdate, ctx) {
192
198
  if (!state.enabled) {
193
199
  return planModeQuestionCancelled(
194
200
  [],
@@ -210,13 +216,14 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
210
216
  );
211
217
  }
212
218
 
213
- const sessionGeneration = menuGeneration;
214
- const questionWorkflowGeneration = workflowGeneration;
215
- return answerPlanModeQuestions(parsed.questions, ctx, {
216
- isCurrent: () =>
217
- sessionGeneration === menuGeneration && questionWorkflowGeneration === workflowGeneration,
218
- isEnabled: () => state.enabled,
219
- });
219
+ const menu = lifecycle.capture();
220
+ const questionSignal = signal ? AbortSignal.any([signal, menu.signal]) : menu.signal;
221
+ return answerPlanModeQuestions(
222
+ parsed.questions,
223
+ ctx,
224
+ { isCurrent: menu.isCurrent, isEnabled: () => state.enabled },
225
+ questionSignal,
226
+ );
220
227
  },
221
228
  });
222
229
 
@@ -243,6 +250,10 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
243
250
  },
244
251
  });
245
252
 
253
+ // Registered tools remain available for transcript replay; the active set is
254
+ // narrowed at session_start rather than here, because Pi refuses action
255
+ // methods (getActiveTools/setActiveTools) during extension loading.
256
+
246
257
  pi.registerCommand("plan", {
247
258
  description: "Enter or manage Plan mode",
248
259
  getArgumentCompletions: completePlanArguments,
@@ -256,7 +267,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
256
267
  return;
257
268
  }
258
269
  enterPlanMode(ctx);
259
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
270
+ notifyEnabled(ctx);
260
271
  return;
261
272
  }
262
273
  if (command === "show") {
@@ -277,8 +288,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
277
288
  }
278
289
  const exportMatch = /^export(?:\s+([\s\S]+))?$/iu.exec(prompt);
279
290
  if (exportMatch) {
280
- const lifecycle = captureMenuLifecycle();
281
- await planExports.export(exportMatch[1], ctx, lifecycle.signal, lifecycle.isCurrent);
291
+ const menu = lifecycle.capture();
292
+ await planExports.export(exportMatch[1], ctx, menu.signal, menu.isCurrent);
282
293
  return;
283
294
  }
284
295
  if (command === "exit" || command === "off") {
@@ -290,8 +301,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
290
301
  : hadPlan
291
302
  ? "Active implementation plan cleared."
292
303
  : "Plan mode disabled.";
293
- await exitPlanMode(ctx);
294
- ctx.ui.notify(notification, "info");
304
+ await exitAndNotify(ctx, notification);
295
305
  return;
296
306
  }
297
307
  if (prompt) {
@@ -329,85 +339,61 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
329
339
  * `ctx` to explain why, would be worse than waiting for the next write. A
330
340
  * genuinely broken file is still reported at the next session start.
331
341
  */
332
- const loadPlanModeSettings = async (generation: number, ctx?: ExtensionContext) => {
342
+ const loadPlanModeSettings = async (session: LifecycleScope, ctx?: ExtensionContext) => {
333
343
  const loaded = await readRuntimeSettings();
334
- if (generation !== menuGeneration || menuController.signal.aborted) return;
344
+ if (!session.isCurrent()) return;
335
345
  if (loaded.kind === "invalid" && !ctx) return;
336
346
  settings = loaded.kind === "loaded" ? loaded.settings : {};
337
347
  if (!ctx) return;
338
348
  if (loaded.kind === "invalid") {
339
349
  ctx.ui.notify(`pi-plan-mode settings ignored: ${loaded.reason}`, "warning");
340
350
  }
341
- if (loaded.notice) ctx.ui.notify(loaded.notice, "warning");
342
351
  };
343
352
 
344
353
  const stopPlanModeSettingsWatch = () => {
345
- if (settingsReloadTimer) {
346
- clearTimeout(settingsReloadTimer);
347
- settingsReloadTimer = undefined;
348
- }
349
- settingsWatch?.close();
350
- settingsWatch = undefined;
354
+ settingsWatcher?.stop();
355
+ settingsWatcher = undefined;
351
356
  };
352
357
 
353
- /**
354
- * Watches the settings file's directory rather than the file itself: saves go
355
- * through a temp file and an atomic rename, and a watch bound to the old inode
356
- * would go deaf after the first one.
357
- */
358
- const startPlanModeSettingsWatch = (generation: number) => {
358
+ /** An injected reader is the only source there is, so it is never watched. */
359
+ const startPlanModeSettingsWatch = (session: LifecycleScope) => {
359
360
  stopPlanModeSettingsWatch();
360
361
  if (dependencies.readSettings) return;
361
- const watchedPath = dependencies.settingsPath ?? planModeSettingsPath();
362
- const watchedFile = basename(watchedPath);
363
- try {
364
- const watcher = watch(dirname(watchedPath), { persistent: false }, (event, changedFile) => {
365
- if (event !== "rename" && event !== "change") return;
366
- // A null filename means the platform could not name the entry; reload
367
- // rather than miss the edit. The agent directory holds other churn, so
368
- // a named entry that is not ours is ignored.
369
- if (changedFile && changedFile.toString() !== watchedFile) return;
370
- if (settingsReloadTimer) clearTimeout(settingsReloadTimer);
371
- settingsReloadTimer = setTimeout(() => {
372
- settingsReloadTimer = undefined;
373
- void loadPlanModeSettings(generation);
374
- }, SETTINGS_RELOAD_DEBOUNCE_MS);
375
- });
376
- watcher.on("error", stopPlanModeSettingsWatch);
377
- settingsWatch = watcher;
378
- } catch {
379
- // An unwatchable directory only costs the live reload; settings still
380
- // load at session start.
381
- stopPlanModeSettingsWatch();
382
- }
362
+ settingsWatcher = createSettingsWatcher({
363
+ path: dependencies.settingsPath ?? planModeSettingsPath(),
364
+ debounceMs: SETTINGS_RELOAD_DEBOUNCE_MS,
365
+ onChange: () => void loadPlanModeSettings(session),
366
+ });
367
+ settingsWatcher.start();
383
368
  };
384
369
 
385
370
  pi.on("session_start", async (event, ctx) => {
386
- const generation = ++menuGeneration;
371
+ const session = lifecycle.nextSession("Plan-mode session replaced");
372
+ planToolsActivated = false;
373
+ currentHasUI = ctx.hasUI;
374
+ reconcilePlanToolSurface(ctx.hasUI);
387
375
  refreshStateBeforeFirstAgentStart = event.reason === "new";
388
- menuController.abort(new DOMException("Plan-mode session replaced", "AbortError"));
389
- menuController = new AbortController();
390
376
  pendingReadyNonce = undefined;
391
377
  latestCommandContext = undefined;
392
378
  settings = {};
393
379
  sessionPlanPath = resolveSessionPlanPath(ctx);
394
380
  restoreState(ctx);
395
- repairLegacyThinkingLevel(ctx);
396
- await loadPlanModeSettings(generation, ctx);
397
- if (generation !== menuGeneration || menuController.signal.aborted) return;
398
- startPlanModeSettingsWatch(generation);
381
+ await loadPlanModeSettings(session, ctx);
382
+ if (!session.isCurrent()) return;
383
+ startPlanModeSettingsWatch(session);
399
384
  const persistFlagActivation = pi.getFlag("plan") === true && !state.enabled;
400
385
  if (persistFlagActivation) {
401
386
  state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
402
387
  }
403
388
  if (persistFlagActivation) persistState();
389
+ if (state.enabled) activatePlanTools(ctx.hasUI);
404
390
  updateUi(ctx);
405
391
  });
406
392
 
407
393
  pi.on("session_shutdown", async (_event, ctx) => {
408
- menuGeneration += 1;
394
+ // No re-arm: nothing may become current again until a session_start.
395
+ lifecycle.endSession("Plan-mode session shut down");
409
396
  stopPlanModeSettingsWatch();
410
- menuController.abort(new DOMException("Plan-mode session shut down", "AbortError"));
411
397
  pendingReadyNonce = undefined;
412
398
  latestCommandContext = undefined;
413
399
  refreshStateBeforeFirstAgentStart = false;
@@ -430,6 +416,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
430
416
  });
431
417
 
432
418
  pi.on("before_agent_start", (event, ctx) => {
419
+ currentHasUI = ctx.hasUI;
433
420
  if (refreshStateBeforeFirstAgentStart) {
434
421
  refreshStateBeforeFirstAgentStart = false;
435
422
  restoreState(ctx);
@@ -439,15 +426,14 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
439
426
  // A new turn supersedes the previous ready plan: revision feedback
440
427
  // re-opens planning until another plan_mode_complete arrives.
441
428
  pendingReadyNonce = undefined;
442
- state = { ...state, awaitingAction: false };
443
- persistState();
444
- updateUi(ctx);
429
+ setState(ctx, { awaitingAction: false });
445
430
  }
446
- // Read fresh and write immediately, exactly like pi-ask-user-question's own
447
- // reconciler, so the two hooks converge on the same result in either
448
- // execution order.
449
- const questionTool = preferredQuestionTool(pi);
450
- reconcileQuestionTool(pi, questionTool);
431
+ if (state.enabled && !planToolsActivated) activatePlanTools(ctx.hasUI);
432
+ else reconcilePlanToolSurface(ctx.hasUI);
433
+ // A headless run has no legitimate question tool, whatever the active set
434
+ // still says: pi-ask-user-question strips its own tool on this same hook,
435
+ // and hook order between the two packages is not ours to depend on.
436
+ const questionTool = ctx.hasUI ? preferredQuestionTool(pi) : null;
451
437
  if (state.enabled) {
452
438
  return { systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt(questionTool)}` };
453
439
  }
@@ -475,38 +461,55 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
475
461
  });
476
462
 
477
463
  function enterPlanMode(ctx: ExtensionContext) {
478
- workflowGeneration += 1;
479
- state = { ...state, enabled: true, awaitingAction: false };
480
- persistState();
481
- updateUi(ctx);
464
+ lifecycle.nextWorkflow();
465
+ activatePlanTools(ctx.hasUI);
466
+ setState(ctx, { enabled: true, awaitingAction: false });
482
467
  }
483
468
 
484
469
  function enterPlanModeWithPrompt(prompt: string, ctx: ExtensionContext) {
485
470
  const previousState = state;
486
471
  const wasEnabled = state.enabled;
487
472
  enterPlanMode(ctx);
488
- if (!wasEnabled) {
489
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
490
- }
491
- if (sendPlanModeUserMessage(prompt, ctx)) return;
492
- state = previousState;
493
- persistState();
494
- updateUi(ctx);
473
+ if (!wasEnabled) notifyEnabled(ctx);
474
+ sendOrRevert(prompt, ctx, previousState);
495
475
  }
496
476
 
497
477
  async function exitPlanMode(ctx: ExtensionContext, options: { keepPlanFile?: boolean } = {}) {
498
- workflowGeneration += 1;
478
+ lifecycle.nextWorkflow();
499
479
  const planPath = state.planPath;
500
480
  pendingReadyNonce = undefined;
501
- state = {
502
- ...state,
503
- enabled: false,
504
- planPath: undefined,
505
- awaitingAction: false,
506
- };
481
+ setState(ctx, { enabled: false, planPath: undefined, awaitingAction: false });
482
+ if (planPath && !options.keepPlanFile) await deletePlanFile(planPath);
483
+ }
484
+
485
+ /** Leaves Plan mode and reports it in one step, for menus and /plan alike. */
486
+ function exitAndNotify(
487
+ ctx: ExtensionContext,
488
+ text: string,
489
+ options: { keepPlanFile?: boolean } = {},
490
+ ) {
491
+ return exitPlanMode(ctx, options).then(() => ctx.ui.notify(text, "info"));
492
+ }
493
+
494
+ function notifyEnabled(ctx: ExtensionContext) {
495
+ ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
496
+ }
497
+
498
+ /** State moves as one: what is remembered, what is persisted, what is shown. */
499
+ function setState(ctx: ExtensionContext, patch: Partial<PlanModeState>) {
500
+ state = { ...state, ...patch };
507
501
  persistState();
508
502
  updateUi(ctx);
509
- if (planPath && !options.keepPlanFile) await deletePlanFile(planPath);
503
+ }
504
+
505
+ /**
506
+ * Sends the message a state change exists to produce, and puts the previous
507
+ * state back when the session refuses it: a mode switch the model was never
508
+ * told about is worse than no switch at all.
509
+ */
510
+ function sendOrRevert(message: string, ctx: ExtensionContext, previousState: PlanModeState) {
511
+ if (sendPlanModeUserMessage(message, ctx)) return;
512
+ setState(ctx, previousState);
510
513
  }
511
514
 
512
515
  function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) {
@@ -525,20 +528,18 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
525
528
  * Writes the durable plan file and marks the plan ready. A write failure
526
529
  * keeps Plan mode active rather than silently losing the plan.
527
530
  */
528
- async function acceptCompletedPlan(plan: string, ctx: ExtensionContext) {
531
+ async function acceptCompletedPlan(plan: string, ctx: ExtensionContext): Promise<string> {
529
532
  const planPath = sessionPlanPath ?? resolveSessionPlanPath(ctx);
530
- sessionPlanPath = planPath;
531
533
  try {
532
534
  await writePlanFile(planPath, plan);
533
535
  } catch (error: unknown) {
534
536
  const detail = error instanceof Error ? error.message : String(error);
535
- ctx.ui.notify(`Unable to save the plan to ${planPath}: ${detail}`, "error");
536
- return undefined;
537
+ throw new Error(`Unable to save the plan to ${planPath}: ${detail}`);
537
538
  }
538
- state = { ...state, planPath, awaitingAction: true };
539
+ sessionPlanPath = planPath;
539
540
  pendingReadyNonce = ++readyPresentationNonce;
540
- persistState();
541
- updateUi(ctx);
541
+ setState(ctx, { planPath, awaitingAction: true });
542
+ showPlanModePlan(pi, ctx, "Proposed Plan", plan);
542
543
  return planPath;
543
544
  }
544
545
 
@@ -551,8 +552,12 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
551
552
  ctx.ui.notify("Plan mode is not active. Use /plan first.", "warning");
552
553
  return;
553
554
  }
555
+ // Same rule as the prompt: a headless run has no question tool to name.
556
+ const questionTool = ctx.hasUI ? preferredQuestionTool(pi) : null;
554
557
  sendPlanModeUserMessage(
555
- `Finalize the current implementation plan now. If any material decision remains, use ${preferredQuestionTool(pi)} instead. Otherwise call plan_mode_complete alone as your final action with the complete decision-ready plan.`,
558
+ `Finalize the current implementation plan now. If any material decision remains, ${
559
+ questionTool === null ? "ask it in plain text" : `use ${questionTool}`
560
+ } instead. Otherwise call plan_mode_complete alone as your final action with the complete decision-ready plan.`,
556
561
  ctx,
557
562
  );
558
563
  }
@@ -573,39 +578,28 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
573
578
  return;
574
579
  }
575
580
 
576
- workflowGeneration += 1;
581
+ lifecycle.nextWorkflow();
577
582
  const previousState = state;
578
583
  pendingReadyNonce = undefined;
579
- state = {
580
- ...state,
581
- enabled: false,
582
- awaitingAction: false,
583
- planPath,
584
- };
585
- persistState();
586
- updateUi(ctx);
587
-
588
- if (!sendPlanModeUserMessage(formatImplementationHandoff(planPath), ctx)) {
589
- state = previousState;
590
- persistState();
591
- updateUi(ctx);
592
- }
584
+ setState(ctx, { enabled: false, awaitingAction: false, planPath });
585
+ sendOrRevert(formatImplementationHandoff(planPath), ctx, previousState);
593
586
  }
594
587
 
595
588
  async function showLaunchMenu(ctx: ExtensionContext) {
596
- const lifecycle = captureMenuLifecycle();
597
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
589
+ const menu = lifecycle.capture();
590
+ if (!menu.isCurrent() || menu.signal.aborted) return;
598
591
  const ui = await loadInteractiveUi();
599
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
592
+ if (!menu.isCurrent() || menu.signal.aborted) return;
600
593
  await ui.showPlanLaunchMenu(ctx, {
601
594
  statusText: "Status: Off.",
602
- ...lifecycle,
595
+ signal: menu.signal,
596
+ isCurrent: menu.isCurrent,
603
597
  start: (signal) => {
604
- if (signal.aborted || !lifecycle.isCurrent()) return;
598
+ if (signal.aborted || !menu.isCurrent()) return;
605
599
  enterPlanMode(ctx);
606
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
600
+ notifyEnabled(ctx);
607
601
  },
608
- settings: (signal) => showSettings(ctx, signal, lifecycle.isCurrent),
602
+ settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
609
603
  });
610
604
  }
611
605
 
@@ -614,27 +608,25 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
614
608
  ctx.ui.notify(planStatusText(), "info");
615
609
  return;
616
610
  }
617
- const lifecycle = captureMenuLifecycle();
618
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
611
+ const menu = lifecycle.capture();
612
+ if (!menu.isCurrent() || menu.signal.aborted) return;
619
613
  const ui = await loadInteractiveUi();
620
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
614
+ if (!menu.isCurrent() || menu.signal.aborted) return;
621
615
  await ui.showActiveImplementationMenu(ctx, {
622
616
  statusText: planStatusText(),
623
617
  ...(state.planPath ? { planPathLine: `Plan file: ${state.planPath}` } : {}),
624
618
  getExportDestination: () => planExports.getDestination(ctx),
625
- signal: lifecycle.signal,
626
- isCurrent: lifecycle.isCurrent,
619
+ signal: menu.signal,
620
+ isCurrent: menu.isCurrent,
627
621
  show: () => showStoredPlan(pi, ctx, state),
628
- exportPlan: (path, signal) => planExports.export(path, ctx, signal, lifecycle.isCurrent),
629
- settings: (signal) => showSettings(ctx, signal, lifecycle.isCurrent),
622
+ exportPlan: (path, signal) => planExports.export(path, ctx, signal, menu.isCurrent),
623
+ settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
630
624
  startNew: () => {
631
625
  enterPlanMode(ctx);
632
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
626
+ notifyEnabled(ctx);
633
627
  },
634
628
  clear: () => {
635
- void exitPlanMode(ctx).then(() => {
636
- ctx.ui.notify("Active implementation plan cleared.", "info");
637
- });
629
+ void exitAndNotify(ctx, "Active implementation plan cleared.");
638
630
  },
639
631
  });
640
632
  }
@@ -661,37 +653,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
661
653
  return result.kind === "closed" && "reason" in result && result.reason === "close";
662
654
  }
663
655
 
664
- function captureMenuLifecycle() {
665
- const sessionGeneration = menuGeneration;
666
- const planWorkflowGeneration = workflowGeneration;
667
- const controller = menuController;
668
- return {
669
- signal: controller.signal,
670
- isCurrent: () =>
671
- sessionGeneration === menuGeneration &&
672
- planWorkflowGeneration === workflowGeneration &&
673
- !controller.signal.aborted,
674
- };
675
- }
676
-
677
- /**
678
- * pi-plan-mode <= 1.2.1 raised the thinking level while planning, and because
679
- * `pi.setThinkingLevel` writes through to the user's real settings, a session
680
- * that died before its restore left that change durable. If the newest state
681
- * entry still carries the capture and the live level still equals what Plan
682
- * mode applied, put the user's level back — once. Persisting state in the new
683
- * shape drops the capture, so the next session finds nothing to repair. A
684
- * user who has already moved the level themselves is left alone.
685
- *
686
- * legacy: delete in 1.4.0
687
- */
688
- function repairLegacyThinkingLevel(ctx: ExtensionContext) {
689
- const legacy = readLegacyThinkingCapture(ctx.sessionManager.getBranch(), STATE_ENTRY_TYPE);
690
- if (!legacy || pi.getThinkingLevel() !== legacy.applied) return;
691
- setPlanThinkingLevel(pi, legacy.previous);
692
- persistState();
693
- }
694
-
695
656
  function resolveSessionPlanPath(ctx: ExtensionContext) {
696
657
  try {
697
658
  return planFilePathForSession(ctx.sessionManager.getSessionId());