@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.
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,10 +47,25 @@ 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";
55
+ /** Label Herdr shows while `plan_mode_question` waits; distinguishes it from an approval. */
56
+ export const HERDR_BLOCKED_LABEL = "plan question";
57
+
58
+ /**
59
+ * Tell Herdr this pane is waiting on a human, so a supervising agent in another
60
+ * pane sees the block instead of reading a stalled turn as progress. Same
61
+ * contract as pi-auto-permissions' `setHerdrBlocked`, duplicated rather than
62
+ * imported so Plan Mode has no dependency on the permissions engine. No-op
63
+ * outside Herdr.
64
+ */
65
+ function setHerdrBlocked(pi: ExtensionAPI, active: boolean): void {
66
+ if (process.env.HERDR_ENV !== "1") return;
67
+ pi.events.emit("herdr:blocked", active ? { active: true, label: HERDR_BLOCKED_LABEL } : { active: false });
68
+ }
59
69
  /**
60
70
  * Plan mode's entire enforcement surface. Everything else — bash, subagents,
61
71
  * MCP, and other extension tools — is left to the session's normal permission
@@ -67,11 +77,7 @@ const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
67
77
  * was once listed here; it was a pre-1.0 upstream tool that no longer exists.)
68
78
  */
69
79
  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
- */
80
+ /** Long enough to collapse one save's burst of filesystem events into one read. */
75
81
  const SETTINGS_RELOAD_DEBOUNCE_MS = 75;
76
82
 
77
83
  /**
@@ -115,12 +121,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
115
121
  let readyPresentationNonce = 0;
116
122
  let pendingReadyNonce: number | undefined;
117
123
  let latestCommandContext: ExtensionCommandContext | undefined;
118
- let menuGeneration = 0;
119
- let workflowGeneration = 0;
120
124
  let refreshStateBeforeFirstAgentStart = false;
121
- let menuController = new AbortController();
122
- let settingsWatch: ReturnType<typeof watch> | undefined;
123
- let settingsReloadTimer: ReturnType<typeof setTimeout> | undefined;
125
+ const lifecycle = createLifecycle();
126
+ let settingsWatcher: ReturnType<typeof createSettingsWatcher> | undefined;
124
127
  let planToolsActivated = false;
125
128
  let currentHasUI = false;
126
129
  let globalQuestionAvailable = false;
@@ -166,7 +169,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
166
169
  const planActions = createPlanActionController({
167
170
  loadInteractiveUi,
168
171
  getState: () => state,
169
- captureLifecycle: captureMenuLifecycle,
172
+ captureLifecycle: () => lifecycle.capture(),
170
173
  statusText: planStatusText,
171
174
  planPathLine: () => (state.planPath ? `Plan file: ${state.planPath}` : undefined),
172
175
  getExportDestination: (ctx) => planExports.getDestination(ctx),
@@ -179,13 +182,11 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
179
182
  exitReady: (ctx) => {
180
183
  // Same had-plan branching as the /plan exit command: the menu must not
181
184
  // 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
- });
185
+ const text =
186
+ state.planPath !== undefined
187
+ ? "Plan mode disabled. Proposed plan discarded."
188
+ : "Plan mode disabled.";
189
+ void exitAndNotify(ctx, text);
189
190
  },
190
191
  });
191
192
 
@@ -229,19 +230,15 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
229
230
  );
230
231
  }
231
232
 
232
- const sessionGeneration = menuGeneration;
233
- const questionWorkflowGeneration = workflowGeneration;
234
- const questionSignal = signal
235
- ? AbortSignal.any([signal, menuController.signal])
236
- : menuController.signal;
233
+ const menu = lifecycle.capture();
234
+ const questionSignal = signal ? AbortSignal.any([signal, menu.signal]) : menu.signal;
237
235
  return answerPlanModeQuestions(
238
236
  parsed.questions,
239
237
  ctx,
240
238
  {
241
- isCurrent: () =>
242
- sessionGeneration === menuGeneration &&
243
- questionWorkflowGeneration === workflowGeneration,
239
+ isCurrent: menu.isCurrent,
244
240
  isEnabled: () => state.enabled,
241
+ onBlocked: (active) => setHerdrBlocked(pi, active),
245
242
  },
246
243
  questionSignal,
247
244
  );
@@ -288,7 +285,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
288
285
  return;
289
286
  }
290
287
  enterPlanMode(ctx);
291
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
288
+ notifyEnabled(ctx);
292
289
  return;
293
290
  }
294
291
  if (command === "show") {
@@ -309,8 +306,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
309
306
  }
310
307
  const exportMatch = /^export(?:\s+([\s\S]+))?$/iu.exec(prompt);
311
308
  if (exportMatch) {
312
- const lifecycle = captureMenuLifecycle();
313
- await planExports.export(exportMatch[1], ctx, lifecycle.signal, lifecycle.isCurrent);
309
+ const menu = lifecycle.capture();
310
+ await planExports.export(exportMatch[1], ctx, menu.signal, menu.isCurrent);
314
311
  return;
315
312
  }
316
313
  if (command === "exit" || command === "off") {
@@ -322,8 +319,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
322
319
  : hadPlan
323
320
  ? "Active implementation plan cleared."
324
321
  : "Plan mode disabled.";
325
- await exitPlanMode(ctx);
326
- ctx.ui.notify(notification, "info");
322
+ await exitAndNotify(ctx, notification);
327
323
  return;
328
324
  }
329
325
  if (prompt) {
@@ -361,76 +357,48 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
361
357
  * `ctx` to explain why, would be worse than waiting for the next write. A
362
358
  * genuinely broken file is still reported at the next session start.
363
359
  */
