@hank-warren/pi-plan-mode 1.1.0 → 1.2.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,41 @@
1
1
  # @hank-warren/pi-plan-mode
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 358aa1f: re-read `pi-plan-mode.json` when it changes instead of only at session start
8
+
9
+ The settings file was read once per session, so a hand-edit — or a Settings save
10
+ from another session — did nothing until the next restart. That sat badly next to
11
+ the plan file, which has been hand-editable and read from disk since 1.0.
12
+
13
+ Plan mode now watches the settings file's directory (not the file: saves land
14
+ through a temp file and an atomic rename, which replaces the inode) and re-reads
15
+ on a 75 ms debounce, so one save costs one load.
16
+
17
+ An unparseable file is still reported and replaced by the defaults at session
18
+ start. Mid-session it is ignored and the last good settings stay in place: a
19
+ reload sees the file the moment an editor touches it, so an invalid read is
20
+ usually a partial write, and there is no context to report it through anyway.
21
+ Deleting the file mid-session does restore the defaults.
22
+
23
+ Session start now also honours the injected `settingsPath` dependency when
24
+ reading, which previously only the Settings menu used — the two could disagree
25
+ about which file they were looking at.
26
+
27
+ ## 1.1.1
28
+
29
+ ### Patch Changes
30
+
31
+ - 7d96995: Make every declared dependency accurate.
32
+
33
+ `pi-permission-selector` no longer declares a `@earendil-works/pi-coding-agent` peer dependency. It imports nothing from it — the claim was left over from 1.0.0, which removed the extension and the selector monkey patch and turned the package into a pure library.
34
+
35
+ `pi-auto-permissions` floors `@hank-warren/pi-permission-selector` at `^1.1.0` and `pi-plan-mode` floors `@narumitw/pi-tui-kit` at `^0.49.3`, in both cases the lowest version the package is actually tested against. Neither uses a feature the old floor lacked, so nothing changes at install time under caret resolution; the ranges simply no longer claim support that nothing verifies.
36
+
37
+ `scripts/validate.py` now enforces that a package's Pi `peerDependencies` are exactly the Pi packages its shipped sources import, in both directions.
38
+
3
39
  ## 1.1.0
4
40
 
5
41
  ### Minor Changes
package/README.md CHANGED
@@ -72,6 +72,8 @@ Writes are atomic (temp file plus rename), so a reader never sees a partial plan
72
72
 
73
73
  Open **Settings** from the `/plan` menu, or edit `$PI_CODING_AGENT_DIR/pi-plan-mode.json` (normally `~/.pi/agent/pi-plan-mode.json`). The file is optional.
74
74
 
75
+ The file is read at session start and **re-read whenever it changes**, so a hand-edit — or a save from another session — applies without restarting. Like the plan file itself, it is edited on disk and read from disk.
76
+
75
77
  ```json
76
78
  {
77
79
  "thinkingLevel": "inherit",
@@ -89,6 +91,8 @@ Open **Settings** from the `/plan` menu, or edit `$PI_CODING_AGENT_DIR/pi-plan-m
89
91
 
90
92
  Unknown keys are preserved. Settings removed in 1.0 (`defaultPlanTools`, `bashPolicy`, `safeSubcommands`, `implementationPlanRetention`) are ignored rather than treated as errors, so an existing settings file keeps working.
91
93
 
94
+ A settings file that does not parse is reported at session start and the defaults are used. Mid-session it is ignored instead, leaving the last good settings in place: an edit is seen the moment your editor touches the file, so an unreadable one is usually a half-finished save rather than what you meant.
95
+
92
96
  ## 🔐 What Plan mode does and does not enforce
93
97
 
94
98
  Plan mode blocks exactly three tools while planning: `edit`, `write`, and `update_plan`. That is the whole enforcement surface.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-plan-mode",
3
- "version": "1.1.0",
3
+ "version": "1.2.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": [
@@ -42,6 +42,6 @@
42
42
  "@earendil-works/pi-tui": "*"
43
43
  },
44
44
  "dependencies": {
45
- "@narumitw/pi-tui-kit": "^0.49.1"
45
+ "@narumitw/pi-tui-kit": "^0.49.3"
46
46
  }
47
47
  }
