@hank-warren/pi-plan-mode 0.1.0 → 1.0.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,14 +1,8 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import type {
3
2
  ExtensionAPI,
4
3
  ExtensionCommandContext,
5
4
  ExtensionContext,
6
5
  } from "@earendil-works/pi-coding-agent";
7
- import {
8
- autoPermissionsIsLoaded,
9
- shouldDelegateBashToAutoPermissions,
10
- snapshotAutoPermissionsTrustedGroups,
11
- } from "./auto-permissions-delegation.js";
12
6
  import { completePlanArguments } from "./command.js";
13
7
  import {
14
8
  normalizePlanModeCompletion,
@@ -26,11 +20,7 @@ import {
26
20
  formatImplementationHandoff,
27
21
  startFreshImplementationFromState,
28
22
  } from "./fresh-implementation.js";
29
- import {
30
- createImplementationRetentionCoordinator,
31
- implementationRetentionPreview,
32
- } from "./implementation-retention.js";
33
- import { invalidPlanMessage, latestAssistantText, parseProposedPlan } from "./message-transform.js";
23
+ import { deletePlanFile, planFilePathForSession, readPlanFile, writePlanFile } from "./plan-file.js";
34
24
  import { createPlanActionController } from "./plan-action-controller.js";
35
25
  import { createPlanExportController } from "./plan-export-controller.js";
36
26
  import {
@@ -39,7 +29,7 @@ import {
39
29
  showStoredPlan,
40
30
  updatePlanModeUi,
41
31
  } from "./presentation.js";
42
- import { buildPlanModePrompt } from "./prompt.js";
32
+ import { buildActivePlanPointer, buildPlanModePrompt } from "./prompt.js";
43
33
  import {
44
34
  answerPlanModeQuestions,
45
35
  normalizePlanModeQuestionParams,
@@ -47,43 +37,23 @@ import {
47
37
  PLAN_MODE_QUESTION_TOOL_NAME,
48
38
  planModeQuestionCancelled,
49
39
  } from "./question-tool.js";
50
- import { withoutRequiredPlanModeTools, withRequiredPlanModeTools } from "./required-tools.js";
51
- import {
52
- preflightSavedPlanImplementation,
53
- savedPlanBlocksNewWorkflow,
54
- } from "./saved-plan-preflight.js";
55
40
  import {
56
41
  awaitPlanModeSettingsWrites,
57
- configuredBashPolicy,
58
- configuredImplementationPlanRetention,
59
42
  configuredThinkingLevel,
60
43
  type PlanModeSettings,
61
44
  readPlanModeSettings,
62
45
  } from "./settings.js";
63
- import { type PlanCompletionSource, type PlanModeState, restorePlanModeState } from "./state.js";
64
- import {
65
- canSelectToolInPlanMode,
66
- classifyPlanModeTool,
67
- findBlockedCommandSegment,
68
- readCommand,
69
- } from "./tool-policy.js";
70
- import {
71
- compareTools,
72
- filterAvailableSelectedToolNames,
73
- snapshotPlanModeSelectedNames,
74
- snapshotPlanModeToolNames,
75
- toolPolicyLabel,
76
- } from "./tool-selection.js";
46
+ import { type PlanModeState, restorePlanModeState } from "./state.js";
77
47
 
78
48
  const STATE_ENTRY_TYPE = "plan-mode-state";
79
- const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
80
- const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
81
- const DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
82
- interface ReadyPresentationIntent {
83
- nonce: number;
84
- plan: string;
85
- source: PlanCompletionSource;
86
- }
49
+ /**
50
+ * Plan mode's entire enforcement surface. Everything else — bash, subagents,
51
+ * MCP, and other extension tools — is left to the session's normal permission
52
+ * layer (for example @hank-warren/pi-auto-permissions), so Plan mode never
53
+ * mutates the active tool set and never fights other extensions for it.
54
+ */
55
+ const BLOCKED_TOOLS = new Set(["edit", "write", "update_plan"]);
56
+
87
57
  type InteractiveUi = typeof import("./interactive-ui.js");
88
58
 
89
59
  interface PlanModeDependencies {
@@ -91,6 +61,7 @@ interface PlanModeDependencies {
91
61
  settingsPath?: string;
92
62
  loadInteractiveUi?(): Promise<InteractiveUi>;
93
63
  }
64
+
94
65
  export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDependencies = {}) {
95
66
  let interactiveUiPromise: Promise<InteractiveUi> | undefined;
96
67
  const loadInteractiveUi = () => {
@@ -105,49 +76,44 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
105
76
  };
106
77
  let state: PlanModeState = { enabled: false, awaitingAction: false };
107
78
  let settings: PlanModeSettings = { thinkingLevel: "inherit" };
108
- let autoPermissionsTrustedGroups = new Set<string>();
109
- let previousTools: string[] | undefined;
110
- let readyPresentationIntent: ReadyPresentationIntent | undefined;
79
+ let sessionPlanPath: string | undefined;
80
+ let readyPresentationNonce = 0;
81
+ let pendingReadyNonce: number | undefined;
111
82
  let latestCommandContext: ExtensionCommandContext | undefined;
112
- let nextReadyPresentationNonce = 0;
113
83
  let menuGeneration = 0;
114
84
  let workflowGeneration = 0;
115
85
  let refreshStateBeforeFirstAgentStart = false;
116
86
  let menuController = new AbortController();
117
- const implementationRetention = createImplementationRetentionCoordinator();
118
87
  const persistState = () => pi.appendEntry<PlanModeState>(STATE_ENTRY_TYPE, state);
119
88
  const planExports = createPlanExportController({
120
89
  getState: () => state,
121
90
  getSettings: () => settings,
122
- finishReady: (ctx) => exitPlanMode(ctx),
91
+ finishReady: (ctx) => {
92
+ void exitPlanMode(ctx, { keepPlanFile: true });
93
+ },
123
94
  });
124
95
  const planActions = createPlanActionController({
125
96
  loadInteractiveUi,
126
97
  getState: () => state,
127
98
  captureLifecycle: captureMenuLifecycle,
128
99
  statusText: planStatusText,
129
- implementationOutcome,
100
+ planPathLine: () => (state.planPath ? `Plan file: ${state.planPath}` : undefined),
130
101
  getExportDestination: (ctx) => planExports.getDestination(ctx),
131
102
  show: (ctx) => showStoredPlan(pi, ctx, state),
132
103
  finalize: requestFinalPlan,
133
104
  implementHere: startImplementation,
134
105
  implementFresh: startFreshImplementation,
135
106
  exportPlan: (ctx, path, signal, isCurrent) => planExports.export(path, ctx, signal, isCurrent),
136
- settings: showSettings,
137
- save: savePlanForLater,
138
107
  stay: updateUi,
139
108
  exitReady: (ctx) => {
140
- exitPlanMode(ctx);
141
- ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info");
142
- },
143
- clearSaved: (ctx) => {
144
- exitPlanMode(ctx);
145
- ctx.ui.notify("Saved plan cleared.", "info");
109
+ void exitPlanMode(ctx).then(() => {
110
+ ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info");
111
+ });
146
112
  },
147
113
  });
148
114
 
149
115
  pi.registerFlag("plan", {
150
- description: "Start in Codex-like Plan mode",
116
+ description: "Start in Plan mode",
151
117
  type: "boolean",
152
118
  default: false,
153
119
  });
@@ -212,21 +178,19 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
212
178
  const parsed = normalizePlanModeCompletion(params);
213
179
  if (!parsed.ok) throw new Error(parsed.error);
214
180
 
215
- acceptCompletedPlan(parsed.plan, PLAN_MODE_COMPLETE_TOOL_NAME, ctx);
216
- return planModeCompleted(parsed.plan);
181
+ const planPath = await acceptCompletedPlan(parsed.plan, ctx);
182
+ return planModeCompleted(parsed.plan, planPath);
217
183
  },
218
184
  });
219
185
 
220
186
  pi.registerCommand("plan", {
221
- description: "Enter or manage Codex-like Plan mode",
187
+ description: "Enter or manage Plan mode",
222
188
  getArgumentCompletions: completePlanArguments,
223
189
  handler: async (args, ctx) => {
224
190
  latestCommandContext = ctx;
225
191
  const prompt = args.trim();
226
192
  const command = prompt.toLowerCase();
227
193
  if (command === "start") {
228
- if (savedPlanBlocksNewWorkflow(ctx, state.savedPlan !== undefined && !state.enabled))
229
- return;
230
194
  if (state.enabled) {
231
195
  ctx.ui.notify("Plan mode is already active.", "info");
232
196
  return;
@@ -236,7 +200,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
236
200
  return;
237
201
  }
238
202
  if (command === "show") {
239
- showStoredPlan(pi, ctx, state);
203
+ await showStoredPlan(pi, ctx, state);
240
204
  return;
241
205
  }
242
206
  if (command === "finalize") {
@@ -244,17 +208,13 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
244
208
  return;
245
209
  }
246
210
  if (command === "implement") {
247
- if (!(state.enabled && state.latestPlan?.trim()) && !state.savedPlan?.plan.trim()) {
211
+ if (!(await currentPlan())) {
248
212
  ctx.ui.notify("No completed plan is available to implement.", "warning");
249
213
  return;
250
214
  }
251
215
  await startImplementation(ctx);
252
216
  return;
253
217
  }
254
- if (command === "save") {
255
- savePlanForLater(ctx);
256
- return;
257
- }
258
218
  const exportMatch = /^export(?:\s+([\s\S]+))?$/iu.exec(prompt);
259
219
  if (exportMatch) {
260
220
  const lifecycle = captureMenuLifecycle();
@@ -262,36 +222,19 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
262
222
  return;
263
223
  }
264
224
  if (command === "exit" || command === "off") {
265
- const notification = state.activeImplementation
266
- ? "Active implementation plan cleared."
267
- : state.savedPlan
268
- ? "Saved plan cleared."
269
- : state.latestPlan
270
- ? "Plan mode disabled. Proposed plan discarded."
271
- : "Plan mode disabled.";
272
- exitPlanMode(ctx);
225
+ const hadPlan = state.planPath !== undefined;
226
+ const notification = state.enabled
227
+ ? hadPlan
228
+ ? "Plan mode disabled. Proposed plan discarded."
229
+ : "Plan mode disabled."
230
+ : hadPlan
231
+ ? "Active implementation plan cleared."
232
+ : "Plan mode disabled.";
233
+ await exitPlanMode(ctx);
273
234
  ctx.ui.notify(notification, "info");
274
235
  return;
275
236
  }
276
- if (command === "tools") {
277
- if (savedPlanBlocksNewWorkflow(ctx, state.savedPlan !== undefined && !state.enabled))
278
- return;
279
- if (state.enabled) {
280
- const message =
281
- "Plan-mode tools are locked while Planning is active. Exit Plan mode and choose tools before starting again.";
282
- if (!ctx.hasUI) throw new Error(message);
283
- ctx.ui.notify(message, "warning");
284
- return;
285
- }
286
- if (!ctx.hasUI) {
287
- throw new Error("/plan tools requires TUI or RPC mode and is unavailable here.");
288
- }
289
- await showLaunchMenu(ctx, "tools");
290
- return;
291
- }
292
237
  if (prompt) {
293
- if (savedPlanBlocksNewWorkflow(ctx, state.savedPlan !== undefined && !state.enabled))
294
- return;
295
238
  enterPlanModeWithPrompt(prompt, ctx);
296
239
  return;
297
240
  }
@@ -300,15 +243,11 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
300
243
  "The interactive /plan menu is unavailable in print and JSON modes. Use /plan start or /plan <prompt>.",
301
244
  );
302
245
  }
246
+ if (!state.enabled && state.planPath) {
247
+ await showActivePlanMenu(ctx);
248
+ return;
249
+ }
303
250
  if (!state.enabled) {
304
- if (state.activeImplementation && ctx.hasUI) {
305
- await showActivePlanMenu(ctx);
306
- return;
307
- }
308
- if (state.savedPlan) {
309
- await planActions.showSaved(ctx);
310
- return;
311
- }
312
251
  await showLaunchMenu(ctx);
313
252
  return;
314
253
  }
@@ -321,16 +260,11 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
321
260
  refreshStateBeforeFirstAgentStart = event.reason === "new";
322
261
  menuController.abort(new DOMException("Plan-mode session replaced", "AbortError"));
323
262
  menuController = new AbortController();
324
- readyPresentationIntent = undefined;
263
+ pendingReadyNonce = undefined;
325
264
  latestCommandContext = undefined;
326
- implementationRetention.reset();
327
265
  settings = { thinkingLevel: "inherit" };
328
- autoPermissionsTrustedGroups = snapshotAutoPermissionsTrustedGroups(
329
- ctx.cwd,
330
- ctx.isProjectTrusted(),
331
- );
266
+ sessionPlanPath = resolveSessionPlanPath(ctx);
332
267
  restoreState(ctx);
333
- implementationRetention.restore(state.activeImplementation);
334
268
  const loadedSettings = await (dependencies.readSettings?.() ?? readPlanModeSettings());
335
269
  if (generation !== menuGeneration || menuController.signal.aborted) return;
336
270
  if (loadedSettings.kind === "loaded") settings = loadedSettings.settings;
@@ -338,33 +272,11 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
338
272
  ctx.ui.notify(`pi-plan-mode settings ignored: ${loadedSettings.reason}`, "warning");
339
273
  }
340
274
  if (loadedSettings.notice) ctx.ui.notify(loadedSettings.notice, "warning");
341
- if (
342
- configuredBashPolicy(settings) === "auto-permissions" &&
343
- !autoPermissionsIsLoaded(safeGetAllTools())
344
- ) {
345
- ctx.ui.notify(
346
- "Plan mode bash policy is 'auto-permissions', but the Auto Permissions extension is not loaded; guarded commands stay blocked by Plan mode.",
347
- "warning",
348
- );
349
- }
350
275
  const persistFlagActivation = pi.getFlag("plan") === true && !state.enabled;
351
276
  if (persistFlagActivation) {
352
- state = state.savedPlan
353
- ? {
354
- ...state,
355
- enabled: true,
356
- latestPlan: state.savedPlan.plan,
357
- latestPlanSource: state.savedPlan.source,
358
- awaitingAction: true,
359
- savedPlan: undefined,
360
- activeImplementation: undefined,
361
- }
362
- : { ...state, enabled: true, activeImplementation: undefined };
277
+ state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
363
278
  }
364
- if (state.enabled) {
365
- activatePlanModeTools();
366
- applyPlanThinkingLevel();
367
- } else deactivatePlanModeQuestionTool();
279
+ if (state.enabled) applyPlanThinkingLevel();
368
280
  if (persistFlagActivation) persistState();
369
281
  updateUi(ctx);
370
282
  });
@@ -385,144 +297,68 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
385
297
  pi.on("session_shutdown", async (_event, ctx) => {
386
298
  menuGeneration += 1;
387
299
  menuController.abort(new DOMException("Plan-mode session shut down", "AbortError"));
388
- readyPresentationIntent = undefined;
300
+ pendingReadyNonce = undefined;
389
301
  latestCommandContext = undefined;
390
302
  refreshStateBeforeFirstAgentStart = false;
391
- autoPermissionsTrustedGroups = new Set<string>();
392
- implementationRetention.reset();
393
303
  await awaitPlanModeSettingsWrites(dependencies.settingsPath);
394
304
  captureManualThinkingLevel();
395
305
  persistState();
396
- if (state.enabled) {
397
- restoreTools();
398
- restoreThinkingLevel();
399
- }
306
+ if (state.enabled) restoreThinkingLevel();
400
307
  clearUi(ctx);
401
308
  });
402
309
 
310
+ /**
311
+ * The complete enforcement surface: three static built-in names. Plan mode
312
+ * does not classify, inspect, or filter any other tool.
313
+ */
403
314
  pi.on("tool_call", async (event) => {
404
315
  if (!state.enabled) return;
405
- if (event.toolName === "update_plan") {
406
- return {
407
- block: true,
408
- reason:
409
- "Plan mode blocks update_plan because it tracks execution progress rather than conversational planning.",
410
- };
411
- }
412
- const calledTool = toolByName(event.toolName);
413
- if (calledTool && classifyPlanModeTool(calledTool) === "blocked") {
414
- return {
415
- block: true,
416
- reason: `Plan mode blocks built-in tool '${event.toolName}' because its policy class is blocked.`,
417
- };
418
- }
419
- if (!calledTool && BLOCKED_BUILTIN_TOOLS.has(event.toolName)) {
420
- return {
421
- block: true,
422
- reason: `Plan mode blocks built-in tool '${event.toolName}' because its metadata is unavailable.`,
423
- };
424
- }
425
- // Built-in-compatible overrides retain the canonical name but replace its source metadata.
426
- if (event.toolName !== "bash") return;
427
-
428
- const command = readCommand(event.input);
429
- const blocked = findBlockedCommandSegment(command, settings.safeSubcommands);
430
- if (blocked !== undefined) {
431
- if (
432
- configuredBashPolicy(settings) === "auto-permissions" &&
433
- shouldDelegateBashToAutoPermissions(command, {
434
- tools: safeGetAllTools(),
435
- trustedGroups: autoPermissionsTrustedGroups,
436
- })
437
- ) {
438
- return;
439
- }
440
- return {
441
- block: true,
442
- reason: `Plan mode blocks bash commands outside its reviewed inspection policy or containing explicitly unsafe arguments.\nBlocked command: ${blocked}`,
443
- };
444
- }
445
- });
446
-
447
- pi.on("context", async (event, ctx) => {
448
- const result = implementationRetention.transformContext(event.messages, state);
449
- if (result.clearActiveImplementationId) {
450
- clearActiveImplementation(result.clearActiveImplementationId, ctx);
451
- }
452
- return { messages: result.messages as typeof event.messages };
316
+ if (!BLOCKED_TOOLS.has(event.toolName)) return;
317
+ return {
318
+ block: true,
319
+ reason:
320
+ event.toolName === "update_plan"
321
+ ? "Plan mode blocks update_plan because it tracks execution progress rather than conversational planning."
322
+ : `Plan mode blocks '${event.toolName}' because planning must not mutate files. Finish the plan with plan_mode_complete, then implement.`,
323
+ };
453
324
  });
454
325
 
455
326
  pi.on("before_agent_start", (event, ctx) => {
456
327
  if (refreshStateBeforeFirstAgentStart) {
457
328
  refreshStateBeforeFirstAgentStart = false;
458
329
  restoreState(ctx);
459
- implementationRetention.reset();
460
- implementationRetention.restore(state.activeImplementation);
461
- if (state.enabled) {
462
- activatePlanModeTools();
463
- applyPlanThinkingLevel();
464
- } else deactivatePlanModeQuestionTool();
330
+ if (state.enabled) applyPlanThinkingLevel();
465
331
  updateUi(ctx);
466
332
  }
467
- if (!state.enabled) return;
468
- if (state.latestPlan || state.awaitingAction) {
469
- readyPresentationIntent = undefined;
470
- state = {
471
- ...state,
472
- latestPlan: undefined,
473
- latestPlanSource: undefined,
474
- awaitingAction: false,
475
- };
333
+ if (state.enabled && state.awaitingAction) {
334
+ // A new turn supersedes the previous ready plan: revision feedback
335
+ // re-opens planning until another plan_mode_complete arrives.
336
+ pendingReadyNonce = undefined;
337
+ state = { ...state, awaitingAction: false };
476
338
  persistState();
477
339
  updateUi(ctx);
478
340
  }
479
- applyPlanModeTools();
480
- return {
481
- systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt({ bashPolicy: configuredBashPolicy(settings) })}`,
482
- };
483
- });
484
-
485
- pi.on("agent_end", async (event, ctx) => {
486
- if (!state.enabled) return;
487
-
488
- const text = latestAssistantText(event.messages);
489
- const parsedPlan = parseProposedPlan(text);
490
- if (parsedPlan.kind !== "valid") {
491
- if (parsedPlan.kind !== "absent") {
492
- ctx.ui.notify(invalidPlanMessage(parsedPlan.kind), "warning");
493
- }
494
- persistState();
495
- updateUi(ctx);
496
- return;
341
+ if (state.enabled) {
342
+ return { systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt()}` };
343
+ }
344
+ // Pointer, not payload: an active plan costs one line of context no matter
345
+ // how large the plan is, and survives compaction for free.
346
+ if (state.planPath) {
347
+ return {
348
+ systemPrompt: `${event.systemPrompt}\n\n${buildActivePlanPointer(state.planPath)}`,
349
+ };
497
350
  }
498
- acceptCompletedPlan(parsedPlan.plan, "legacy_proposed_plan", ctx);
499
351
  });