364
- const loadPlanModeSettings = async (generation: number, ctx?: ExtensionContext) => {
360
+ const loadPlanModeSettings = async (session: LifecycleScope, ctx?: ExtensionContext) => {
365
361
  const loaded = await readRuntimeSettings();
366
- if (generation !== menuGeneration || menuController.signal.aborted) return;
362
+ if (!session.isCurrent()) return;
367
363
  if (loaded.kind === "invalid" && !ctx) return;
368
364
  settings = loaded.kind === "loaded" ? loaded.settings : {};
369
365
  if (!ctx) return;
370
366
  if (loaded.kind === "invalid") {
371
367
  ctx.ui.notify(`pi-plan-mode settings ignored: ${loaded.reason}`, "warning");
372
368
  }
373
- if (loaded.notice) ctx.ui.notify(loaded.notice, "warning");
374
369
  };
375
370
 
376
371
  const stopPlanModeSettingsWatch = () => {
377
- if (settingsReloadTimer) {
378
- clearTimeout(settingsReloadTimer);
379
- settingsReloadTimer = undefined;
380
- }
381
- settingsWatch?.close();
382
- settingsWatch = undefined;
372
+ settingsWatcher?.stop();
373
+ settingsWatcher = undefined;
383
374
  };
384
375
 
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) => {
376
+ /** An injected reader is the only source there is, so it is never watched. */
377
+ const startPlanModeSettingsWatch = (session: LifecycleScope) => {
391
378
  stopPlanModeSettingsWatch();
392
379
  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
- }
380
+ settingsWatcher = createSettingsWatcher({
381
+ path: dependencies.settingsPath ?? planModeSettingsPath(),
382
+ debounceMs: SETTINGS_RELOAD_DEBOUNCE_MS,
383
+ onChange: () => void loadPlanModeSettings(session),
384
+ });
385
+ settingsWatcher.start();
415
386
  };
416
387
 