package/src/plan-mode.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, dirname } from "node:path";
1
3
  import type {
2
4
  ExtensionAPI,
3
5
  ExtensionCommandContext,
@@ -46,6 +48,7 @@ import {
46
48
  awaitPlanModeSettingsWrites,
47
49
  configuredThinkingLevel,
48
50
  type PlanModeSettings,
51
+ planModeSettingsPath,
49
52
  readPlanModeSettings,
50
53
  } from "./settings.js";
51
54
  import { type PlanModeState, restorePlanModeState } from "./state.js";
@@ -58,6 +61,12 @@ const STATE_ENTRY_TYPE = "plan-mode-state";
58
61
  * mutates the active tool set and never fights other extensions for it.
59
62
  */
60
63
  const BLOCKED_TOOLS = new Set(["edit", "write", "update_plan"]);
64
+ /**
65
+ * One hand-edit or menu save fans out into several filesystem events (temp file
66
+ * created, renamed into place). Collapsing them into one re-read keeps a save
67
+ * to a single load.
68
+ */
69
+ const SETTINGS_RELOAD_DEBOUNCE_MS = 75;
61
70
 
62
71
  /**
63
72
  * Which question tool the prompt should name this turn.
@@ -124,6 +133,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
124
133
  let workflowGeneration = 0;
125
134
  let refreshStateBeforeFirstAgentStart = false;
126
135
  let menuController = new AbortController();
136
+ let settingsWatch: ReturnType<typeof watch> | undefined;
137
+ let settingsReloadTimer: ReturnType<typeof setTimeout> | undefined;
127
138
  const persistState = () => pi.appendEntry<PlanModeState>(STATE_ENTRY_TYPE, state);
128
139
  const planExports = createPlanExportController({
129
140
  getState: () => state,
@@ -295,6 +306,73 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
295
306
  },
296
307
  });
297
308
 
309
+ const readRuntimeSettings = () =>
310
+ dependencies.readSettings?.() ?? readPlanModeSettings(dependencies.settingsPath);
311
+
312
+ /**
313
+ * `ctx` present means this is the session-start load: problems are reported
314
+ * and anything unusable falls back to defaults.
315
+ *
316
+ * `ctx` absent means a watch-triggered reload, which keeps the last good
317
+ * settings when the file no longer parses. A hand-edit is observed the moment
318
+ * the editor touches the file, so an invalid read is usually a half-written
319
+ * save rather than intent — discarding a working export path for it, with no
320
+ * `ctx` to explain why, would be worse than waiting for the next write. A
321
+ * genuinely broken file is still reported at the next session start.
322
+ */
323
+ const loadPlanModeSettings = async (generation: number, ctx?: ExtensionContext) => {
324
+ const loaded = await readRuntimeSettings();
325
+ if (generation !== menuGeneration || menuController.signal.aborted) return;
326
+ if (loaded.kind === "invalid" && !ctx) return;
327
+ settings = loaded.kind === "loaded" ? loaded.settings : { thinkingLevel: "inherit" };
328
+ if (!ctx) return;
329
+ if (loaded.kind === "invalid") {
330
+ ctx.ui.notify(`pi-plan-mode settings ignored: ${loaded.reason}`, "warning");
331
+ }
332
+ if (loaded.notice) ctx.ui.notify(loaded.notice, "warning");
333
+ };
334
+
335
+ const stopPlanModeSettingsWatch = () => {
336
+ if (settingsReloadTimer) {
337
+ clearTimeout(settingsReloadTimer);
338
+ settingsReloadTimer = undefined;
339
+ }
340
+ settingsWatch?.close();
341
+ settingsWatch = undefined;
342
+ };
343
+
344
+ /**
345
+ * Watches the settings file's directory rather than the file itself: saves go
346
+ * through a temp file and an atomic rename, and a watch bound to the old inode
347
+ * would go deaf after the first one.
348
+ */
349
+ const startPlanModeSettingsWatch = (generation: number) => {
350
+ stopPlanModeSettingsWatch();
351
+ if (dependencies.readSettings) return;
352
+ const watchedPath = dependencies.settingsPath ?? planModeSettingsPath();
353
+ const watchedFile = basename(watchedPath);
354
+ try {
355
+ const watcher = watch(dirname(watchedPath), { persistent: false }, (event, changedFile) => {
356
+ if (event !== "rename" && event !== "change") return;
357
+ // A null filename means the platform could not name the entry; reload
358
+ // rather than miss the edit. The agent directory holds other churn, so
359
+ // a named entry that is not ours is ignored.
360
+ if (changedFile && changedFile.toString() !== watchedFile) return;
361
+ if (settingsReloadTimer) clearTimeout(settingsReloadTimer);
362
+ settingsReloadTimer = setTimeout(() => {
363
+ settingsReloadTimer = undefined;
364
+ void loadPlanModeSettings(generation);
365
+ }, SETTINGS_RELOAD_DEBOUNCE_MS);
366
+ });
367
+ watcher.on("error", stopPlanModeSettingsWatch);
368
+ settingsWatch = watcher;
369
+ } catch {
370
+ // An unwatchable directory only costs the live reload; settings still
371
+ // load at session start.
372
+ stopPlanModeSettingsWatch();
373
+ }
374
+ };
375
+
298
376
  pi.on("session_start", async (event, ctx) => {
299
377
  const generation = ++menuGeneration;
300
378
  refreshStateBeforeFirstAgentStart = event.reason === "new";
@@ -305,13 +383,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
305
383
  settings = { thinkingLevel: "inherit" };
306
384
  sessionPlanPath = resolveSessionPlanPath(ctx);
307
385
  restoreState(ctx);
308
- const loadedSettings = await (dependencies.readSettings?.() ?? readPlanModeSettings());
386
+ await loadPlanModeSettings(generation, ctx);
309
387
  if (generation !== menuGeneration || menuController.signal.aborted) return;
310
- if (loadedSettings.kind === "loaded") settings = loadedSettings.settings;
311
- else if (loadedSettings.kind === "invalid") {
312
- ctx.ui.notify(`pi-plan-mode settings ignored: ${loadedSettings.reason}`, "warning");
313
- }
314
- if (loadedSettings.notice) ctx.ui.notify(loadedSettings.notice, "warning");
388
+ startPlanModeSettingsWatch(generation);
315
389
  const persistFlagActivation = pi.getFlag("plan") === true && !state.enabled;
316
390
  if (persistFlagActivation) {
317
391
  state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
@@ -336,6 +410,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
336
410
 
337
411
  pi.on("session_shutdown", async (_event, ctx) => {
338
412
  menuGeneration += 1;
413
+ stopPlanModeSettingsWatch();
339
414
  menuController.abort(new DOMException("Plan-mode session shut down", "AbortError"));
340
415
  pendingReadyNonce = undefined;
341
416
  latestCommandContext = undefined;