500
352
 
501
353
  onAgentSettled(pi, async (_event, ctx) => {
502
- const settledImplementationId = implementationRetention.implementationSettled(
503
- state.activeImplementation,
504
- );
505
- if (settledImplementationId) clearActiveImplementation(settledImplementationId, ctx);
506
-
507
- const intent = readyPresentationIntent;
508
- if (!intent || !readyPresentationIsCurrent(intent)) return;
354
+ const nonce = pendingReadyNonce;
355
+ if (nonce === undefined || nonce !== readyPresentationNonce) return;
356
+ if (!state.enabled || !state.awaitingAction) return;
509
357
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
510
358
 
511
- readyPresentationIntent = undefined;
359
+ pendingReadyNonce = undefined;
512
360
  try {
513
- if (intent.source === "legacy_proposed_plan") {
514
- pi.sendMessage(
515
- {
516
- customType: PROPOSED_PLAN_MESSAGE_TYPE,
517
- content: `**Proposed Plan**\n\n${intent.plan}`,
518
- display: true,
519
- },
520
- { triggerTurn: false },
521
- );
522
- }
523
- if (ctx.hasUI && completedPlanIsCurrent(intent)) {
524
- await planActions.showReady(latestCommandContext ?? ctx);
525
- }
361
+ if (ctx.hasUI) await planActions.showReady(latestCommandContext ?? ctx);
526
362
  } catch (error: unknown) {
527
363
  if (!isStaleExtensionContextError(error)) throw error;
528
364
  }
@@ -530,15 +366,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
530
366
 
531
367
  function enterPlanMode(ctx: ExtensionContext) {
532
368
  workflowGeneration += 1;
533
- if (!state.enabled) previousTools = withoutRequiredPlanModeTools(safeGetActiveTools());
534
- state = {
535
- ...state,
536
- enabled: true,
537
- awaitingAction: false,
538
- savedPlan: undefined,
539
- activeImplementation: undefined,
540
- };
541
- activatePlanModeTools();
369
+ state = { ...state, enabled: true, awaitingAction: false };
542
370
  applyPlanThinkingLevel();
543
371
  persistState();
544
372
  updateUi(ctx);
@@ -552,36 +380,31 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
552
380
  ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
553
381
  }
554
382
  if (sendPlanModeUserMessage(prompt, ctx)) return;
555
- if (!previousState.enabled) {
556
- restoreTools();
557
- restoreThinkingLevel();
558
- }
383
+ if (!wasEnabled) restoreThinkingLevel();
559
384
  state = previousState;
560
385
  persistState();
561
386
  updateUi(ctx);
562
387
  }
563
388
 
564
- function exitPlanMode(ctx: ExtensionContext) {
389
+ async function exitPlanMode(ctx: ExtensionContext, options: { keepPlanFile?: boolean } = {}) {
565
390
  workflowGeneration += 1;
566
391
  const wasEnabled = state.enabled;
567
- readyPresentationIntent = undefined;
392
+ const planPath = state.planPath;
393
+ pendingReadyNonce = undefined;
568
394
  state = {
569
395
  ...state,
570
396
  enabled: false,
571
- latestPlan: undefined,
572
- latestPlanSource: undefined,
397
+ planPath: undefined,
573
398
  awaitingAction: false,
574
- savedPlan: undefined,
575
- activeImplementation: undefined,
576
399
  manualThinkingLevel: undefined,
577
400
  };
578
401
  if (wasEnabled) {
579
- restoreTools();
580
402
  restoreThinkingLevel();
581
403
  state = { ...state, manualThinkingLevel: undefined };
582
404
  }
583
405
  persistState();
584
406
  updateUi(ctx);
407
+ if (planPath && !options.keepPlanFile) await deletePlanFile(planPath);
585
408
  }
586
409
 
587
410
  function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) {
@@ -596,48 +419,29 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
596
419
  }
597
420
  }