417
388
  pi.on("session_start", async (event, ctx) => {
418
- const generation = ++menuGeneration;
389
+ const session = lifecycle.nextSession("Plan-mode session replaced");
419
390
  planToolsActivated = false;
420
391
  currentHasUI = ctx.hasUI;
421
392
  reconcilePlanToolSurface(ctx.hasUI);
422
393
  refreshStateBeforeFirstAgentStart = event.reason === "new";
423
- menuController.abort(new DOMException("Plan-mode session replaced", "AbortError"));
424
- menuController = new AbortController();
425
394
  pendingReadyNonce = undefined;
426
395
  latestCommandContext = undefined;
427
396
  settings = {};
428
397
  sessionPlanPath = resolveSessionPlanPath(ctx);
429
398
  restoreState(ctx);
430
- repairLegacyThinkingLevel(ctx);
431
- await loadPlanModeSettings(generation, ctx);
432
- if (generation !== menuGeneration || menuController.signal.aborted) return;
433
- startPlanModeSettingsWatch(generation);
399
+ await loadPlanModeSettings(session, ctx);
400
+ if (!session.isCurrent()) return;
401
+ startPlanModeSettingsWatch(session);
434
402
  const persistFlagActivation = pi.getFlag("plan") === true && !state.enabled;
435
403
  if (persistFlagActivation) {
436
404
  state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
@@ -441,9 +409,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
441
409
  });
442
410
 
443
411
  pi.on("session_shutdown", async (_event, ctx) => {
444
- menuGeneration += 1;
412
+ // No re-arm: nothing may become current again until a session_start.
413
+ lifecycle.endSession("Plan-mode session shut down");
445
414
  stopPlanModeSettingsWatch();
446
- menuController.abort(new DOMException("Plan-mode session shut down", "AbortError"));
447
415
  pendingReadyNonce = undefined;
448
416
  latestCommandContext = undefined;
449
417
  refreshStateBeforeFirstAgentStart = false;
@@ -476,9 +444,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
476
444
  // A new turn supersedes the previous ready plan: revision feedback
477
445
  // re-opens planning until another plan_mode_complete arrives.
478
446
  pendingReadyNonce = undefined;
479
- state = { ...state, awaitingAction: false };
480
- persistState();
481
- updateUi(ctx);
447
+ setState(ctx, { awaitingAction: false });
482
448
  }
483
449
  if (state.enabled && !planToolsActivated) activatePlanTools(ctx.hasUI);
484
450
  else reconcilePlanToolSurface(ctx.hasUI);
@@ -513,39 +479,55 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
513
479
  });
514
480
 
515
481
  function enterPlanMode(ctx: ExtensionContext) {
516
- workflowGeneration += 1;
482
+ lifecycle.nextWorkflow();
517
483
  activatePlanTools(ctx.hasUI);
518
- state = { ...state, enabled: true, awaitingAction: false };
519
- persistState();
520
- updateUi(ctx);
484
+ setState(ctx, { enabled: true, awaitingAction: false });
521
485
  }
522
486
 
523
487
  function enterPlanModeWithPrompt(prompt: string, ctx: ExtensionContext) {
524
488
  const previousState = state;
525
489
  const wasEnabled = state.enabled;
526
490
  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);
491
+ if (!wasEnabled) notifyEnabled(ctx);
492
+ sendOrRevert(prompt, ctx, previousState);
534
493
  }
535
494
 
536
495
  async function exitPlanMode(ctx: ExtensionContext, options: { keepPlanFile?: boolean } = {}) {
537
- workflowGeneration += 1;
496
+ lifecycle.nextWorkflow();
538
497
  const planPath = state.planPath;
539
498
  pendingReadyNonce = undefined;
540
- state = {
541
- ...state,
542
- enabled: false,
543
- planPath: undefined,
544
- awaitingAction: false,
545
- };
499
+ setState(ctx, { enabled: false, planPath: undefined, awaitingAction: false });
500
+ if (planPath && !options.keepPlanFile) await deletePlanFile(planPath);
501
+ }
502
+
503
+ /** Leaves Plan mode and reports it in one step, for menus and /plan alike. */
504
+ function exitAndNotify(
505
+ ctx: ExtensionContext,
506
+ text: string,
507
+ options: { keepPlanFile?: boolean } = {},
508
+ ) {
509
+ return exitPlanMode(ctx, options).then(() => ctx.ui.notify(text, "info"));
510
+ }
511
+
512
+ function notifyEnabled(ctx: ExtensionContext) {
513
+ ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
514
+ }
515
+
516
+ /** State moves as one: what is remembered, what is persisted, what is shown. */
517
+ function setState(ctx: ExtensionContext, patch: Partial<PlanModeState>) {
518
+ state = { ...state, ...patch };
546
519
  persistState();
547
520
  updateUi(ctx);
548
- if (planPath && !options.keepPlanFile) await deletePlanFile(planPath);
521
+ }
522
+
523
+ /**
524
+ * Sends the message a state change exists to produce, and puts the previous
525
+ * state back when the session refuses it: a mode switch the model was never
526
+ * told about is worse than no switch at all.
527
+ */
528
+ function sendOrRevert(message: string, ctx: ExtensionContext, previousState: PlanModeState) {
529
+ if (sendPlanModeUserMessage(message, ctx)) return;
530
+ setState(ctx, previousState);
549
531
  }
550
532
 
551
533
  function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) {
@@ -573,10 +555,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
573
555
  throw new Error(`Unable to save the plan to ${planPath}: ${detail}`);
574
556
  }
