@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/CHANGELOG.md +25 -0
- package/package.json +2 -2
- package/src/command.ts +1 -1
- package/src/completion-tool.ts +11 -6
- package/src/extension-runtime.ts +0 -11
- package/src/fresh-implementation.ts +2 -2
- package/src/lifecycle.ts +79 -0
- package/src/plan-export.ts +4 -4
- package/src/plan-file.ts +1 -5
- package/src/plan-launch-menu.ts +1 -1
- package/src/plan-mode.ts +174 -213
- package/src/presentation.ts +54 -12
- package/src/prompt.ts +26 -6
- package/src/question-tool.ts +26 -10
- package/src/settings-menu.ts +6 -48
- package/src/settings-watch.ts +60 -0
- package/src/settings.ts +12 -69
- package/src/state.ts +0 -44
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 {
|
|
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
|
|
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
|
-
*
|
|
100
|
-
*
|
|
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
|
|
103
|
-
if (preferred === PLAN_MODE_QUESTION_TOOL) return;
|
|
77
|
+
function preferredQuestionTool(pi: ExtensionAPI): string | null {
|
|
104
78
|
const active = pi.getActiveTools();
|
|
105
|
-
if (
|
|
106
|
-
|
|
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
|
-
|
|
139
|
-
let
|
|
140
|
-
let
|
|
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:
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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,
|
|
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
|
|
214
|
-
const
|
|
215
|
-
return answerPlanModeQuestions(
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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
|
|
281
|
-
await planExports.export(exportMatch[1], ctx,
|
|
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
|
|
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 (
|
|
342
|
+
const loadPlanModeSettings = async (session: LifecycleScope, ctx?: ExtensionContext) => {
|
|
333
343
|
const loaded = await readRuntimeSettings();
|
|
334
|
-
if (
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
settingsReloadTimer = undefined;
|
|
348
|
-
}
|
|
349
|
-
settingsWatch?.close();
|
|
350
|
-
settingsWatch = undefined;
|
|
354
|
+
settingsWatcher?.stop();
|
|
355
|
+
settingsWatcher = undefined;
|
|
351
356
|
};
|
|
352
357
|
|
|
353
|
-
/**
|
|
354
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
persistState();
|
|
444
|
-
updateUi(ctx);
|
|
429
|
+
setState(ctx, { awaitingAction: false });
|
|
445
430
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
478
|
+
lifecycle.nextWorkflow();
|
|
499
479
|
const planPath = state.planPath;
|
|
500
480
|
pendingReadyNonce = undefined;
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
return undefined;
|
|
537
|
+
throw new Error(`Unable to save the plan to ${planPath}: ${detail}`);
|
|
537
538
|
}
|
|
538
|
-
|
|
539
|
+
sessionPlanPath = planPath;
|
|
539
540
|
pendingReadyNonce = ++readyPresentationNonce;
|
|
540
|
-
|
|
541
|
-
|
|
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,
|
|
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
|
-
|
|
581
|
+
lifecycle.nextWorkflow();
|
|
577
582
|
const previousState = state;
|
|
578
583
|
pendingReadyNonce = undefined;
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
597
|
-
if (!
|
|
589
|
+
const menu = lifecycle.capture();
|
|
590
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
598
591
|
const ui = await loadInteractiveUi();
|
|
599
|
-
if (!
|
|
592
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
600
593
|
await ui.showPlanLaunchMenu(ctx, {
|
|
601
594
|
statusText: "Status: Off.",
|
|
602
|
-
|
|
595
|
+
signal: menu.signal,
|
|
596
|
+
isCurrent: menu.isCurrent,
|
|
603
597
|
start: (signal) => {
|
|
604
|
-
if (signal.aborted || !
|
|
598
|
+
if (signal.aborted || !menu.isCurrent()) return;
|
|
605
599
|
enterPlanMode(ctx);
|
|
606
|
-
ctx
|
|
600
|
+
notifyEnabled(ctx);
|
|
607
601
|
},
|
|
608
|
-
settings: (signal) => showSettings(ctx, signal,
|
|
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
|
|
618
|
-
if (!
|
|
611
|
+
const menu = lifecycle.capture();
|
|
612
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
619
613
|
const ui = await loadInteractiveUi();
|
|
620
|
-
if (!
|
|
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:
|
|
626
|
-
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,
|
|
629
|
-
settings: (signal) => showSettings(ctx, signal,
|
|
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
|
|
626
|
+
notifyEnabled(ctx);
|
|
633
627
|
},
|
|
634
628
|
clear: () => {
|
|
635
|
-
void
|
|
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());
|