@hank-warren/pi-plan-mode 0.1.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.
@@ -0,0 +1,1037 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionCommandContext,
5
+ ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ autoPermissionsIsLoaded,
9
+ shouldDelegateBashToAutoPermissions,
10
+ snapshotAutoPermissionsTrustedGroups,
11
+ } from "./auto-permissions-delegation.js";
12
+ import { completePlanArguments } from "./command.js";
13
+ import {
14
+ normalizePlanModeCompletion,
15
+ PLAN_MODE_COMPLETE_PARAMS,
16
+ PLAN_MODE_COMPLETE_TOOL_NAME,
17
+ planModeCompleted,
18
+ renderPlanModeCompletion,
19
+ } from "./completion-tool.js";
20
+ import {
21
+ isStaleExtensionContextError,
22
+ onAgentSettled,
23
+ setPlanThinkingLevel,
24
+ } from "./extension-runtime.js";
25
+ import {
26
+ formatImplementationHandoff,
27
+ startFreshImplementationFromState,
28
+ } from "./fresh-implementation.js";
29
+ import {
30
+ createImplementationRetentionCoordinator,
31
+ implementationRetentionPreview,
32
+ } from "./implementation-retention.js";
33
+ import { invalidPlanMessage, latestAssistantText, parseProposedPlan } from "./message-transform.js";
34
+ import { createPlanActionController } from "./plan-action-controller.js";
35
+ import { createPlanExportController } from "./plan-export-controller.js";
36
+ import {
37
+ clearPlanModeUi,
38
+ planModeStatusText as formatPlanModeStatusText,
39
+ showStoredPlan,
40
+ updatePlanModeUi,
41
+ } from "./presentation.js";
42
+ import { buildPlanModePrompt } from "./prompt.js";
43
+ import {
44
+ answerPlanModeQuestions,
45
+ normalizePlanModeQuestionParams,
46
+ PLAN_MODE_QUESTION_PARAMS,
47
+ PLAN_MODE_QUESTION_TOOL_NAME,
48
+ planModeQuestionCancelled,
49
+ } 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
+ import {
56
+ awaitPlanModeSettingsWrites,
57
+ configuredBashPolicy,
58
+ configuredImplementationPlanRetention,
59
+ configuredThinkingLevel,
60
+ type PlanModeSettings,
61
+ readPlanModeSettings,
62
+ } 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";
77
+
78
+ 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
+ }
87
+ type InteractiveUi = typeof import("./interactive-ui.js");
88
+
89
+ interface PlanModeDependencies {
90
+ readSettings?(): ReturnType<typeof readPlanModeSettings>;
91
+ settingsPath?: string;
92
+ loadInteractiveUi?(): Promise<InteractiveUi>;
93
+ }
94
+ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDependencies = {}) {
95
+ let interactiveUiPromise: Promise<InteractiveUi> | undefined;
96
+ const loadInteractiveUi = () => {
97
+ if (dependencies.loadInteractiveUi) return dependencies.loadInteractiveUi();
98
+ if (!interactiveUiPromise) {
99
+ interactiveUiPromise = import("./interactive-ui.js").catch((error) => {
100
+ interactiveUiPromise = undefined;
101
+ throw error;
102
+ });
103
+ }
104
+ return interactiveUiPromise;
105
+ };
106
+ let state: PlanModeState = { enabled: false, awaitingAction: false };
107
+ let settings: PlanModeSettings = { thinkingLevel: "inherit" };
108
+ let autoPermissionsTrustedGroups = new Set<string>();
109
+ let previousTools: string[] | undefined;
110
+ let readyPresentationIntent: ReadyPresentationIntent | undefined;
111
+ let latestCommandContext: ExtensionCommandContext | undefined;
112
+ let nextReadyPresentationNonce = 0;
113
+ let menuGeneration = 0;
114
+ let workflowGeneration = 0;
115
+ let refreshStateBeforeFirstAgentStart = false;
116
+ let menuController = new AbortController();
117
+ const implementationRetention = createImplementationRetentionCoordinator();
118
+ const persistState = () => pi.appendEntry<PlanModeState>(STATE_ENTRY_TYPE, state);
119
+ const planExports = createPlanExportController({
120
+ getState: () => state,
121
+ getSettings: () => settings,
122
+ finishReady: (ctx) => exitPlanMode(ctx),
123
+ });
124
+ const planActions = createPlanActionController({
125
+ loadInteractiveUi,
126
+ getState: () => state,
127
+ captureLifecycle: captureMenuLifecycle,
128
+ statusText: planStatusText,
129
+ implementationOutcome,
130
+ getExportDestination: (ctx) => planExports.getDestination(ctx),
131
+ show: (ctx) => showStoredPlan(pi, ctx, state),
132
+ finalize: requestFinalPlan,
133
+ implementHere: startImplementation,
134
+ implementFresh: startFreshImplementation,
135
+ exportPlan: (ctx, path, signal, isCurrent) => planExports.export(path, ctx, signal, isCurrent),
136
+ settings: showSettings,
137
+ save: savePlanForLater,
138
+ stay: updateUi,
139
+ 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");
146
+ },
147
+ });
148
+
149
+ pi.registerFlag("plan", {
150
+ description: "Start in Codex-like Plan mode",
151
+ type: "boolean",
152
+ default: false,
153
+ });
154
+
155
+ pi.registerTool({
156
+ name: PLAN_MODE_QUESTION_TOOL_NAME,
157
+ label: "Plan question",
158
+ description:
159
+ "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.",
160
+ promptSnippet: "Ask user decision questions while Plan mode is active",
161
+ promptGuidelines: [
162
+ "In Plan mode, use plan_mode_question for important preferences, tradeoffs, or assumptions that cannot be discovered from read-only exploration.",
163
+ ],
164
+ parameters: PLAN_MODE_QUESTION_PARAMS,
165
+ async execute(_toolCallId, params: unknown, _signal, _onUpdate, ctx) {
166
+ if (!state.enabled) {
167
+ return planModeQuestionCancelled(
168
+ [],
169
+ "plan_mode_inactive",
170
+ "Error: plan_mode_question is only available while Plan mode is active.",
171
+ );
172
+ }
173
+
174
+ const parsed = normalizePlanModeQuestionParams(params);
175
+ if (!parsed.ok) {
176
+ return planModeQuestionCancelled([], "invalid_input", `Error: ${parsed.error}`);
177
+ }
178
+
179
+ if (!ctx.hasUI) {
180
+ return planModeQuestionCancelled(
181
+ parsed.questions,
182
+ "ui_unavailable",
183
+ "Unable to ask Plan-mode questions because interactive UI is not available.",
184
+ );
185
+ }
186
+
187
+ const sessionGeneration = menuGeneration;
188
+ const questionWorkflowGeneration = workflowGeneration;
189
+ return answerPlanModeQuestions(parsed.questions, ctx, {
190
+ isCurrent: () =>
191
+ sessionGeneration === menuGeneration && questionWorkflowGeneration === workflowGeneration,
192
+ isEnabled: () => state.enabled,
193
+ });
194
+ },
195
+ });
196
+
197
+ pi.registerTool({
198
+ name: PLAN_MODE_COMPLETE_TOOL_NAME,
199
+ label: "Complete plan",
200
+ description:
201
+ "Submit the complete decision-ready implementation plan for user review. Only available while Plan mode is active, and must be the final standalone action.",
202
+ promptSnippet: "Submit the final Plan-mode implementation plan",
203
+ promptGuidelines: [
204
+ "Call plan_mode_complete alone as the final action only after the implementation plan is decision-complete.",
205
+ ],
206
+ parameters: PLAN_MODE_COMPLETE_PARAMS,
207
+ renderResult: renderPlanModeCompletion,
208
+ async execute(_toolCallId, params: unknown, _signal, _onUpdate, ctx) {
209
+ if (!state.enabled) {
210
+ throw new Error("plan_mode_complete is only available while Plan mode is active");
211
+ }
212
+ const parsed = normalizePlanModeCompletion(params);
213
+ if (!parsed.ok) throw new Error(parsed.error);
214
+
215
+ acceptCompletedPlan(parsed.plan, PLAN_MODE_COMPLETE_TOOL_NAME, ctx);
216
+ return planModeCompleted(parsed.plan);
217
+ },
218
+ });
219
+
220
+ pi.registerCommand("plan", {
221
+ description: "Enter or manage Codex-like Plan mode",
222
+ getArgumentCompletions: completePlanArguments,
223
+ handler: async (args, ctx) => {
224
+ latestCommandContext = ctx;
225
+ const prompt = args.trim();
226
+ const command = prompt.toLowerCase();
227
+ if (command === "start") {
228
+ if (savedPlanBlocksNewWorkflow(ctx, state.savedPlan !== undefined && !state.enabled))
229
+ return;
230
+ if (state.enabled) {
231
+ ctx.ui.notify("Plan mode is already active.", "info");
232
+ return;
233
+ }
234
+ enterPlanMode(ctx);
235
+ ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
236
+ return;
237
+ }
238
+ if (command === "show") {
239
+ showStoredPlan(pi, ctx, state);
240
+ return;
241
+ }
242
+ if (command === "finalize") {
243
+ requestFinalPlan(ctx);
244
+ return;
245
+ }
246
+ if (command === "implement") {
247
+ if (!(state.enabled && state.latestPlan?.trim()) && !state.savedPlan?.plan.trim()) {
248
+ ctx.ui.notify("No completed plan is available to implement.", "warning");
249
+ return;
250
+ }
251
+ await startImplementation(ctx);
252
+ return;
253
+ }
254
+ if (command === "save") {
255
+ savePlanForLater(ctx);
256
+ return;
257
+ }
258
+ const exportMatch = /^export(?:\s+([\s\S]+))?$/iu.exec(prompt);
259
+ if (exportMatch) {
260
+ const lifecycle = captureMenuLifecycle();
261
+ await planExports.export(exportMatch[1], ctx, lifecycle.signal, lifecycle.isCurrent);
262
+ return;
263
+ }
264
+ 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);
273
+ ctx.ui.notify(notification, "info");
274
+ return;
275
+ }
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
+ if (prompt) {
293
+ if (savedPlanBlocksNewWorkflow(ctx, state.savedPlan !== undefined && !state.enabled))
294
+ return;
295
+ enterPlanModeWithPrompt(prompt, ctx);
296
+ return;
297
+ }
298
+ if (!ctx.hasUI) {
299
+ throw new Error(
300
+ "The interactive /plan menu is unavailable in print and JSON modes. Use /plan start or /plan <prompt>.",
301
+ );
302
+ }
303
+ 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
+ await showLaunchMenu(ctx);
313
+ return;
314
+ }
315
+ await planActions.showCurrent(ctx);
316
+ },
317
+ });
318
+
319
+ pi.on("session_start", async (event, ctx) => {
320
+ const generation = ++menuGeneration;
321
+ refreshStateBeforeFirstAgentStart = event.reason === "new";
322
+ menuController.abort(new DOMException("Plan-mode session replaced", "AbortError"));
323
+ menuController = new AbortController();
324
+ readyPresentationIntent = undefined;
325
+ latestCommandContext = undefined;
326
+ implementationRetention.reset();
327
+ settings = { thinkingLevel: "inherit" };
328
+ autoPermissionsTrustedGroups = snapshotAutoPermissionsTrustedGroups(
329
+ ctx.cwd,
330
+ ctx.isProjectTrusted(),
331
+ );
332
+ restoreState(ctx);
333
+ implementationRetention.restore(state.activeImplementation);
334
+ const loadedSettings = await (dependencies.readSettings?.() ?? readPlanModeSettings());
335
+ if (generation !== menuGeneration || menuController.signal.aborted) return;
336
+ if (loadedSettings.kind === "loaded") settings = loadedSettings.settings;
337
+ else if (loadedSettings.kind === "invalid") {
338
+ ctx.ui.notify(`pi-plan-mode settings ignored: ${loadedSettings.reason}`, "warning");
339
+ }
340
+ 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
+ const persistFlagActivation = pi.getFlag("plan") === true && !state.enabled;
351
+ 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 };
363
+ }
364
+ if (state.enabled) {
365
+ activatePlanModeTools();
366
+ applyPlanThinkingLevel();
367
+ } else deactivatePlanModeQuestionTool();
368
+ if (persistFlagActivation) persistState();
369
+ updateUi(ctx);
370
+ });
371
+
372
+ pi.on("thinking_level_select", (event) => {
373
+ if (!state.enabled || !state.appliedThinkingLevel) return;
374
+ if (event.level !== state.appliedThinkingLevel) {
375
+ state = {
376
+ ...state,
377
+ manualThinkingLevel: event.level,
378
+ previousThinkingLevel: undefined,
379
+ appliedThinkingLevel: undefined,
380
+ };
381
+ persistState();
382
+ }
383
+ });
384
+
385
+ pi.on("session_shutdown", async (_event, ctx) => {
386
+ menuGeneration += 1;
387
+ menuController.abort(new DOMException("Plan-mode session shut down", "AbortError"));
388
+ readyPresentationIntent = undefined;
389
+ latestCommandContext = undefined;
390
+ refreshStateBeforeFirstAgentStart = false;
391
+ autoPermissionsTrustedGroups = new Set<string>();
392
+ implementationRetention.reset();
393
+ await awaitPlanModeSettingsWrites(dependencies.settingsPath);
394
+ captureManualThinkingLevel();
395
+ persistState();
396
+ if (state.enabled) {
397
+ restoreTools();
398
+ restoreThinkingLevel();
399
+ }
400
+ clearUi(ctx);
401
+ });
402
+
403
+ pi.on("tool_call", async (event) => {
404
+ 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 };
453
+ });
454
+
455
+ pi.on("before_agent_start", (event, ctx) => {
456
+ if (refreshStateBeforeFirstAgentStart) {
457
+ refreshStateBeforeFirstAgentStart = false;
458
+ restoreState(ctx);
459
+ implementationRetention.reset();
460
+ implementationRetention.restore(state.activeImplementation);
461
+ if (state.enabled) {
462
+ activatePlanModeTools();
463
+ applyPlanThinkingLevel();
464
+ } else deactivatePlanModeQuestionTool();
465
+ updateUi(ctx);
466
+ }
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
+ };
476
+ persistState();
477
+ updateUi(ctx);
478
+ }
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;
497
+ }
498
+ acceptCompletedPlan(parsedPlan.plan, "legacy_proposed_plan", ctx);
499
+ });
500
+
501
+ 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;
509
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
510
+
511
+ readyPresentationIntent = undefined;
512
+ 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
+ }
526
+ } catch (error: unknown) {
527
+ if (!isStaleExtensionContextError(error)) throw error;
528
+ }
529
+ });
530
+
531
+ function enterPlanMode(ctx: ExtensionContext) {
532
+ 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();
542
+ applyPlanThinkingLevel();
543
+ persistState();
544
+ updateUi(ctx);
545
+ }
546
+
547
+ function enterPlanModeWithPrompt(prompt: string, ctx: ExtensionContext) {
548
+ const previousState = state;
549
+ const wasEnabled = state.enabled;
550
+ enterPlanMode(ctx);
551
+ if (!wasEnabled) {
552
+ ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
553
+ }
554
+ if (sendPlanModeUserMessage(prompt, ctx)) return;
555
+ if (!previousState.enabled) {
556
+ restoreTools();
557
+ restoreThinkingLevel();
558
+ }
559
+ state = previousState;
560
+ persistState();
561
+ updateUi(ctx);
562
+ }
563
+
564
+ function exitPlanMode(ctx: ExtensionContext) {
565
+ workflowGeneration += 1;
566
+ const wasEnabled = state.enabled;
567
+ readyPresentationIntent = undefined;
568
+ state = {
569
+ ...state,
570
+ enabled: false,
571
+ latestPlan: undefined,
572
+ latestPlanSource: undefined,
573
+ awaitingAction: false,
574
+ savedPlan: undefined,
575
+ activeImplementation: undefined,
576
+ manualThinkingLevel: undefined,
577
+ };
578
+ if (wasEnabled) {
579
+ restoreTools();
580
+ restoreThinkingLevel();
581
+ state = { ...state, manualThinkingLevel: undefined };
582
+ }
583
+ persistState();
584
+ updateUi(ctx);
585
+ }
586
+
587
+ function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) {
588
+ try {
589
+ if (ctx.isIdle()) pi.sendUserMessage(message);
590
+ else pi.sendUserMessage(message, { deliverAs: "followUp" });
591
+ return true;
592
+ } catch (error: unknown) {
593
+ const detail = error instanceof Error ? error.message : String(error);
594
+ ctx.ui.notify(`Unable to send Plan-mode message: ${detail}`, "error");
595
+ return false;
596
+ }
597
+ }
598
+
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;
614
+ }
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
+ };
626
+ persistState();
627
+ updateUi(ctx);
628
+ }
629
+
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;
641
+ }
642
+
643
+ function requestFinalPlan(ctx: ExtensionContext) {
644
+ if (!state.enabled) {
645
+ ctx.ui.notify("Plan mode is not active. Use /plan first.", "warning");
646
+ return;
647
+ }
648
+ sendPlanModeUserMessage(
649
+ "Finalize the current implementation plan now. If any material decision remains, use plan_mode_question instead. Otherwise call plan_mode_complete alone as your final action with the complete decision-ready plan.",
650
+ ctx,
651
+ );
652
+ }
653
+
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
+ async function startFreshImplementation(ctx: ExtensionContext, menuIsCurrent: () => boolean) {
685
+ await startFreshImplementationFromState(ctx, {
686
+ getState: () => state,
687
+ menuIsCurrent,
688
+ retention: configuredImplementationPlanRetention(settings),
689
+ stateEntryType: STATE_ENTRY_TYPE,
690
+ });
691
+ }
692
+
693
+ 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");
711
+ return;
712
+ }
713
+
714
+ workflowGeneration += 1;
715
+ const previousState = state;
716
+ const wasEnabled = state.enabled;
717
+ readyPresentationIntent = undefined;
718
+ state = {
719
+ ...state,
720
+ enabled: false,
721
+ latestPlan: undefined,
722
+ latestPlanSource: undefined,
723
+ awaitingAction: false,
724
+ savedPlan: undefined,
725
+ activeImplementation: {
726
+ id: randomUUID(),
727
+ plan,
728
+ source,
729
+ startedAt: Date.now(),
730
+ retention: configuredImplementationPlanRetention(settings),
731
+ },
732
+ manualThinkingLevel: undefined,
733
+ };
734
+ if (wasEnabled) {
735
+ restoreTools();
736
+ restoreThinkingLevel();
737
+ state = { ...state, manualThinkingLevel: undefined };
738
+ }
739
+ persistState();
740
+ updateUi(ctx);
741
+
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
+ }
751
+ persistState();
752
+ updateUi(ctx);
753
+ }
754
+ }
755
+
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") {
766
+ const lifecycle = captureMenuLifecycle();
767
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
768
+ const ui = await loadInteractiveUi();
769
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
770
+ const tools = selectableTools();
771
+ 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
+ }),
789
+ ...lifecycle,
790
+ start: (signal) => {
791
+ if (signal.aborted || !lifecycle.isCurrent()) return;
792
+ enterPlanMode(ctx);
793
+ ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
794
+ },
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
+ settings: (signal) => showSettings(ctx, signal, lifecycle.isCurrent),
806
+ });
807
+ }
808
+
809
+ async function showActivePlanMenu(ctx: ExtensionContext) {
810
+ if (!ctx.hasUI) {
811
+ ctx.ui.notify(planStatusText(), "info");
812
+ return;
813
+ }
814
+ const lifecycle = captureMenuLifecycle();
815
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
816
+ const ui = await loadInteractiveUi();
817
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
818
+ await ui.showActiveImplementationMenu(ctx, {
819
+ statusText: planStatusText(),
820
+ getExportDestination: () => planExports.getDestination(ctx),
821
+ signal: lifecycle.signal,
822
+ isCurrent: lifecycle.isCurrent,
823
+ show: () => showStoredPlan(pi, ctx, state),
824
+ exportPlan: (path, signal) => planExports.export(path, ctx, signal, lifecycle.isCurrent),
825
+ settings: (signal) => showSettings(ctx, signal, lifecycle.isCurrent),
826
+ startNew: () => {
827
+ enterPlanMode(ctx);
828
+ ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
829
+ },
830
+ clear: () => {
831
+ exitPlanMode(ctx);
832
+ ctx.ui.notify("Active implementation plan cleared.", "info");
833
+ },
834
+ });
835
+ }
836
+
837
+ async function showSettings(
838
+ ctx: ExtensionContext,
839
+ signal: AbortSignal,
840
+ isCurrent: () => boolean,
841
+ ) {
842
+ if (!isCurrent() || signal.aborted) return false;
843
+ const ui = await loadInteractiveUi();
844
+ if (!isCurrent() || signal.aborted) return false;
845
+ const result = await ui.showPlanModeSettings(ctx, {
846
+ tools: selectableTools(),
847
+ signal,
848
+ isCurrent,
849
+ settingsPath: dependencies.settingsPath,
850
+ onSaved: (saved) => {
851
+ if (isCurrent()) settings = saved;
852
+ },
853
+ ...(dependencies.readSettings
854
+ ? { readSettings: async () => dependencies.readSettings?.() ?? { kind: "missing" } }
855
+ : {}),
856
+ });
857
+ return result.kind === "closed" && "reason" in result && result.reason === "close";
858
+ }
859
+
860
+ function captureMenuLifecycle() {
861
+ const sessionGeneration = menuGeneration;
862
+ const planWorkflowGeneration = workflowGeneration;
863
+ const controller = menuController;
864
+ return {
865
+ signal: controller.signal,
866
+ isCurrent: () =>
867
+ sessionGeneration === menuGeneration &&
868
+ planWorkflowGeneration === workflowGeneration &&
869
+ !controller.signal.aborted,
870
+ };
871
+ }
872
+
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
+ function applyPlanThinkingLevel() {
933
+ if (state.manualThinkingLevel) {
934
+ if (pi.getThinkingLevel() !== state.manualThinkingLevel) {
935
+ setPlanThinkingLevel(pi, state.manualThinkingLevel);
936
+ }
937
+ return;
938
+ }
939
+ const configured = configuredThinkingLevel(settings);
940
+ if (!configured) {
941
+ state = {
942
+ ...state,
943
+ previousThinkingLevel: undefined,
944
+ appliedThinkingLevel: undefined,
945
+ };
946
+ return;
947
+ }
948
+ const current = pi.getThinkingLevel();
949
+ if (!state.appliedThinkingLevel) state.previousThinkingLevel = current;
950
+ if (current !== configured) setPlanThinkingLevel(pi, configured);
951
+ state.appliedThinkingLevel = pi.getThinkingLevel();
952
+ }
953
+
954
+ function captureManualThinkingLevel() {
955
+ if (!state.appliedThinkingLevel) return;
956
+ const current = pi.getThinkingLevel();
957
+ if (current === state.appliedThinkingLevel) return;
958
+ state = {
959
+ ...state,
960
+ manualThinkingLevel: current,
961
+ previousThinkingLevel: undefined,
962
+ appliedThinkingLevel: undefined,
963
+ };
964
+ }
965
+
966
+ function restoreThinkingLevel() {
967
+ captureManualThinkingLevel();
968
+ const { appliedThinkingLevel, previousThinkingLevel } = state;
969
+ if (
970
+ appliedThinkingLevel &&
971
+ previousThinkingLevel &&
972
+ pi.getThinkingLevel() === appliedThinkingLevel
973
+ ) {
974
+ setPlanThinkingLevel(pi, previousThinkingLevel);
975
+ }
976
+ state = { ...state, appliedThinkingLevel: undefined, previousThinkingLevel: undefined };
977
+ }
978
+
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() {
988
+ try {
989
+ return pi.getActiveTools();
990
+ } catch {
991
+ return DEFAULT_TOOLS;
992
+ }
993
+ }
994
+
995
+ function restoreState(ctx: ExtensionContext) {
996
+ state = restorePlanModeState(ctx.sessionManager.getBranch(), STATE_ENTRY_TYPE);
997
+ }
998
+
999
+ function updateUi(ctx: ExtensionContext) {
1000
+ updatePlanModeUi(ctx, state, formatToolSummary);
1001
+ }
1002
+
1003
+ function clearUi(ctx: ExtensionContext) {
1004
+ clearPlanModeUi(ctx);
1005
+ }
1006
+
1007
+ 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);
1022
+ }
1023
+ }
1024
+
1025
+ 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";
1034
+ export { normalizePlanModeQuestionParams } from "./question-tool.js";
1035
+ export { withoutPlanModeQuestionTool, withRequiredPlanModeTools } from "./required-tools.js";
1036
+ export { normalizePlanModeSettings, readPlanModeSettings } from "./settings.js";
1037
+ export { canSelectToolInPlanMode, classifyPlanModeTool, isSafeCommand } from "./tool-policy.js";