598
421
 
599
- function acceptCompletedPlan(plan: string, source: PlanCompletionSource, ctx: ExtensionContext) {
600
- const normalized = normalizePlanModeCompletion({ plan });
601
- if (!normalized.ok) {
602
- ctx.ui.notify(`Proposed plan is not ready: ${normalized.error}.`, "warning");
603
- persistState();
604
- updateUi(ctx);
605
- return;
606
- }
607
- if (
608
- state.enabled &&
609
- state.awaitingAction &&
610
- state.latestPlan === normalized.plan &&
611
- state.latestPlanSource === source
612
- ) {
613
- return;
422
+ /**
423
+ * Writes the durable plan file and marks the plan ready. A write failure
424
+ * keeps Plan mode active rather than silently losing the plan.
425
+ */
426
+ async function acceptCompletedPlan(plan: string, ctx: ExtensionContext) {
427
+ const planPath = sessionPlanPath ?? resolveSessionPlanPath(ctx);
428
+ sessionPlanPath = planPath;
429
+ try {
430
+ await writePlanFile(planPath, plan);
431
+ } catch (error: unknown) {
432
+ const detail = error instanceof Error ? error.message : String(error);
433
+ ctx.ui.notify(`Unable to save the plan to ${planPath}: ${detail}`, "error");
434
+ return undefined;
614
435
  }
615
- state = {
616
- ...state,
617
- latestPlan: normalized.plan,
618
- latestPlanSource: source,
619
- awaitingAction: true,
620
- };
621
- readyPresentationIntent = {
622
- nonce: ++nextReadyPresentationNonce,
623
- plan: normalized.plan,
624
- source,
625
- };
436
+ state = { ...state, planPath, awaitingAction: true };
437
+ pendingReadyNonce = ++readyPresentationNonce;
626
438
  persistState();
627
439
  updateUi(ctx);
440
+ return planPath;
628
441
  }
629
442
 
630
- function completedPlanIsCurrent(intent: ReadyPresentationIntent) {
631
- return (
632
- state.enabled &&
633
- state.awaitingAction &&
634
- state.latestPlan === intent.plan &&
635
- state.latestPlanSource === intent.source
636
- );
637
- }
638
-
639
- function readyPresentationIsCurrent(intent: ReadyPresentationIntent) {
640
- return completedPlanIsCurrent(intent) && readyPresentationIntent?.nonce === intent.nonce;
443
+ async function currentPlan() {
444
+ return state.planPath ? await readPlanFile(state.planPath) : undefined;
641
445
  }
642
446
 
643
447
  function requestFinalPlan(ctx: ExtensionContext) {
@@ -651,157 +455,61 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
651
455
  );
652
456
  }
653
457
 
654
- function savePlanForLater(ctx: ExtensionContext) {
655
- const plan = state.enabled ? state.latestPlan?.trim() : undefined;
656
- if (!plan) {
657
- const message = "No completed plan is available to save.";
658
- if (!ctx.hasUI) throw new Error(message);
659
- ctx.ui.notify(message, "warning");
660
- return;
661
- }
662
- const source = state.latestPlanSource ?? "legacy_proposed_plan";
663
-
664
- workflowGeneration += 1;
665
- readyPresentationIntent = undefined;
666
- state = {
667
- ...state,
668
- enabled: false,
669
- latestPlan: undefined,
670
- latestPlanSource: undefined,
671
- awaitingAction: false,
672
- savedPlan: { plan, source },
673
- activeImplementation: undefined,
674
- manualThinkingLevel: undefined,
675
- };
676
- restoreTools();
677
- restoreThinkingLevel();
678
- state = { ...state, manualThinkingLevel: undefined };
679
- persistState();
680
- updateUi(ctx);
681
- ctx.ui.notify("Plan saved for later. Plan mode disabled.", "info");
682
- }
683
-
684
458
  async function startFreshImplementation(ctx: ExtensionContext, menuIsCurrent: () => boolean) {
685
459
  await startFreshImplementationFromState(ctx, {
686
460
  getState: () => state,
687
461
  menuIsCurrent,
688
- retention: configuredImplementationPlanRetention(settings),
689
462
  stateEntryType: STATE_ENTRY_TYPE,
690
463
  });
691
464
  }
692
465
 
693
466
  async function startImplementation(ctx: ExtensionContext) {
694
- const savedPlan = state.enabled ? undefined : state.savedPlan;
695
- if (savedPlan) {
696
- const sessionGeneration = menuGeneration;
697
- const planWorkflowGeneration = workflowGeneration;
698
- const isCurrent = () =>
699
- sessionGeneration === menuGeneration &&
700
- planWorkflowGeneration === workflowGeneration &&
701
- !menuController.signal.aborted &&
702
- !state.enabled &&
703
- state.savedPlan === savedPlan;
704
- if (!(await preflightSavedPlanImplementation(ctx, isCurrent))) return;
705
- }
706
- const plan = (state.enabled ? state.latestPlan : savedPlan?.plan)?.trim();
707
- const source =
708
- (state.enabled ? state.latestPlanSource : savedPlan?.source) ?? "legacy_proposed_plan";
709
- if (!plan) {
710
- ctx.ui.notify("Plan mode disabled. No proposed plan is available to implement.", "warning");
467
+ const planPath = state.planPath;
468
+ const plan = await currentPlan();
469
+ if (!planPath || !plan) {
470
+ ctx.ui.notify("No completed plan is available to implement.", "warning");
711
471
  return;
712
472
  }
713
473
 
714
474
  workflowGeneration += 1;
715
475
  const previousState = state;
716
476
  const wasEnabled = state.enabled;
717
- readyPresentationIntent = undefined;
477
+ pendingReadyNonce = undefined;
718
478
  state = {
719
479
  ...state,
720
480
  enabled: false,
721
- latestPlan: undefined,
722
- latestPlanSource: undefined,
723
481
  awaitingAction: false,
724
- savedPlan: undefined,
725
- activeImplementation: {
726
- id: randomUUID(),
727
- plan,
728
- source,
729
- startedAt: Date.now(),
730
- retention: configuredImplementationPlanRetention(settings),
731
- },
482
+ planPath,
732
483
  manualThinkingLevel: undefined,
733
484
  };
734
485
  if (wasEnabled) {
735
- restoreTools();
736
486
  restoreThinkingLevel();
737
487
  state = { ...state, manualThinkingLevel: undefined };
738
488
  }
739
489
  persistState();
740
490
  updateUi(ctx);
741
491
 
742
- const sent = sendPlanModeUserMessage(formatImplementationHandoff(plan), ctx);
743
- if (!sent) {
744
- if (savedPlan) {
745
- state = previousState;
746
- } else {
747
- enterPlanMode(ctx);
748
- state = previousState;
749
- applyPlanThinkingLevel();
750
- }
492
+ if (!sendPlanModeUserMessage(formatImplementationHandoff(planPath), ctx)) {
493
+ state = previousState;
494
+ if (wasEnabled) applyPlanThinkingLevel();
751
495
  persistState();
752
496
  updateUi(ctx);
753
497
  }
754
498
  }
755
499
 
756
- function clearActiveImplementation(id: string, ctx: ExtensionContext) {
757
- if (state.activeImplementation?.id !== id) return false;
758
- workflowGeneration += 1;
759
- state = { ...state, activeImplementation: undefined };
760
- persistState();
761
- updateUi(ctx);
762
- return true;
763
- }
764
-
765
- async function showLaunchMenu(ctx: ExtensionContext, initialScreen: "main" | "tools" = "main") {
500
+ async function showLaunchMenu(ctx: ExtensionContext) {
766
501
  const lifecycle = captureMenuLifecycle();
767
502
  if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
768
503
  const ui = await loadInteractiveUi();
769
504
  if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
770
- const tools = selectableTools();
771
505
  await ui.showPlanLaunchMenu(ctx, {
772
- statusText: "Status: Off — normal tools are active.",
773
- initialScreen,
774
- getSelectedNames: () => snapshotPlanModeSelectedNames(tools, toolSelectionSnapshot()),
775
- toolSummary: (selectedNames) =>
776
- `When started: ${snapshotPlanModeToolNames(tools, selectedNames, toolSelectionSnapshot()).join(", ")}`,
777
- tools: tools.map((tool) => {
778
- const selectable = canSelectToolInPlanMode(tool);
779
- const policy = toolPolicyLabel(tool);
780
- const description = tool.description ?? "No description available";
781
- return {
782
- name: tool.name,
783
- description: `${policy} · ${description}`,
784
- searchText: [policy, description].join(" "),
785
- disabled: !selectable,
786
- disabledReason: selectable ? undefined : "Blocked by Plan-mode policy",
787
- };
788
- }),
506
+ statusText: "Status: Off.",
789
507
  ...lifecycle,
790
508
  start: (signal) => {
791
509
  if (signal.aborted || !lifecycle.isCurrent()) return;
792
510
  enterPlanMode(ctx);
793
511
  ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
794
512
  },
795
- startWithTools: (names, signal) => {
796
- if (signal.aborted || !lifecycle.isCurrent()) return;
797
- state = {
798
- ...state,
799
- selectedToolNames: filterAvailableSelectedToolNames(names, tools),
800
- selectedToolKeys: undefined,
801
- };
802
- enterPlanMode(ctx);
803
- ctx.ui.notify("Plan mode enabled with the selected tools.", "info");
804
- },
805
513
  settings: (signal) => showSettings(ctx, signal, lifecycle.isCurrent),
806
514
  });
807
515
  }
@@ -817,6 +525,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
817
525
  if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
818
526
  await ui.showActiveImplementationMenu(ctx, {
819
527
  statusText: planStatusText(),
528
+ ...(state.planPath ? { planPathLine: `Plan file: ${state.planPath}` } : {}),
820
529
  getExportDestination: () => planExports.getDestination(ctx),
821
530
  signal: lifecycle.signal,
822
531
  isCurrent: lifecycle.isCurrent,
@@ -828,8 +537,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
828
537
  ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
829
538
  },
830
539
  clear: () => {
831
- exitPlanMode(ctx);
832
- ctx.ui.notify("Active implementation plan cleared.", "info");
540
+ void exitPlanMode(ctx).then(() => {
541
+ ctx.ui.notify("Active implementation plan cleared.", "info");
542
+ });
833
543
  },
834
544
  });
835
545
  }
@@ -843,7 +553,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
843
553
  const ui = await loadInteractiveUi();
844
554
  if (!isCurrent() || signal.aborted) return false;
845
555
  const result = await ui.showPlanModeSettings(ctx, {
846
- tools: selectableTools(),
847
556
  signal,
848
557
  isCurrent,
849
558
  settingsPath: dependencies.settingsPath,
@@ -870,65 +579,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
870
579
  };
871
580
  }
872
581
 
873
- function activatePlanModeTools() {
874
- previousTools ??= withoutRequiredPlanModeTools(safeGetActiveTools());
875
- applyPlanModeTools();
876
- }
877
-
878
- function applyPlanModeTools() {
879
- pi.setActiveTools(planModeToolNames());
880
- }
881
-
882
- function planModeToolNames() {
883
- const tools = selectableTools();
884
- if (
885
- tools.length === 0 &&
886
- state.selectedToolNames === undefined &&
887
- state.selectedToolKeys === undefined &&
888
- settings.defaultPlanTools === undefined
889
- ) {
890
- return ["read", "bash", PLAN_MODE_QUESTION_TOOL_NAME, PLAN_MODE_COMPLETE_TOOL_NAME];
891
- }
892
-
893
- const selectedNames = snapshotPlanModeSelectedNames(tools, toolSelectionSnapshot());
894
- return withRequiredPlanModeTools(
895
- tools
896
- .filter((tool) => selectedNames.has(tool.name) && canSelectToolInPlanMode(tool))
897
- .map((tool) => tool.name),
898
- );
899
- }
900
-
901
- function toolSelectionSnapshot() {
902
- return {
903
- selectedToolNames: state.selectedToolNames,
904
- selectedToolKeys: state.selectedToolKeys,
905
- defaultPlanTools: settings.defaultPlanTools,
906
- };
907
- }
908
-
909
- function selectableTools() {
910
- return safeGetAllTools()
911
- .filter(
912
- (tool) =>
913
- tool.name !== PLAN_MODE_QUESTION_TOOL_NAME && tool.name !== PLAN_MODE_COMPLETE_TOOL_NAME,
914
- )
915
- .sort(compareTools);
916
- }
917
-
918
- function safeGetAllTools() {
919
- try {
920
- return pi.getAllTools();
921
- } catch {
922
- return [];
923
- }
924
- }
925
-
926
- function restoreTools() {
927
- const restoredTools = previousTools ?? DEFAULT_TOOLS;
928
- pi.setActiveTools(withoutRequiredPlanModeTools(restoredTools));
929
- previousTools = undefined;
930
- }
931
-
932
582
  function applyPlanThinkingLevel() {
933
583
  if (state.manualThinkingLevel) {
934
584
  if (pi.getThinkingLevel() !== state.manualThinkingLevel) {
@@ -976,19 +626,11 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
976
626
  state = { ...state, appliedThinkingLevel: undefined, previousThinkingLevel: undefined };
977
627
  }
978
628
 
979
- function deactivatePlanModeQuestionTool() {
980
- const activeTools = safeGetActiveTools();
981
- const filteredTools = withoutRequiredPlanModeTools(activeTools);
982
- if (filteredTools.length !== activeTools.length) {
983
- pi.setActiveTools(filteredTools);
984
- }
985
- }
986
-
987
- function safeGetActiveTools() {
629
+ function resolveSessionPlanPath(ctx: ExtensionContext) {
988
630
  try {
989
- return pi.getActiveTools();
631
+ return planFilePathForSession(ctx.sessionManager.getSessionId());
990
632
  } catch {
991
- return DEFAULT_TOOLS;
633
+ return planFilePathForSession(undefined);
992
634
  }
993
635
  }
994
636
 
@@ -997,7 +639,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
997
639
  }
998
640
 
999
641
  function updateUi(ctx: ExtensionContext) {
1000
- updatePlanModeUi(ctx, state, formatToolSummary);
642
+ updatePlanModeUi(ctx, state);
1001
643
  }
1002
644
 
1003
645
  function clearUi(ctx: ExtensionContext) {
@@ -1005,33 +647,12 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
1005
647
  }
1006
648
 
1007
649
  function planStatusText() {
1008
- return formatPlanModeStatusText(state, formatToolSummary);
1009
- }
1010
-
1011
- function implementationOutcome() {
1012
- return implementationRetentionPreview(configuredImplementationPlanRetention(settings));
1013
- }
1014
-
1015
- function formatToolSummary() {
1016
- const names = planModeToolNames();
1017
- return `Tools: ${names.length > 0 ? names.join(", ") : "none"}`;
1018
- }
1019
-
1020
- function toolByName(toolName: string) {
1021
- return safeGetAllTools().find((candidate) => candidate.name === toolName);
650
+ return formatPlanModeStatusText(state);
1022
651
  }
1023
652
  }
1024
653
 
1025
654
  export { completePlanArguments } from "./command.js";
1026
- export {
1027
- extractProposedPlan,
1028
- latestAssistantText,
1029
- parseProposedPlan,
1030
- stripProposedPlanBlocks,
1031
- stripProposedPlanBlocksFromMessage,
1032
- } from "./message-transform.js";
1033
- export { buildPlanModePrompt } from "./prompt.js";
655
+ export { planFilePathForSession, plansDirectory } from "./plan-file.js";
656
+ export { buildActivePlanPointer, buildPlanModePrompt } from "./prompt.js";
1034
657
  export { normalizePlanModeQuestionParams } from "./question-tool.js";
1035
- export { withoutPlanModeQuestionTool, withRequiredPlanModeTools } from "./required-tools.js";
1036
658
  export { normalizePlanModeSettings, readPlanModeSettings } from "./settings.js";
1037
- export { canSelectToolInPlanMode, classifyPlanModeTool, isSafeCommand } from "./tool-policy.js";