@hank-warren/pi-plan-mode 1.5.0 → 1.7.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.
@@ -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
  }