575
557
  sessionPlanPath = planPath;
576
- state = { ...state, planPath, awaitingAction: true };
577
558
  pendingReadyNonce = ++readyPresentationNonce;
578
- persistState();
579
- updateUi(ctx);
559
+ setState(ctx, { planPath, awaitingAction: true });
580
560
  showPlanModePlan(pi, ctx, "Proposed Plan", plan);
581
561
  return planPath;
582
562
  }
@@ -616,39 +596,28 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
616
596
  return;
617
597
  }
618
598
 
619
- workflowGeneration += 1;
599
+ lifecycle.nextWorkflow();
620
600
  const previousState = state;
621
601
  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
- }
602
+ setState(ctx, { enabled: false, awaitingAction: false, planPath });
603
+ sendOrRevert(formatImplementationHandoff(planPath), ctx, previousState);
636
604
  }
637
605
 
638
606
  async function showLaunchMenu(ctx: ExtensionContext) {
639
- const lifecycle = captureMenuLifecycle();
640
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
607
+ const menu = lifecycle.capture();
608
+ if (!menu.isCurrent() || menu.signal.aborted) return;
641
609
  const ui = await loadInteractiveUi();
642
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
610
+ if (!menu.isCurrent() || menu.signal.aborted) return;
643
611
  await ui.showPlanLaunchMenu(ctx, {
644
612
  statusText: "Status: Off.",
645
- ...lifecycle,
613
+ signal: menu.signal,
614
+ isCurrent: menu.isCurrent,
646
615
  start: (signal) => {
647
- if (signal.aborted || !lifecycle.isCurrent()) return;
616
+ if (signal.aborted || !menu.isCurrent()) return;
648
617
  enterPlanMode(ctx);
649
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
618
+ notifyEnabled(ctx);
650
619
  },
651
- settings: (signal) => showSettings(ctx, signal, lifecycle.isCurrent),
620
+ settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
652
621
  });
653
622
  }
654
623
 
@@ -657,27 +626,25 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
657
626
  ctx.ui.notify(planStatusText(), "info");
658
627
  return;
659
628
  }
660
- const lifecycle = captureMenuLifecycle();
661
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
629
+ const menu = lifecycle.capture();
630
+ if (!menu.isCurrent() || menu.signal.aborted) return;
662
631
  const ui = await loadInteractiveUi();
663
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
632
+ if (!menu.isCurrent() || menu.signal.aborted) return;
664
633
  await ui.showActiveImplementationMenu(ctx, {
665
634
  statusText: planStatusText(),
666
635
  ...(state.planPath ? { planPathLine: `Plan file: ${state.planPath}` } : {}),
667
636
  getExportDestination: () => planExports.getDestination(ctx),
668
- signal: lifecycle.signal,
669
- isCurrent: lifecycle.isCurrent,
637
+ signal: menu.signal,
638
+ isCurrent: menu.isCurrent,
670
639
  show: () => showStoredPlan(pi, ctx, state),
671
- exportPlan: (path, signal) => planExports.export(path, ctx, signal, lifecycle.isCurrent),
672
- settings: (signal) => showSettings(ctx, signal, lifecycle.isCurrent),
640
+ exportPlan: (path, signal) => planExports.export(path, ctx, signal, menu.isCurrent),
641
+ settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
673
642
  startNew: () => {
674
643
  enterPlanMode(ctx);
675
- ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
644
+ notifyEnabled(ctx);
676
645
  },
677
646
  clear: () => {
678
- void exitPlanMode(ctx).then(() => {
679
- ctx.ui.notify("Active implementation plan cleared.", "info");
680
- });
647
+ void exitAndNotify(ctx, "Active implementation plan cleared.");
681
648
  },
682
649
  });
683
650
  }
@@ -704,37 +671,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
704
671
  return result.kind === "closed" && "reason" in result && result.reason === "close";
705
672
  }
