@hank-warren/pi-plan-mode 1.5.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # @hank-warren/pi-plan-mode
2
2
 
3
+ ## 1.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d0c46a5: Drop two legacy paths that were past their delete-by date:
8
+
9
+ - The one-shot repair of a thinking level left raised by a pre-1.3.0 session, and the "thinkingLevel is no longer used" row in the settings menu. An unknown `thinkingLevel` key in `pi-plan-mode.json` is still preserved verbatim on save.
10
+ - The `plan-mode.json` settings fallback and its "Using legacy…" / "ignored because…" notices. Only `$PI_CODING_AGENT_DIR/pi-plan-mode.json` is read now; a host still on the old filename gets defaults and should rename the file.
11
+
12
+ Internally, the settings watcher and the menu/workflow lifecycle moved into their own modules and the state transitions share one helper; nothing else about `/plan` changed. `engines.node` now states Pi's own floor, `>=22.19.0`.
13
+
3
14
  ## 1.5.0
4
15
 
5
16
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-plan-mode",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Plan mode for Pi: research and design with a durable plan file that survives compaction.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-plan-mode#readme",
24
24
  "engines": {
25
- "node": ">=18.0.0"
25
+ "node": ">=22.19.0"
26
26
  },
27
27
  "pi": {
28
28
  "extensions": [
package/src/command.ts CHANGED
@@ -1,4 +1,4 @@
1
- export interface CommandArgumentCompletion {
1
+ interface CommandArgumentCompletion {
2
2
  value: string;
3
3
  label: string;
4
4
  description?: string;
@@ -2,10 +2,10 @@ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
2
  import { Markdown } from "@earendil-works/pi-tui";
3
3
 
4
4
  export const PLAN_MODE_COMPLETE_TOOL_NAME = "plan_mode_complete";
5
- export const PLAN_MODE_COMPLETE_VERSION = 1;
6
- export const PLAN_MODE_MAX_CHARS = 50_000;
5
+ const PLAN_MODE_COMPLETE_VERSION = 1;
6
+ const PLAN_MODE_MAX_CHARS = 50_000;
7
7
 
8
- export type PlanModeCompletionDetails = {
8
+ type PlanModeCompletionDetails = {
9
9
  version: typeof PLAN_MODE_COMPLETE_VERSION;
10
10
  source: typeof PLAN_MODE_COMPLETE_TOOL_NAME;
11
11
  plan: string;
@@ -43,7 +43,7 @@ export function normalizePlanModeCompletion(input: unknown): NormalizePlanModeCo
43
43
  return { ok: true, plan };
44
44
  }
45
45
 
46
- export function planFromCompletionDetails(value: unknown) {
46
+ function planFromCompletionDetails(value: unknown) {
47
47
  if (!isRecord(value)) return undefined;
48
48
  if (
49
49
  value.version !== PLAN_MODE_COMPLETE_VERSION ||
@@ -78,7 +78,7 @@ type PlanModeCompletionRenderResult = {
78
78
  details?: unknown;
79
79
  };
80
80
 
81
- export function planModeCompletionMarkdown(result: PlanModeCompletionRenderResult) {
81
+ function planModeCompletionMarkdown(result: PlanModeCompletionRenderResult) {
82
82
  const content = result.content
83
83
  .filter((block) => block.type === "text" && typeof block.text === "string")
84
84
  .map((block) => block.text)
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { LegacyThinkingCapture } from "./state.js";
3
2
 
4
3
  type AgentSettledHandler = (event: unknown, ctx: ExtensionContext) => unknown;
5
4
 
@@ -11,16 +10,6 @@ export function onAgentSettled(pi: ExtensionAPI, handler: AgentSettledHandler) {
11
10
  ).on("agent_settled", handler);
12
11
  }
13
12
 
14
- /**
15
- * Only the one-shot migration that undoes a pre-1.3.0 thinking-level change
16
- * still calls this. Plan mode never sets the thinking level otherwise.
17
- *
18
- * legacy: delete in 1.4.0
19
- */
20
- export function setPlanThinkingLevel(pi: ExtensionAPI, level: LegacyThinkingCapture["previous"]) {
21
- (pi.setThinkingLevel as unknown as (level: string) => void)(level);
22
- }
23
-
24
13
  export function isStaleExtensionContextError(error: unknown) {
25
14
  return (
26
15
  error instanceof Error &&
@@ -5,7 +5,7 @@ import type { PlanModeState } from "./state.js";
5
5
  type NewSessionOptions = Exclude<Parameters<ExtensionCommandContext["newSession"]>[0], undefined>;
6
6
  type ReplacementContext = Parameters<NonNullable<NewSessionOptions["withSession"]>>[0];
7
7
 
8
- export interface FreshImplementationRequest {
8
+ interface FreshImplementationRequest {
9
9
  plan: string;
10
10
  planPath: string;
11
11
  stateEntryType: string;
@@ -18,7 +18,7 @@ interface FreshImplementationFromStateOptions {
18
18
  stateEntryType: string;
19
19
  }
20
20
 
21
- export type FreshImplementationResult =
21
+ type FreshImplementationResult =
22
22
  | { kind: "started" }
23
23
  | { kind: "cancelled" }
24
24
  | { kind: "partial" }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A scope captured when deferred work starts: the signal that work should race
3
+ * against, and the question "is what I was started for still the current
4
+ * thing?".
5
+ */
6
+ export interface LifecycleScope {
7
+ readonly signal: AbortSignal;
8
+ isCurrent(): boolean;
9
+ }
10
+
11
+ /**
12
+ * Two nested generations decide whether deferred Plan-mode work may still act.
13
+ *
14
+ * The session generation moves when Pi replaces or shuts down the session: a
15
+ * menu, a settings reload, or a question left waiting from the previous session
16
+ * must never write to the new one. The workflow generation moves on every
17
+ * enter/exit/implement, so a menu opened against one plan cannot act after the
18
+ * user has moved on — while a settings reload, which belongs to the session
19
+ * rather than to a plan, is deliberately left alone by it.
20
+ *
21
+ * The abort signal is the second half of the same rule: it stops work that is
22
+ * already blocked on the UI, where a generation check would never be reached.
23
+ */
24
+ export function createLifecycle() {
25
+ let sessionGeneration = 0;
26
+ let workflowGeneration = 0;
27
+ let controller = new AbortController();
28
+
29
+ const sessionScope = (): LifecycleScope => {
30
+ const session = sessionGeneration;
31
+ const active = controller;
32
+ return {
33
+ signal: active.signal,
34
+ isCurrent: () => session === sessionGeneration && !active.signal.aborted,
35
+ };
36
+ };
37
+
38
+ /**
39
+ * Ends the current session: everything captured before this call goes stale
40
+ * and everything waiting on the signal is aborted with `reason`. The aborted
41
+ * signal stays in place, so anything captured *after* it is stale too —
42
+ * which is what a shut-down session wants: there is no next session to be
43
+ * current for, and a menu opened in that window must refuse to run.
44
+ */
45
+ const endSession = (reason: string) => {
46
+ sessionGeneration += 1;
47
+ controller.abort(new DOMException(reason, "AbortError"));
48
+ };
49
+
50
+ return {
51
+ /** The live session signal, for composing with a caller's own. */
52
+ get signal() {
53
+ return controller.signal;
54
+ },
55
+ endSession,
56
+ /**
57
+ * Ends the current session and opens the next one, whose scope is
58
+ * returned: work started from here races against a fresh signal.
59
+ */
60
+ nextSession(reason: string): LifecycleScope {
61
+ endSession(reason);
62
+ controller = new AbortController();
63
+ return sessionScope();
64
+ },
65
+ /** Supersedes menus and prompts opened against the previous plan state. */
66
+ nextWorkflow() {
67
+ workflowGeneration += 1;
68
+ },
69
+ /** The scope for menu-scale work: stale as soon as either generation moves. */
70
+ capture(): LifecycleScope {
71
+ const session = sessionScope();
72
+ const workflow = workflowGeneration;
73
+ return {
74
+ signal: session.signal,
75
+ isCurrent: () => session.isCurrent() && workflow === workflowGeneration,
76
+ };
77
+ },
78
+ };
79
+ }
@@ -8,7 +8,7 @@ import type { PlanModeState } from "./state.js";
8
8
 
9
9
  export { DEFAULT_PLAN_EXPORT_PATH };
10
10
 
11
- export interface PlanExportResult {
11
+ interface PlanExportResult {
12
12
  path: string;
13
13
  }
14
14
 
@@ -17,7 +17,7 @@ export interface PlanExportDestination {
17
17
  resolvedPath: string;
18
18
  }
19
19
 
20
- export interface PlanExportLifecycle {
20
+ interface PlanExportLifecycle {
21
21
  signal: AbortSignal;
22
22
  isCurrent(): boolean;
23
23
  getState?(): PlanModeState;
@@ -74,7 +74,7 @@ export async function exportStoredPlan(
74
74
  return true;
75
75
  }
76
76
 
77
- export async function exportPlanToFile(
77
+ async function exportPlanToFile(
78
78
  plan: string,
79
79
  requestedPath: string | undefined,
80
80
  cwd: string,
@@ -109,7 +109,7 @@ export function planExportDestination(defaultPath: string, cwd: string): PlanExp
109
109
  };
110
110
  }
111
111
 
112
- export function resolvePlanExportPath(
112
+ function resolvePlanExportPath(
113
113
  requestedPath: string | undefined,
114
114
  cwd: string,
115
115
  defaultPath = DEFAULT_PLAN_EXPORT_PATH,
package/src/plan-file.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdir, open, rename, rm, unlink, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
- export const PLANS_DIRECTORY = "plans";
7
+ const PLANS_DIRECTORY = "plans";
8
8
  const MAX_PLAN_BYTES = 1024 * 1024;
9
9
 
10
10
  /**
@@ -79,7 +79,3 @@ export async function readPlanFile(path: string): Promise<string | undefined> {
79
79
  export async function deletePlanFile(path: string): Promise<void> {
80
80
  await unlink(path).catch(() => undefined);
81
81
  }
82
-
83
- export async function planFileExists(path: string): Promise<boolean> {
84
- return (await readPlanFile(path)) !== undefined;
85
- }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
3
 
4
- export interface PlanLaunchMenuOptions {
4
+ interface PlanLaunchMenuOptions {
5
5
  statusText: string;
6
6
  signal: AbortSignal;
7
7
  isCurrent(): boolean;
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,15 +11,12 @@ 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";
@@ -52,7 +47,8 @@ import {
52
47
  planModeSettingsPath,
53
48
  readPlanModeSettings,
54
49
  } from "./settings.js";
55
- import { type PlanModeState, readLegacyThinkingCapture, restorePlanModeState } from "./state.js";
50
+ import { createSettingsWatcher } from "./settings-watch.js";
51
+ import { type PlanModeState, restorePlanModeState } from "./state.js";
56
52
 
57
53
  const STATE_ENTRY_TYPE = "plan-mode-state";
58
54
  const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
@@ -67,11 +63,7 @@ const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
67
63
  * was once listed here; it was a pre-1.0 upstream tool that no longer exists.)
68
64
  */
69
65
  const BLOCKED_TOOLS = new Set(["edit", "write"]);
70
- /**
71
- * One hand-edit or menu save fans out into several filesystem events (temp file
72
- * created, renamed into place). Collapsing them into one re-read keeps a save
73
- * to a single load.
74
- */
66
+ /** Long enough to collapse one save's burst of filesystem events into one read. */
75
67
  const SETTINGS_RELOAD_DEBOUNCE_MS = 75;
76
68
 
77
69
  /**
@@ -115,12 +107,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
115
107
  let readyPresentationNonce = 0;
116
108
  let pendingReadyNonce: number | undefined;
117
109
  let latestCommandContext: ExtensionCommandContext | undefined;
118
- let menuGeneration = 0;
119
- let workflowGeneration = 0;
120
110
  let refreshStateBeforeFirstAgentStart = false;
121
- let menuController = new AbortController();
122
- let settingsWatch: ReturnType<typeof watch> | undefined;
123
- let settingsReloadTimer: ReturnType<typeof setTimeout> | undefined;
111
+ const lifecycle = createLifecycle();
112
+ let settingsWatcher: ReturnType<typeof createSettingsWatcher> | undefined;
124
113
  let planToolsActivated = false;
125
114
  let currentHasUI = false;
126
115
  let globalQuestionAvailable = false;
@@ -166,7 +155,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
166
155
  const planActions = createPlanActionController({
167
156
  loadInteractiveUi,
168
157
  getState: () => state,
169
- captureLifecycle: captureMenuLifecycle,
158
+ captureLifecycle: () => lifecycle.capture(),
170
159
  statusText: planStatusText,
171
160
  planPathLine: () => (state.planPath ? `Plan file: ${state.planPath}` : undefined),
172
161
  getExportDestination: (ctx) => planExports.getDestination(ctx),
@@ -179,13 +168,11 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
179
168
  exitReady: (ctx) => {
180
169
  // Same had-plan branching as the /plan exit command: the menu must not
181
170
  // claim a plan was discarded when none was ever completed.
182
- const hadPlan = state.planPath !== undefined;
183
- void exitPlanMode(ctx).then(() => {
184
- ctx.ui.notify(
185
- hadPlan ? "Plan mode disabled. Proposed plan discarded." : "Plan mode disabled.",
186
- "info",
187
- );
188
- });
171
+ const text =
172
+ state.planPath !== undefined
173
+ ? "Plan mode disabled. Proposed plan discarded."
174
+ : "Plan mode disabled.";
175
+ void exitAndNotify(ctx, text);
189
176
  },
190
177
  });
191
178
 
@@ -229,20 +216,12 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
229
216
  );
230
217
  }
231
218
 
232
- const sessionGeneration = menuGeneration;
233
- const questionWorkflowGeneration = workflowGeneration;
234
- const questionSignal = signal
235
- ? AbortSignal.any([signal, menuController.signal])
236
- : menuController.signal;
219
+ const menu = lifecycle.capture();
220
+ const questionSignal = signal ? AbortSignal.any([signal, menu.signal]) : menu.signal;
237
221
  return answerPlanModeQuestions(
238
222
  parsed.questions,
239
223
  ctx,
240
- {
241
- isCurrent: () =>
242
- sessionGeneration === menuGeneration &&
243
- questionWorkflowGeneration === workflowGeneration,
244
- isEnabled: () => state.enabled,
245
- },
224
+ { isCurrent: menu.isCurrent, isEnabled: () => state.enabled },
246
225
  questionSignal,
247
226
  );
248
227
  },
@@ -288,7 +267,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
288
267
  return;
289
268
  }
290
269
  enterPlanMode(ctx);
291
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
270
+ notifyEnabled(ctx);
292
271
  return;
293
272
  }
294
273
  if (command === "show") {
@@ -309,8 +288,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
309
288
  }
310
289
  const exportMatch = /^export(?:\s+([\s\S]+))?$/iu.exec(prompt);
311
290
  if (exportMatch) {
312
- const lifecycle = captureMenuLifecycle();
313
- await planExports.export(exportMatch[1], ctx, lifecycle.signal, lifecycle.isCurrent);
291
+ const menu = lifecycle.capture();
292
+ await planExports.export(exportMatch[1], ctx, menu.signal, menu.isCurrent);
314
293
  return;
315
294
  }
316
295
  if (command === "exit" || command === "off") {
@@ -322,8 +301,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
322
301
  : hadPlan
323
302
  ? "Active implementation plan cleared."
324
303
  : "Plan mode disabled.";
325
- await exitPlanMode(ctx);
326
- ctx.ui.notify(notification, "info");
304
+ await exitAndNotify(ctx, notification);
327
305
  return;
328
306
  }
329
307
  if (prompt) {
@@ -361,76 +339,48 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
361
339
  * `ctx` to explain why, would be worse than waiting for the next write. A
362
340
  * genuinely broken file is still reported at the next session start.
363
341
  */
364
- const loadPlanModeSettings = async (generation: number, ctx?: ExtensionContext) => {
342
+ const loadPlanModeSettings = async (session: LifecycleScope, ctx?: ExtensionContext) => {
365
343
  const loaded = await readRuntimeSettings();
366
- if (generation !== menuGeneration || menuController.signal.aborted) return;
344
+ if (!session.isCurrent()) return;
367
345
  if (loaded.kind === "invalid" && !ctx) return;
368
346
  settings = loaded.kind === "loaded" ? loaded.settings : {};
369
347
  if (!ctx) return;
370
348
  if (loaded.kind === "invalid") {
371
349
  ctx.ui.notify(`pi-plan-mode settings ignored: ${loaded.reason}`, "warning");
372
350
  }
373
- if (loaded.notice) ctx.ui.notify(loaded.notice, "warning");
374
351
  };
375
352
 
376
353
  const stopPlanModeSettingsWatch = () => {
377
- if (settingsReloadTimer) {
378
- clearTimeout(settingsReloadTimer);
379
- settingsReloadTimer = undefined;
380
- }
381
- settingsWatch?.close();
382
- settingsWatch = undefined;
354
+ settingsWatcher?.stop();
355
+ settingsWatcher = undefined;
383
356
  };
384
357
 
385
- /**
386
- * Watches the settings file's directory rather than the file itself: saves go
387
- * through a temp file and an atomic rename, and a watch bound to the old inode
388
- * would go deaf after the first one.
389
- */
390
- const startPlanModeSettingsWatch = (generation: number) => {
358
+ /** An injected reader is the only source there is, so it is never watched. */
359
+ const startPlanModeSettingsWatch = (session: LifecycleScope) => {
391
360
  stopPlanModeSettingsWatch();
392
361
  if (dependencies.readSettings) return;
393
- const watchedPath = dependencies.settingsPath ?? planModeSettingsPath();
394
- const watchedFile = basename(watchedPath);
395
- try {
396
- const watcher = watch(dirname(watchedPath), { persistent: false }, (event, changedFile) => {
397
- if (event !== "rename" && event !== "change") return;
398
- // A null filename means the platform could not name the entry; reload
399
- // rather than miss the edit. The agent directory holds other churn, so
400
- // a named entry that is not ours is ignored.
401
- if (changedFile && changedFile.toString() !== watchedFile) return;
402
- if (settingsReloadTimer) clearTimeout(settingsReloadTimer);
403
- settingsReloadTimer = setTimeout(() => {
404
- settingsReloadTimer = undefined;
405
- void loadPlanModeSettings(generation);
406
- }, SETTINGS_RELOAD_DEBOUNCE_MS);
407
- });
408
- watcher.on("error", stopPlanModeSettingsWatch);
409
- settingsWatch = watcher;
410
- } catch {
411
- // An unwatchable directory only costs the live reload; settings still
412
- // load at session start.
413
- stopPlanModeSettingsWatch();
414
- }
362
+ settingsWatcher = createSettingsWatcher({
363
+ path: dependencies.settingsPath ?? planModeSettingsPath(),
364
+ debounceMs: SETTINGS_RELOAD_DEBOUNCE_MS,
365
+ onChange: () => void loadPlanModeSettings(session),
366
+ });
367
+ settingsWatcher.start();
415
368
  };
416
369
 
417
370
  pi.on("session_start", async (event, ctx) => {
418
- const generation = ++menuGeneration;
371
+ const session = lifecycle.nextSession("Plan-mode session replaced");
419
372
  planToolsActivated = false;
420
373
  currentHasUI = ctx.hasUI;
421
374
  reconcilePlanToolSurface(ctx.hasUI);
422
375
  refreshStateBeforeFirstAgentStart = event.reason === "new";
423
- menuController.abort(new DOMException("Plan-mode session replaced", "AbortError"));
424
- menuController = new AbortController();
425
376
  pendingReadyNonce = undefined;
426
377
  latestCommandContext = undefined;
427
378
  settings = {};
428
379
  sessionPlanPath = resolveSessionPlanPath(ctx);
429
380
  restoreState(ctx);
430
- repairLegacyThinkingLevel(ctx);
431
- await loadPlanModeSettings(generation, ctx);
432
- if (generation !== menuGeneration || menuController.signal.aborted) return;
433
- startPlanModeSettingsWatch(generation);
381
+ await loadPlanModeSettings(session, ctx);
382
+ if (!session.isCurrent()) return;
383
+ startPlanModeSettingsWatch(session);
434
384
  const persistFlagActivation = pi.getFlag("plan") === true && !state.enabled;
435
385
  if (persistFlagActivation) {
436
386
  state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
@@ -441,9 +391,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
441
391
  });
442
392
 
443
393
  pi.on("session_shutdown", async (_event, ctx) => {
444
- menuGeneration += 1;
394
+ // No re-arm: nothing may become current again until a session_start.
395
+ lifecycle.endSession("Plan-mode session shut down");
445
396
  stopPlanModeSettingsWatch();
446
- menuController.abort(new DOMException("Plan-mode session shut down", "AbortError"));
447
397
  pendingReadyNonce = undefined;
448
398
  latestCommandContext = undefined;
449
399
  refreshStateBeforeFirstAgentStart = false;
@@ -476,9 +426,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
476
426
  // A new turn supersedes the previous ready plan: revision feedback
477
427
  // re-opens planning until another plan_mode_complete arrives.
478
428
  pendingReadyNonce = undefined;
479
- state = { ...state, awaitingAction: false };
480
- persistState();
481
- updateUi(ctx);
429
+ setState(ctx, { awaitingAction: false });
482
430
  }
483
431
  if (state.enabled && !planToolsActivated) activatePlanTools(ctx.hasUI);
484
432
  else reconcilePlanToolSurface(ctx.hasUI);
@@ -513,39 +461,55 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
513
461
  });
514
462
 
515
463
  function enterPlanMode(ctx: ExtensionContext) {
516
- workflowGeneration += 1;
464
+ lifecycle.nextWorkflow();
517
465
  activatePlanTools(ctx.hasUI);
518
- state = { ...state, enabled: true, awaitingAction: false };
519
- persistState();
520
- updateUi(ctx);
466
+ setState(ctx, { enabled: true, awaitingAction: false });
521
467
  }
522
468
 
523
469
  function enterPlanModeWithPrompt(prompt: string, ctx: ExtensionContext) {
524
470
  const previousState = state;
525
471
  const wasEnabled = state.enabled;
526
472
  enterPlanMode(ctx);
527
- if (!wasEnabled) {
528
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
529
- }
530
- if (sendPlanModeUserMessage(prompt, ctx)) return;
531
- state = previousState;
532
- persistState();
533
- updateUi(ctx);
473
+ if (!wasEnabled) notifyEnabled(ctx);
474
+ sendOrRevert(prompt, ctx, previousState);
534
475
  }
535
476
 
536
477
  async function exitPlanMode(ctx: ExtensionContext, options: { keepPlanFile?: boolean } = {}) {
537
- workflowGeneration += 1;
478
+ lifecycle.nextWorkflow();
538
479
  const planPath = state.planPath;
539
480
  pendingReadyNonce = undefined;
540
- state = {
541
- ...state,
542
- enabled: false,
543
- planPath: undefined,
544
- awaitingAction: false,
545
- };
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 };
546
501
  persistState();
547
502
  updateUi(ctx);
548
- if (planPath && !options.keepPlanFile) await deletePlanFile(planPath);
503
+ }
504
+
505
+ /**
506
+ * Sends the message a state change exists to produce, and puts the previous
507
+ * state back when the session refuses it: a mode switch the model was never
508
+ * told about is worse than no switch at all.
509
+ */
510
+ function sendOrRevert(message: string, ctx: ExtensionContext, previousState: PlanModeState) {
511
+ if (sendPlanModeUserMessage(message, ctx)) return;
512
+ setState(ctx, previousState);
549
513
  }
550
514
 
551
515
  function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) {
@@ -573,10 +537,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
573
537
  throw new Error(`Unable to save the plan to ${planPath}: ${detail}`);
574
538
  }
575
539
  sessionPlanPath = planPath;
576
- state = { ...state, planPath, awaitingAction: true };
577
540
  pendingReadyNonce = ++readyPresentationNonce;
578
- persistState();
579
- updateUi(ctx);
541
+ setState(ctx, { planPath, awaitingAction: true });
580
542
  showPlanModePlan(pi, ctx, "Proposed Plan", plan);
581
543
  return planPath;
582
544
  }
@@ -616,39 +578,28 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
616
578
  return;
617
579
  }
618
580
 
619
- workflowGeneration += 1;
581
+ lifecycle.nextWorkflow();
620
582
  const previousState = state;
621
583
  pendingReadyNonce = undefined;
622
- state = {
623
- ...state,
624
- enabled: false,
625
- awaitingAction: false,
626
- planPath,
627
- };
628
- persistState();
629
- updateUi(ctx);
630
-
631
- if (!sendPlanModeUserMessage(formatImplementationHandoff(planPath), ctx)) {
632
- state = previousState;
633
- persistState();
634
- updateUi(ctx);
635
- }
584
+ setState(ctx, { enabled: false, awaitingAction: false, planPath });
585
+ sendOrRevert(formatImplementationHandoff(planPath), ctx, previousState);
636
586
  }
637
587
 
638
588
  async function showLaunchMenu(ctx: ExtensionContext) {
639
- const lifecycle = captureMenuLifecycle();
640
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
589
+ const menu = lifecycle.capture();
590
+ if (!menu.isCurrent() || menu.signal.aborted) return;
641
591
  const ui = await loadInteractiveUi();
642
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
592
+ if (!menu.isCurrent() || menu.signal.aborted) return;
643
593
  await ui.showPlanLaunchMenu(ctx, {
644
594
  statusText: "Status: Off.",
645
- ...lifecycle,
595
+ signal: menu.signal,
596
+ isCurrent: menu.isCurrent,
646
597
  start: (signal) => {
647
- if (signal.aborted || !lifecycle.isCurrent()) return;
598
+ if (signal.aborted || !menu.isCurrent()) return;
648
599
  enterPlanMode(ctx);
649
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
600
+ notifyEnabled(ctx);
650
601
  },
651
- settings: (signal) => showSettings(ctx, signal, lifecycle.isCurrent),
602
+ settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
652
603
  });
653
604
  }
654
605
 
@@ -657,27 +608,25 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
657
608
  ctx.ui.notify(planStatusText(), "info");
658
609
  return;
659
610
  }
660
- const lifecycle = captureMenuLifecycle();
661
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
611
+ const menu = lifecycle.capture();
612
+ if (!menu.isCurrent() || menu.signal.aborted) return;
662
613
  const ui = await loadInteractiveUi();
663
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
614
+ if (!menu.isCurrent() || menu.signal.aborted) return;
664
615
  await ui.showActiveImplementationMenu(ctx, {
665
616
  statusText: planStatusText(),
666
617
  ...(state.planPath ? { planPathLine: `Plan file: ${state.planPath}` } : {}),
667
618
  getExportDestination: () => planExports.getDestination(ctx),
668
- signal: lifecycle.signal,
669
- isCurrent: lifecycle.isCurrent,
619
+ signal: menu.signal,
620
+ isCurrent: menu.isCurrent,
670
621
  show: () => showStoredPlan(pi, ctx, state),
671
- exportPlan: (path, signal) => planExports.export(path, ctx, signal, lifecycle.isCurrent),
672
- settings: (signal) => showSettings(ctx, signal, lifecycle.isCurrent),
622
+ exportPlan: (path, signal) => planExports.export(path, ctx, signal, menu.isCurrent),
623
+ settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
673
624
  startNew: () => {
674
625
  enterPlanMode(ctx);
675
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
626
+ notifyEnabled(ctx);
676
627
  },
677
628
  clear: () => {
678
- void exitPlanMode(ctx).then(() => {
679
- ctx.ui.notify("Active implementation plan cleared.", "info");
680
- });
629
+ void exitAndNotify(ctx, "Active implementation plan cleared.");
681
630
  },
682
631
  });
683
632
  }
@@ -704,37 +653,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
704
653
  return result.kind === "closed" && "reason" in result && result.reason === "close";
705
654
  }
706
655
 
707
- function captureMenuLifecycle() {
708
- const sessionGeneration = menuGeneration;
709
- const planWorkflowGeneration = workflowGeneration;
710
- const controller = menuController;
711
- return {
712
- signal: controller.signal,
713
- isCurrent: () =>
714
- sessionGeneration === menuGeneration &&
715
- planWorkflowGeneration === workflowGeneration &&
716
- !controller.signal.aborted,
717
- };
718
- }
719
-
720
- /**
721
- * pi-plan-mode <= 1.2.1 raised the thinking level while planning, and because
722
- * `pi.setThinkingLevel` writes through to the user's real settings, a session
723
- * that died before its restore left that change durable. If the newest state
724
- * entry still carries the capture and the live level still equals what Plan
725
- * mode applied, put the user's level back — once. Persisting state in the new
726
- * shape drops the capture, so the next session finds nothing to repair. A
727
- * user who has already moved the level themselves is left alone.
728
- *
729
- * legacy: delete in 1.4.0
730
- */
731
- function repairLegacyThinkingLevel(ctx: ExtensionContext) {
732
- const legacy = readLegacyThinkingCapture(ctx.sessionManager.getBranch(), STATE_ENTRY_TYPE);
733
- if (!legacy || pi.getThinkingLevel() !== legacy.applied) return;
734
- setPlanThinkingLevel(pi, legacy.previous);
735
- persistState();
736
- }
737
-
738
656
  function resolveSessionPlanPath(ctx: ExtensionContext) {
739
657
  try {
740
658
  return planFilePathForSession(ctx.sessionManager.getSessionId());
@@ -69,9 +69,9 @@ export function registerPlanModeCardRenderer(pi: ExtensionAPI): void {
69
69
  * characters do not justify a shared package; a user reading a footer
70
70
  * justifies the consistency.
71
71
  */
72
- export type PlanModePhase = "drafting" | "revising" | "ready" | "implementing";
72
+ type PlanModePhase = "drafting" | "revising" | "ready" | "implementing";
73
73
 
74
- export interface PlanModeView {
74
+ interface PlanModeView {
75
75
  phase: PlanModePhase;
76
76
  /** The footer line: plain text with a glyph, no colour. */
77
77
  footer: string;
@@ -2,12 +2,12 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  export const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
4
4
 
5
- export type PlanModeQuestionOption = {
5
+ type PlanModeQuestionOption = {
6
6
  label: string;
7
7
  description?: string;
8
8
  };
9
9
 
10
- export type PlanModeQuestion = {
10
+ type PlanModeQuestion = {
11
11
  id: string;
12
12
  header: string;
13
13
  question: string;
@@ -184,7 +184,7 @@ export async function answerPlanModeQuestions(
184
184
  return planModeQuestionAnswered(questions, answers);
185
185
  }
186
186
 
187
- export async function askPlanModeQuestions(
187
+ async function askPlanModeQuestions(
188
188
  questions: PlanModeQuestion[],
189
189
  ctx: ExtensionContext,
190
190
  shouldContinue: () => boolean = () => true,
@@ -243,7 +243,7 @@ function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: num
243
243
  return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
244
244
  }
245
245
 
246
- export function planModeQuestionAnswered(
246
+ function planModeQuestionAnswered(
247
247
  questions: PlanModeQuestion[],
248
248
  answers: PlanModeQuestionAnswer[],
249
249
  ) {
@@ -1,4 +1,3 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
2
  import { defineMenu, type RunMenuResult, runMenu } from "@narumitw/pi-tui-kit";
4
3
  import { planExportDestination } from "./plan-export.js";
@@ -16,17 +15,13 @@ import {
16
15
  interface SettingsMenuState {
17
16
  kind: "valid" | "invalid";
18
17
  settings: PlanModeSettings;
19
- notice?: string;
20
18
  reason?: string;
21
- /** The removed `thinkingLevel` key is still in the file. legacy: delete in 1.4.0 */
22
- hasLegacyThinkingLevel?: boolean;
23
19
  }
24
20
 
25
- export interface PlanModeSettingsMenuOptions {
21
+ interface PlanModeSettingsMenuOptions {
26
22
  signal: AbortSignal;
27
23
  isCurrent(): boolean;
28
24
  settingsPath?: string;
29
- legacySettingsPath?: string;
30
25
  readSettings?: (settingsPath?: string) => Promise<PlanModeSettingsLoadResult>;
31
26
  updateSettings?: (
32
27
  patch: PlanModeSettingsPatch,
@@ -49,19 +44,9 @@ export async function showPlanModeSettings(
49
44
  const loadState = async (): Promise<SettingsMenuState> => {
50
45
  const loaded = await readSettings(options.settingsPath);
51
46
  if (loaded.kind === "invalid") {
52
- return {
53
- kind: "invalid",
54
- settings: {},
55
- notice: loaded.notice,
56
- reason: loaded.reason,
57
- };
47
+ return { kind: "invalid", settings: {}, reason: loaded.reason };
58
48
  }
59
- return {
60
- kind: "valid",
61
- settings: loaded.kind === "loaded" ? loaded.settings : {},
62
- notice: loaded.notice,
63
- hasLegacyThinkingLevel: await hasLegacyThinkingLevel(settingsPath),
64
- };
49
+ return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
65
50
  };
66
51
 
67
52
  const menu = defineMenu<SettingsMenuState, Screen, Action, ExtensionContext>({
@@ -73,7 +58,7 @@ export async function showPlanModeSettings(
73
58
  : {
74
59
  kind: "settings",
75
60
  title: "Plan Mode Settings",
76
- lines: settingsLines(settingsPath, state),
61
+ lines: settingsLines(settingsPath),
77
62
  items: [
78
63
  {
79
64
  id: "defaultPlanExportPath",
@@ -132,11 +117,7 @@ export async function showPlanModeSettings(
132
117
  ) {
133
118
  if (signal.aborted || !options.isCurrent()) return { kind: "rejected" as const };
134
119
  try {
135
- const saved = await updateSettings(patch, {
136
- settingsPath: options.settingsPath,
137
- legacySettingsPath: options.legacySettingsPath,
138
- signal,
139
- });
120
+ const saved = await updateSettings(patch, { settingsPath: options.settingsPath, signal });
140
121
  if (options.isCurrent()) options.onSaved(saved);
141
122
  if (signal.aborted || !options.isCurrent()) return { kind: "rejected" as const };
142
123
  actionCtx.ui.notify(successMessage, "info");
@@ -153,35 +134,13 @@ export async function showPlanModeSettings(
153
134
  }
154
135
  }
155
136
 
156
- function settingsLines(settingsPath: string, state: SettingsMenuState) {
137
+ function settingsLines(settingsPath: string) {
157
138
  return [
158
139
  `User settings · ${safeTerminalText(settingsPath)}`,
159
140
  "The export destination applies to its next action.",
160
- // legacy: delete in 1.4.0
161
- ...(state.hasLegacyThinkingLevel
162
- ? [
163
- "thinkingLevel is no longer used — thinking is a session setting and Plan mode never changes it.",
164
- ]
165
- : []),
166
- ...(state.notice ? [safeTerminalText(state.notice)] : []),
167
141
  ];
168
142
  }
169
143
 
170
- /**
171
- * The removed key is preserved verbatim on save, so the only way to know it is
172
- * still there is to look at the file. A read failure simply hides the notice.
173
- *
174
- * legacy: delete in 1.4.0
175
- */
176
- async function hasLegacyThinkingLevel(settingsPath: string) {
177
- try {
178
- const parsed: unknown = JSON.parse(await readFile(settingsPath, "utf8"));
179
- return typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "thinkingLevel");
180
- } catch {
181
- return false;
182
- }
183
- }
184
-
185
144
  function invalidScreen(settingsPath: string, state: SettingsMenuState) {
186
145
  return {
187
146
  kind: "detail" as const,
@@ -189,7 +148,6 @@ function invalidScreen(settingsPath: string, state: SettingsMenuState) {
189
148
  lines: [
190
149
  `Invalid settings file. Fix ${safeTerminalText(settingsPath)} before saving.`,
191
150
  safeTerminalText(state.reason ?? "The settings file is invalid."),
192
- ...(state.notice ? [safeTerminalText(state.notice)] : []),
193
151
  ],
194
152
  hint: "back" as const,
195
153
  };
@@ -0,0 +1,60 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, dirname } from "node:path";
3
+
4
+ interface SettingsWatcherOptions {
5
+ /** The settings file to follow; its directory is what actually gets watched. */
6
+ path: string;
7
+ debounceMs: number;
8
+ onChange(): void;
9
+ }
10
+
11
+ /**
12
+ * Follows one settings file for out-of-band edits.
13
+ *
14
+ * The watch is on the file's *directory* rather than the file itself: saves go
15
+ * through a temp file and an atomic rename, and a watch bound to the old inode
16
+ * would go deaf after the first one. One hand-edit or menu save also fans out
17
+ * into several filesystem events (temp file created, renamed into place), so
18
+ * `debounceMs` collapses them into a single `onChange`.
19
+ */
20
+ export function createSettingsWatcher(options: SettingsWatcherOptions) {
21
+ const watchedFile = basename(options.path);
22
+ let watcher: ReturnType<typeof watch> | undefined;
23
+ let reloadTimer: ReturnType<typeof setTimeout> | undefined;
24
+
25
+ const stop = () => {
26
+ if (reloadTimer) {
27
+ clearTimeout(reloadTimer);
28
+ reloadTimer = undefined;
29
+ }
30
+ watcher?.close();
31
+ watcher = undefined;
32
+ };
33
+
34
+ return {
35
+ start() {
36
+ stop();
37
+ try {
38
+ const started = watch(dirname(options.path), { persistent: false }, (event, changed) => {
39
+ if (event !== "rename" && event !== "change") return;
40
+ // A null filename means the platform could not name the entry; reload
41
+ // rather than miss the edit. The directory holds other churn, so a
42
+ // named entry that is not ours is ignored.
43
+ if (changed && changed.toString() !== watchedFile) return;
44
+ if (reloadTimer) clearTimeout(reloadTimer);
45
+ reloadTimer = setTimeout(() => {
46
+ reloadTimer = undefined;
47
+ options.onChange();
48
+ }, options.debounceMs);
49
+ });
50
+ started.on("error", stop);
51
+ watcher = started;
52
+ } catch {
53
+ // An unwatchable directory only costs the live reload; settings still
54
+ // load at session start.
55
+ stop();
56
+ }
57
+ },
58
+ stop,
59
+ };
60
+ }
package/src/settings.ts CHANGED
@@ -5,7 +5,6 @@ import { basename, dirname, join } from "node:path";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  export const PLAN_MODE_SETTINGS_FILE = "pi-plan-mode.json";
8
- const LEGACY_PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
9
8
  const MAX_SETTINGS_BYTES = 64 * 1024;
10
9
  export const DEFAULT_PLAN_EXPORT_PATH = "PLAN.md";
11
10
  const MAX_PLAN_EXPORT_PATH_LENGTH = 4096;
@@ -18,14 +17,13 @@ export interface PlanModeSettingsPatch {
18
17
  }
19
18
  export interface UpdatePlanModeSettingsOptions {
20
19
  settingsPath?: string;
21
- legacySettingsPath?: string;
22
20
  signal?: AbortSignal;
23
21
  beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>;
24
22
  }
25
23
  export type PlanModeSettingsLoadResult =
26
- | { kind: "missing"; notice?: string }
27
- | { kind: "invalid"; reason: string; notice?: string }
28
- | { kind: "loaded"; settings: PlanModeSettings; notice?: string };
24
+ | { kind: "missing" }
25
+ | { kind: "invalid"; reason: string }
26
+ | { kind: "loaded"; settings: PlanModeSettings };
29
27
 
30
28
  type SettingsDocument = Record<string, unknown>;
31
29
  type SettingsSnapshot = {
@@ -39,10 +37,6 @@ export function planModeSettingsPath() {
39
37
  return join(getAgentDir(), PLAN_MODE_SETTINGS_FILE);
40
38
  }
41
39
 
42
- function legacyPlanModeSettingsPath() {
43
- return join(getAgentDir(), LEGACY_PLAN_MODE_SETTINGS_FILE);
44
- }
45
-
46
40
  /**
47
41
  * Unknown top-level keys are tolerated and preserved on save. Settings removed
48
42
  * over time (defaultPlanTools, bashPolicy, safeSubcommands,
@@ -80,34 +74,10 @@ function normalizePlanExportPath(value: unknown) {
80
74
  }
81
75
 
82
76
  export async function readPlanModeSettings(
83
- settingsPath?: string,
77
+ settingsPath = planModeSettingsPath(),
84
78
  ): Promise<PlanModeSettingsLoadResult> {
85
- if (settingsPath) {
86
- await awaitPlanModeSettingsWrites(settingsPath);
87
- return (await readSettingsSnapshot(settingsPath)).result;
88
- }
89
- const canonicalPath = planModeSettingsPath();
90
- await awaitPlanModeSettingsWrites(canonicalPath);
91
- const canonical = await readSettingsSnapshot(canonicalPath);
92
- const legacyPath = legacyPlanModeSettingsPath();
93
- if (canonical.result.kind !== "missing") {
94
- return (await pathExists(legacyPath))
95
- ? {
96
- ...canonical.result,
97
- notice: `${LEGACY_PLAN_MODE_SETTINGS_FILE} ignored because ${PLAN_MODE_SETTINGS_FILE} takes precedence.`,
98
- }
99
- : canonical.result;
100
- }
101
-
102
- const legacy = await readSettingsSnapshot(legacyPath);
103
- const raced = await readSettingsSnapshot(canonicalPath);
104
- if (raced.result.kind !== "missing") return raced.result;
105
- return legacy.result.kind === "loaded"
106
- ? {
107
- ...legacy.result,
108
- notice: `Using legacy ${LEGACY_PLAN_MODE_SETTINGS_FILE}; rename it to ${PLAN_MODE_SETTINGS_FILE}. The legacy file was not modified.`,
109
- }
110
- : legacy.result;
79
+ await awaitPlanModeSettingsWrites(settingsPath);
80
+ return (await readSettingsSnapshot(settingsPath)).result;
111
81
  }
112
82
 
113
83
  export function updatePlanModeSettings(
@@ -115,11 +85,9 @@ export function updatePlanModeSettings(
115
85
  options: UpdatePlanModeSettingsOptions = {},
116
86
  ): Promise<PlanModeSettings> {
117
87
  const settingsPath = options.settingsPath ?? planModeSettingsPath();
118
- const legacySettingsPath =
119
- options.legacySettingsPath ?? (options.settingsPath ? undefined : legacyPlanModeSettingsPath());
120
88
  return enqueueMutation(settingsPath, async () => {
121
89
  options.signal?.throwIfAborted();
122
- const current = await readSettingsDocumentForUpdate(settingsPath, legacySettingsPath);
90
+ const current = await readSettingsDocumentForUpdate(settingsPath);
123
91
  const updated: SettingsDocument = { ...current };
124
92
  if (patch.defaultPlanExportPath === null) delete updated.defaultPlanExportPath;
125
93
  else if (patch.defaultPlanExportPath !== undefined) {
@@ -152,27 +120,12 @@ function enqueueMutation<T>(settingsPath: string, mutation: () => Promise<T>): P
152
120
  return result;
153
121
  }
154
122
 
155
- async function readSettingsDocumentForUpdate(
156
- settingsPath: string,
157
- legacySettingsPath: string | undefined,
158
- ): Promise<SettingsDocument> {
159
- const canonical = await readSettingsSnapshot(settingsPath);
160
- if (canonical.result.kind === "loaded") return canonical.document ?? {};
161
- if (canonical.result.kind === "invalid") {
162
- throw invalidSettingsError(settingsPath, canonical.result.reason);
163
- }
164
- if (!legacySettingsPath) return {};
165
-
166
- const legacy = await readSettingsSnapshot(legacySettingsPath);
167
- const raced = await readSettingsSnapshot(settingsPath);
168
- if (raced.result.kind === "loaded") return raced.document ?? {};
169
- if (raced.result.kind === "invalid") {
170
- throw invalidSettingsError(settingsPath, raced.result.reason);
171
- }
172
- if (legacy.result.kind === "invalid") {
173
- throw invalidSettingsError(legacySettingsPath, legacy.result.reason);
123
+ async function readSettingsDocumentForUpdate(settingsPath: string): Promise<SettingsDocument> {
124
+ const snapshot = await readSettingsSnapshot(settingsPath);
125
+ if (snapshot.result.kind === "invalid") {
126
+ throw invalidSettingsError(settingsPath, snapshot.result.reason);
174
127
  }
175
- return legacy.document ?? {};
128
+ return snapshot.result.kind === "loaded" ? (snapshot.document ?? {}) : {};
176
129
  }
177
130
 
178
131
  async function readSettingsSnapshot(settingsPath: string): Promise<SettingsSnapshot> {
@@ -264,16 +217,6 @@ function isSettingsDocument(value: unknown): value is SettingsDocument {
264
217
  return typeof value === "object" && value !== null && !Array.isArray(value);
265
218
  }
266
219
 
267
- async function pathExists(path: string) {
268
- try {
269
- const handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
270
- await handle.close();
271
- return true;
272
- } catch (error: unknown) {
273
- return !(isNodeError(error) && error.code === "ENOENT");
274
- }
275
- }
276
-
277
220
  function invalidSettingsError(settingsPath: string, reason: string) {
278
221
  return new Error(`pi-plan-mode settings at ${settingsPath} are invalid: ${reason}`);
279
222
  }
package/src/state.ts CHANGED
@@ -39,42 +39,6 @@ function newestStateEntry(entries: unknown[], stateEntryType: string): SessionEn
39
39
  return undefined;
40
40
  }
41
41
 
42
- // legacy: delete in 1.4.0
43
- const LEGACY_THINKING_LEVELS = [
44
- "off",
45
- "minimal",
46
- "low",
47
- "medium",
48
- "high",
49
- "xhigh",
50
- "max",
51
- ] as const;
52
-
53
- // legacy: delete in 1.4.0
54
- export type LegacyThinkingCapture = {
55
- previous: (typeof LEGACY_THINKING_LEVELS)[number];
56
- applied: (typeof LEGACY_THINKING_LEVELS)[number];
57
- };
58
-
59
- /**
60
- * Reads the thinking-level capture written by pi-plan-mode <= 1.2.1, so a
61
- * session interrupted while Plan mode held a raised level can have the user's
62
- * level put back once. Both halves must be present and valid: a partial or
63
- * absent capture is nothing to repair.
64
- *
65
- * legacy: delete in 1.4.0
66
- */
67
- export function readLegacyThinkingCapture(
68
- entries: unknown[],
69
- stateEntryType: string,
70
- ): LegacyThinkingCapture | undefined {
71
- const entry = newestStateEntry(entries, stateEntryType);
72
- if (!isRecord(entry?.data)) return undefined;
73
- const previous = legacyThinkingLevel(entry.data.previousThinkingLevel);
74
- const applied = legacyThinkingLevel(entry.data.appliedThinkingLevel);
75
- return previous && applied ? { previous, applied } : undefined;
76
- }
77
-
78
42
  /**
79
43
  * Persisted paths are only trusted when they are absolute and free of NUL, so
80
44
  * malformed state can never redirect a read or a delete to a relative target.
@@ -86,14 +50,6 @@ function absolutePath(value: unknown) {
86
50
  return normalized;
87
51
  }
88
52
 
89
- // legacy: delete in 1.4.0
90
- function legacyThinkingLevel(value: unknown): (typeof LEGACY_THINKING_LEVELS)[number] | undefined {
91
- return typeof value === "string" &&
92
- LEGACY_THINKING_LEVELS.includes(value as (typeof LEGACY_THINKING_LEVELS)[number])
93
- ? (value as (typeof LEGACY_THINKING_LEVELS)[number])
94
- : undefined;
95
- }
96
-
97
53
  function isRecord(value: unknown): value is Record<string, unknown> {
98
54
  return typeof value === "object" && value !== null && !Array.isArray(value);
99
55
  }