706
673
 
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
674
  function resolveSessionPlanPath(ctx: ExtensionContext) {
739
675
  try {
740
676
  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;
package/src/prompt.ts CHANGED
@@ -1,3 +1,25 @@
1
+ import { join } from "node:path";
2
+
3
+ /**
4
+ * The plan-craft document: what decision-complete means, why exploration comes
5
+ * before questions, what separates a question worth asking from one the
6
+ * repository already answers, and what a finished plan contains.
7
+ *
8
+ * It used to ship as a skill. A skill buys one thing an injected pointer
9
+ * cannot: a description line in every system prompt, so the model could
10
+ * propose planning unprompted. Across ~220 sessions after it shipped, every
11
+ * read of the file happened after the Plan Mode prompt was already active —
12
+ * never off the description — and the model never suggested `/plan` on its
13
+ * own. So the line was a tax on every session that never planned (~95% of
14
+ * them) and bought nothing. An absolute path, injected only while Plan Mode
15
+ * is active, is the same document at zero cost outside it, and a hard path
16
+ * beats "if it is available".
17
+ *
18
+ * Resolved from this module's own location so it survives every install
19
+ * layout (git, npm, workspace symlink, `npm link`).
20
+ */
21
+ export const PLAN_CRAFT_DOC = join(import.meta.dirname, "..", "docs", "plan-craft.md");
22
+
1
23
  const PLAN_CONTEXT_MARKER = "[PLAN MODE ACTIVE]";
2
24
 
3
25
  /** The built-in question tool. Used whenever nothing better is installed. */
@@ -71,7 +93,7 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
71
93
 
72
94
  ## Mode rules
73
95
 
74
- - Read the pi-plan-mode skill before planning if it is available: it carries the plan-crafting craft — decision-completeness, exploring before asking, question quality, and what a finished plan contains.
96
+ - Before planning, read ${PLAN_CRAFT_DOC}: it carries the plan-crafting craft — decision-completeness, exploring before asking, question quality, and what a finished plan contains.
75
97
  - Stay in Plan Mode until a developer or extension explicitly exits it.
76
98
  - Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
77
99
  - Do not use todo/checklist tooling to track execution progress in Plan Mode; Plan Mode is conversational planning, and the plan itself belongs in plan_mode_complete.
@@ -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;
@@ -151,15 +151,32 @@ export function normalizePlanModeQuestionParams(
151
151
  export async function answerPlanModeQuestions(
152
152
  questions: PlanModeQuestion[],
153
153
  ctx: ExtensionContext,
154
- lifecycle: { isCurrent(): boolean; isEnabled(): boolean },
154
+ lifecycle: {
155
+ isCurrent(): boolean;
156
+ isEnabled(): boolean;
157
+ /**
158
+ * Called with `true` while a selector is open and `false` once it closes,
159
+ * however it closes. plan-mode.ts forwards this to Herdr so a supervising
160
+ * agent in another pane sees "blocked on a human", not "working".
161
+ */
162
+ onBlocked?(active: boolean): void;
163
+ },
155
164
  signal?: AbortSignal,
156
165
  ) {
157
- const answers = await askPlanModeQuestions(
158
- questions,
159
- ctx,
160
- () => lifecycle.isCurrent() && lifecycle.isEnabled() && !signal?.aborted,
161
- signal,
162
- );
166
+ lifecycle.onBlocked?.(true);
167
+ let answers: PlanModeQuestionAnswer[] | undefined;
168
+ try {
169
+ answers = await askPlanModeQuestions(
170
+ questions,
171
+ ctx,
172
+ () => lifecycle.isCurrent() && lifecycle.isEnabled() && !signal?.aborted,
173
+ signal,
174
+ );
175
+ } finally {
176
+ // In `finally` so a throw or a session replacement never leaves Herdr
177
+ // believing this pane is still waiting on someone.
178
+ lifecycle.onBlocked?.(false);
179
+ }
163
180
  if (!lifecycle.isCurrent()) {
164
181
  return planModeQuestionCancelled(
165
182
  questions,
@@ -184,7 +201,7 @@ export async function answerPlanModeQuestions(
184
201
  return planModeQuestionAnswered(questions, answers);
185
202
  }
186
203
 
187
- export async function askPlanModeQuestions(
204
+ async function askPlanModeQuestions(
188
205
  questions: PlanModeQuestion[],
189
206
  ctx: ExtensionContext,
190
207
  shouldContinue: () => boolean = () => true,
@@ -243,7 +260,7 @@ function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: num
243
260
  return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
244
261
  }
245
262
 
246
- export function planModeQuestionAnswered(
263
+ function planModeQuestionAnswered(
247
264
  questions: PlanModeQuestion[],
248
265
  answers: PlanModeQuestionAnswer[],
249
266
  ) {