@openlines/opl-pi-sht 0.1.21 → 0.2.1

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.
@@ -45,14 +45,14 @@ import {
45
45
  getModeDefinition,
46
46
  executeHandoffAllowed,
47
47
  withPlanComplete,
48
+ planCompleteAllowed,
48
49
  LAZY_TOOLS,
49
50
  LOADER_TOOL_NAME,
50
51
  applyLazyPolicy,
51
52
  lazyToolsToEnable,
52
53
  } from "./config.js";
53
54
  import {
54
- isSafeCommand,
55
- isDestructive,
55
+ bashBlockReason,
56
56
  extractPlanText,
57
57
  isPlanLike,
58
58
  ensurePlanDir,
@@ -69,6 +69,8 @@ import {
69
69
  setRefining,
70
70
  getActivePlanFile,
71
71
  setActivePlanFile,
72
+ getRestoringModel,
73
+ setRestoringModel,
72
74
  transition,
73
75
  enterPlanWithFile,
74
76
  restore,
@@ -120,22 +122,51 @@ export default function modeSwitcher(pi: ExtensionAPI) {
120
122
  // ─── Saved model for restoring ─────────────────────────────────────────────
121
123
  let savedModel: Model<any> | null = null;
122
124
  const queueModelChange = createLatestModelQueue();
123
- const MAX_REFINE_CYCLES = 5;
124
-
125
- function saveAndSetActiveTools(toolNames: string[]): void {
126
- if (savedToolNames === null) {
127
- savedToolNames = pi.getAllTools().map((t) => t.name);
125
+ // Consecutive restore failures for the same model, so an un-authable restore point is
126
+ // retried once and then released instead of blocking future captures forever.
127
+ let restoreAttempts = 0;
128
+
129
+ function saveAndSetActiveTools(toolNames: string[], resting = false): void {
130
+ if (resting) {
131
+ // OFF is the resting baseline: drop any snapshot from the mode being exited, otherwise a
132
+ // later tool-less mode would restore the pre-OFF set and widen past the pinned baseline.
133
+ savedToolNames = null;
134
+ } else if (savedToolNames === null) {
135
+ // Snapshot what is actually active, not every registered tool: entering a mode must
136
+ // never widen the set the user (or Pi settings) narrowed before it.
137
+ savedToolNames = pi.getActiveTools();
128
138
  }
129
139
  pi.setActiveTools(applyLazyPolicy(toolNames));
130
140
  }
131
141
 
142
+ /** Plan-mode tool list: honors a modes.plan.tools override, not just the shared default. */
143
+ function planModeTools(): string[] {
144
+ return getModeDefinition("plan")?.tools ?? PLAN_MODE_TOOLS;
145
+ }
146
+
147
+ /**
148
+ * Pi's setActiveTools() silently drops names it does not know, so a typo in
149
+ * modes.<name>.tools quietly shrinks the mode. Say so once per name per session.
150
+ */
151
+ const warnedTools = new Set<string>();
152
+ function warnUnknownTools(ctx: ExtensionContext, mode: string, tools?: string[]): void {
153
+ if (!ctx.hasUI || !tools) return;
154
+ const known = new Set(pi.getAllTools().map((t) => t.name));
155
+ for (const name of tools) {
156
+ if (known.has(name) || warnedTools.has(name)) continue;
157
+ warnedTools.add(name);
158
+ ctx.ui.notify(`[mode-switcher] modes.${mode}.tools: unknown tool "${name}" is ignored by Pi`, "warning");
159
+ }
160
+ }
161
+
132
162
  /** Queue model changes so a superseded async switch cannot finish after its restore. */
133
- function setQueuedModel(ctx: ExtensionContext, model: Model<any>, label: string): Promise<void> {
163
+ function setQueuedModel(ctx: ExtensionContext, model: Model<any>, label: string): Promise<boolean | undefined> {
134
164
  return queueModelChange(async () => {
135
165
  const success = await pi.setModel(model);
136
166
  if (!success && ctx.hasUI) ctx.ui.notify(`[mode-switcher] No API key for model ${label}`, "error");
137
167
  updateStatus(ctx);
138
- }).then(() => undefined);
168
+ return success;
169
+ });
139
170
  }
140
171
 
141
172
  async function switchToModel(ctx: ExtensionContext, modelRef: { provider: string; id: string }): Promise<void> {
@@ -158,18 +189,41 @@ export default function modeSwitcher(pi: ExtensionAPI) {
158
189
  async function applyModeModel(ctx: ExtensionContext, modeDef: { model?: { provider: string; id: string } } | undefined): Promise<void> {
159
190
  const modelRef = modeDef?.model;
160
191
  if (!modelRef) return;
161
- if (savedModel === null) {
162
- savedModel = ctx.model ?? null;
192
+ const current = ctx.model ?? null;
193
+ const alreadyOnModeModel = current !== null && current.provider === modelRef.provider && current.id === modelRef.id;
194
+ // Capture the restore point once per mode, and never when the session is already on
195
+ // the mode's own model (after /reload or /resume inside a mode) or when a restore
196
+ // point is already persisted — otherwise the real previous model is lost.
197
+ if (savedModel === null && getRestoringModel() === null && !alreadyOnModeModel) {
198
+ rememberRestoreModel(current);
163
199
  }
164
200
  await switchToModel(ctx, modelRef);
165
201
  }
166
202
 
203
+ /** Record the model to return to, in memory and in the session blob. */
204
+ function rememberRestoreModel(model: Model<any> | null): void {
205
+ savedModel = model;
206
+ if (model) restoreAttempts = 0;
207
+ setRestoringModel(model ? { provider: model.provider, id: model.id } : null, pi);
208
+ }
209
+
167
210
  /** Restore whatever model was active before applyModeModel last changed it. */
168
211
  function restoreModelIfSaved(ctx: ExtensionContext): void {
169
- if (savedModel === null) return;
170
- const toRestore = savedModel;
171
- savedModel = null;
172
- void setQueuedModel(ctx, toRestore, `${toRestore.provider}/${toRestore.id}`);
212
+ const ref = savedModel ?? getRestoringModel();
213
+ const toRestore = ref ? (savedModel ?? ctx.modelRegistry.find(ref.provider, ref.id)) : null;
214
+ rememberRestoreModel(null);
215
+ // The model left the registry (provider removed): drop the point so the next entry captures a fresh one.
216
+ if (!toRestore) return;
217
+ void setQueuedModel(ctx, toRestore, `${toRestore.provider}/${toRestore.id}`).then((outcome) => {
218
+ // false = the model exists but has no auth yet (fixed by /login): keep it for one retry.
219
+ // undefined = superseded by a newer switch, which now owns the restore.
220
+ if (outcome === false && ref && restoreAttempts < 1) {
221
+ restoreAttempts++;
222
+ setRestoringModel({ provider: ref.provider, id: ref.id }, pi);
223
+ } else {
224
+ restoreAttempts = 0;
225
+ }
226
+ });
173
227
  }
174
228
 
175
229
  /**
@@ -182,8 +236,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
182
236
  function applyOffModel(ctx: ExtensionContext): void {
183
237
  const offModeDef = getModeDefinition("off");
184
238
  if (offModeDef?.model) {
185
- savedModel = null;
186
- switchToModel(ctx, offModeDef.model);
239
+ rememberRestoreModel(null);
240
+ void switchToModel(ctx, offModeDef.model);
187
241
  } else {
188
242
  restoreModelIfSaved(ctx);
189
243
  }
@@ -195,12 +249,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
195
249
  }
196
250
 
197
251
  function restoreAllTools(): void {
198
- if (savedToolNames !== null) {
199
- pi.setActiveTools(applyLazyPolicy(toolsWithoutPlanComplete(savedToolNames)));
200
- savedToolNames = null;
201
- } else {
202
- pi.setActiveTools(applyLazyPolicy(toolsWithoutPlanComplete(pi.getAllTools().map((t) => t.name))));
203
- }
252
+ const names = savedToolNames ?? pi.getActiveTools();
253
+ pi.setActiveTools(applyLazyPolicy(toolsWithoutPlanComplete(names)));
254
+ savedToolNames = null;
204
255
  }
205
256
 
206
257
  // ─── Plan file helpers ─────────────────────────────────────────────────────
@@ -227,6 +278,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
227
278
  function updateStatus(ctx: ExtensionContext): void {
228
279
  const mode = getMode();
229
280
  const modeDef = getModeDefinition(mode);
281
+ warnUnknownTools(ctx, mode, modeDef?.tools);
230
282
 
231
283
  if (!ctx.hasUI) return;
232
284
 
@@ -302,7 +354,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
302
354
  function enterPlanMode(ctx: ExtensionContext): void {
303
355
  transition("plan", pi);
304
356
  const modeDef = getModeDefinition("plan");
305
- saveAndSetActiveTools(modeDef?.tools ?? PLAN_MODE_TOOLS);
357
+ saveAndSetActiveTools(planModeTools());
306
358
  applyModeModel(ctx, modeDef);
307
359
  updateStatus(ctx);
308
360
  if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
@@ -314,7 +366,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
314
366
 
315
367
  async function enterExecuteMode(ctx: ExtensionContext): Promise<void> {
316
368
  transition("execute", pi);
317
- const baseNames = savedToolNames ?? pi.getAllTools().map((t) => t.name);
369
+ const baseNames = savedToolNames ?? pi.getActiveTools();
318
370
  pi.setActiveTools(applyLazyPolicy([...toolsWithoutPlanComplete(baseNames), "plan_complete"]));
319
371
  savedToolNames = null;
320
372
  const modeDef = getModeDefinition("execute");
@@ -337,7 +389,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
337
389
  */
338
390
  function enterOffMode(ctx: ExtensionContext, message?: string, silent = false): void {
339
391
  transition("off", pi);
340
- restoreAllTools();
392
+ const offModeDef = getModeDefinition("off");
393
+ // OFF gates tools only when the user declares modes.off.tools; otherwise it restores
394
+ // whatever was active before the mode.
395
+ if (offModeDef?.tools) {
396
+ saveAndSetActiveTools(withPlanComplete("off", offModeDef.tools), true);
397
+ } else {
398
+ restoreAllTools();
399
+ }
341
400
  applyOffModel(ctx);
342
401
  updateStatus(ctx);
343
402
  if (!silent && ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
@@ -358,6 +417,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
358
417
  saveAndSetActiveTools(withPlanComplete(name, modeDef.tools));
359
418
  } else {
360
419
  restoreAllTools();
420
+ // Inherited (unlisted) tool set: allowPlanComplete still needs the tool active,
421
+ // otherwise the mode can never finish through the completion path.
422
+ if (modeDef?.allowPlanComplete) pi.setActiveTools(withPlanComplete(name, pi.getActiveTools()));
361
423
  }
362
424
  applyModeModel(ctx, modeDef);
363
425
  updateStatus(ctx);
@@ -422,7 +484,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
422
484
  const filename = choice.slice("execute:".length);
423
485
  if (current !== "off") enterOffMode(ctx, undefined, true);
424
486
  enterPlanWithFile(filename, pi);
425
- saveAndSetActiveTools(PLAN_MODE_TOOLS);
487
+ saveAndSetActiveTools(planModeTools());
426
488
  await enterExecuteMode(ctx);
427
489
  pi.sendUserMessage("Execute the plan steps now.", { deliverAs: "followUp" });
428
490
  } else if (getModeDefinition(choice)) {
@@ -459,14 +521,16 @@ export default function modeSwitcher(pi: ExtensionAPI) {
459
521
  const modeDef = getModeDefinition(mode);
460
522
 
461
523
  if (modeDef?.tools) {
462
- saveAndSetActiveTools(modeDef.tools);
524
+ // Honor allowPlanComplete here too, otherwise a resume drops the tool a mode was given.
525
+ // OFF's declared list is the resting baseline, so it must not keep a stale snapshot.
526
+ saveAndSetActiveTools(withPlanComplete(mode, modeDef.tools), mode === "off");
463
527
  } else if (mode === "execute") {
464
- const allNames = pi.getAllTools().map((t) => t.name);
528
+ const allNames = pi.getActiveTools();
465
529
  pi.setActiveTools(applyLazyPolicy([...toolsWithoutPlanComplete(allNames), "plan_complete"]));
466
530
  } else {
467
- // Default: all tools except plan_complete
468
- const allNames = pi.getAllTools().map((t) => t.name);
469
- pi.setActiveTools(applyLazyPolicy(toolsWithoutPlanComplete(allNames)));
531
+ // Default: the active set minus plan_complete, re-added when the mode allows it.
532
+ const allNames = pi.getActiveTools();
533
+ pi.setActiveTools(applyLazyPolicy(withPlanComplete(mode, toolsWithoutPlanComplete(allNames))));
470
534
  }
471
535
 
472
536
  // Apply the mode's model BEFORE sending any resume follow-up message, so
@@ -552,10 +616,10 @@ export default function modeSwitcher(pi: ExtensionAPI) {
552
616
  // plan_complete is only callable in execute mode
553
617
  const mode = getMode();
554
618
  const modeDef = getModeDefinition(mode);
555
- if (event.toolName === "plan_complete" && !modeDef?.allowPlanComplete) {
619
+ if (event.toolName === "plan_complete" && !planCompleteAllowed(mode)) {
556
620
  return {
557
621
  block: true,
558
- reason: `[mode-switcher] plan_complete only available in execute mode. Current mode: ${mode}`,
622
+ reason: `[mode-switcher] plan_complete is only available in execute mode or a mode with allowPlanComplete. Current mode: ${mode}`,
559
623
  };
560
624
  }
561
625
 
@@ -564,29 +628,11 @@ export default function modeSwitcher(pi: ExtensionAPI) {
564
628
  if (!modeDef?.safePatterns && !modeDef?.destructivePatterns) return {};
565
629
 
566
630
  const command = event.input.command as string;
567
-
568
- // Check safe patterns (if defined, command must match at least one)
569
- if (modeDef.safePatterns && modeDef.safePatterns.length > 0) {
570
- const matchesSafe = modeDef.safePatterns.some((pattern) => pattern.test(command));
571
- if (!matchesSafe) {
572
- return {
573
- block: true,
574
- reason: `[mode-switcher] Command blocked — not in safe pattern list for ${mode} mode: ${command}`,
575
- };
576
- }
631
+ const reason = bashBlockReason(command, modeDef.safePatterns, modeDef.destructivePatterns);
632
+ if (reason) {
633
+ return { block: true, reason: `[mode-switcher] Command blocked — ${reason} [${mode} mode]` };
577
634
  }
578
635
 
579
- // Check destructive patterns (if defined, must not match any) — also against
580
- // a quote/backslash-stripped skeleton so r"m"/r\m obfuscation can't dodge \brm\b.
581
- if (modeDef.destructivePatterns && modeDef.destructivePatterns.length > 0) {
582
- if (isDestructive(command, modeDef.destructivePatterns)) {
583
- return {
584
- block: true,
585
- reason: `[mode-switcher] Command blocked — destructive pattern in ${mode} mode: ${command}`,
586
- };
587
- }
588
- }
589
-
590
636
  return {};
591
637
  });
592
638
 
@@ -596,15 +642,18 @@ export default function modeSwitcher(pi: ExtensionAPI) {
596
642
  name: "plan_complete",
597
643
  label: "Plan Complete",
598
644
  description:
599
- "Signal that all plan steps have been executed. ONLY callable in EXECUTE mode. Call this once after finishing the final step. This exits execute mode. Do NOT call this outside execute mode.",
645
+ "Signal that all plan steps have been executed. ONLY callable in EXECUTE mode or in a mode configured with allowPlanComplete. Call this once after finishing the final step. This exits the mode. Do NOT call this outside those modes.",
600
646
  parameters: Type.Object({}),
601
647
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
602
- if (getMode() !== "execute") {
603
- return { content: [{ type: "text", text: "plan_complete is only available in execute mode." }], details: undefined };
648
+ if (!planCompleteAllowed(getMode())) {
649
+ return {
650
+ content: [{ type: "text", text: "plan_complete is only available in execute mode or a mode with allowPlanComplete." }],
651
+ details: undefined,
652
+ };
604
653
  }
605
654
  cleanupPlanFile();
606
655
  enterOffMode(ctx, "Plan implemented. Plan mode OFF.");
607
- return { content: [{ type: "text", text: "Execute mode exited." }], details: undefined };
656
+ return { content: [{ type: "text", text: "Plan mode exited." }], details: undefined };
608
657
  },
609
658
  });
610
659
 
@@ -612,7 +661,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
612
661
 
613
662
  pi.on("tool_result", (event: ToolResultEvent, ctx: ExtensionContext) => {
614
663
  if (event.toolName !== "plan_complete") return;
615
- if (getMode() !== "execute") return;
664
+ // The tool body has usually already returned to OFF, which makes this a no-op; the check
665
+ // stays mode-based (not execute-only) so a custom allowPlanComplete mode is handled alike.
666
+ if (getMode() !== "execute" && !planCompleteAllowed(getMode())) return;
616
667
  cleanupPlanFile();
617
668
  enterOffMode(ctx, "Plan implemented. Plan mode OFF.");
618
669
  });
@@ -623,7 +674,17 @@ export default function modeSwitcher(pi: ExtensionAPI) {
623
674
  // If plan_complete was never called, exit execute mode automatically.
624
675
  // (If it was called, mode is already "off" here — this check is a no-op.)
625
676
  if (getMode() === "execute") {
626
- enterOffMode(ctx, "Execution complete. Mode OFF.");
677
+ // An aborted turn (ESC) is a pause, not a finished execution: stay in execute mode
678
+ // so the plan can be resumed instead of silently dropping out mid-plan. Pi emits an
679
+ // assistant message with stopReason "aborted" even when the abort lands before the
680
+ // first token, so there is always a message to read here.
681
+ const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant") as
682
+ | { stopReason?: string }
683
+ | undefined;
684
+ // Only a turn that actually finished counts as done: "aborted"/"error" (and a missing
685
+ // assistant message) are pauses, so execute mode survives ESC and provider failures.
686
+ const reason = lastAssistant?.stopReason;
687
+ if (reason && reason !== "aborted" && reason !== "error") enterOffMode(ctx, "Execution complete. Mode OFF.");
627
688
  return;
628
689
  }
629
690
 
@@ -716,7 +777,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
716
777
  }
717
778
  const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
718
779
  enterPlanWithFile(filename, pi);
719
- saveAndSetActiveTools(PLAN_MODE_TOOLS);
780
+ saveAndSetActiveTools(planModeTools());
720
781
  updateStatus(ctx);
721
782
  const createTitle = titleFromFilename(filename);
722
783
  if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
@@ -734,8 +795,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
734
795
  if (getMode() !== "plan") return;
735
796
 
736
797
  const refineCount = getRefineCount();
737
- const refineLabel = refineCount >= MAX_REFINE_CYCLES
738
- ? `Refine (${refineCount} cycles — consider saving)`
798
+ const refineLabel = refineCount > 0
799
+ ? `Refine (${refineCount} ${refineCount === 1 ? "cycle" : "cycles"} so far)`
739
800
  : "Refine";
740
801
  const options: SelectItem[] = [
741
802
  { value: "execute", label: "Execute" },
@@ -785,7 +846,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
785
846
  /** Load an existing plan file and show the action menu. */
786
847
  function loadPlanAndShowMenu(ctx: ExtensionContext, filename: string, displayName: string): void {
787
848
  enterPlanWithFile(filename, pi);
788
- saveAndSetActiveTools(PLAN_MODE_TOOLS);
849
+ saveAndSetActiveTools(planModeTools());
789
850
  updateStatus(ctx);
790
851
  if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
791
852
  ctx.ui.notify(USER_CONFIG.labels.plan.notifyLoaded.replace("{title}", displayName), "info");
@@ -819,10 +880,15 @@ export default function modeSwitcher(pi: ExtensionAPI) {
819
880
  return container;
820
881
  });
821
882
 
822
- // ─── Command: /mode [chat|plan|off] ────────────────────────────────────────
883
+ // ─── Command: /mode [chat|plan|off|normal|<custom>] ──────────────────────
884
+
885
+ /** Names /mode accepts as an argument: the built-ins plus every registered custom mode. */
886
+ function modeArgumentNames(): string[] {
887
+ return ["off", "normal", "chat", "plan", ...Array.from(MODE_REGISTRY.keys()).filter((n) => n !== "off" && n !== "chat" && n !== "plan" && n !== "execute")];
888
+ }
823
889
 
824
890
  pi.registerCommand("mode", {
825
- description: "Mode switcher: /mode (open picker) · /mode chat · /mode plan · /mode normal",
891
+ description: "Mode switcher: /mode (open picker) · /mode chat · /mode plan · /mode normal · /mode <custom>",
826
892
  handler: async (args: string, ctx) => {
827
893
  const input = args.trim().toLowerCase();
828
894
 
@@ -860,7 +926,27 @@ export default function modeSwitcher(pi: ExtensionAPI) {
860
926
  return;
861
927
  }
862
928
 
863
- if (ctx.hasUI) ctx.ui.notify(`Unknown mode: "${input}". Use: chat, plan, normal`, "warning");
929
+ // Any other registered mode: config-declared custom modes (modes.<name>) and built-in
930
+ // overrides, so /mode review works the same as picking it from the picker.
931
+ if (getModeDefinition(input)) {
932
+ if (input === "execute") {
933
+ if (ctx.hasUI) ctx.ui.notify("Execute mode needs an active plan — use /execute", "info");
934
+ return;
935
+ }
936
+ if (getModeDefinition(input)?.enabled === false) {
937
+ if (ctx.hasUI) ctx.ui.notify(`Mode "${input}" is disabled (modes.${input}.enabled: false)`, "warning");
938
+ return;
939
+ }
940
+ if (getMode() === input) {
941
+ if (ctx.hasUI) ctx.ui.notify(`Already in ${input} mode`, "info");
942
+ return;
943
+ }
944
+ if (getMode() !== "off") enterOffMode(ctx, undefined, true);
945
+ enterCustomMode(ctx, input);
946
+ return;
947
+ }
948
+
949
+ if (ctx.hasUI) ctx.ui.notify(`Unknown mode: "${input}". Use: ${modeArgumentNames().join(", ")}`, "warning");
864
950
  },
865
951
  });
866
952
 
@@ -958,7 +1044,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
958
1044
  }
959
1045
  if (getMode() !== "off") enterOffMode(ctx, undefined, true);
960
1046
  enterPlanWithFile(filename, pi);
961
- saveAndSetActiveTools(PLAN_MODE_TOOLS);
1047
+ saveAndSetActiveTools(planModeTools());
962
1048
  updateStatus(ctx);
963
1049
  if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
964
1050
  ctx.ui.notify(USER_CONFIG.labels.plan.notifyWithTitle.replace("{title}", titleFromFilename(filename)), USER_CONFIG.labels.plan.notifyType as "info" | "warning" | "error");
@@ -983,7 +1069,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
983
1069
  async function startExecute(filename: string): Promise<void> {
984
1070
  if (current !== "off") enterOffMode(ctx, undefined, true);
985
1071
  enterPlanWithFile(filename, pi);
986
- saveAndSetActiveTools(PLAN_MODE_TOOLS);
1072
+ saveAndSetActiveTools(planModeTools());
987
1073
  await enterExecuteMode(ctx);
988
1074
  pi.sendUserMessage("Execute the plan steps now.", { deliverAs: "followUp" });
989
1075
  }
@@ -1,17 +1,19 @@
1
1
  /** OFF/CHAT/PLAN/EXECUTE unified state machine with session persistence via appendEntry blob. */
2
2
 
3
3
  import { ENTRY_TYPE, getModeDefinition } from "./config.js";
4
- import type { AgentMode, AgentModeBlob, ModeAppearanceConfig } from "./types.js";
4
+ import type { AgentMode, AgentModeBlob, ModeAppearanceConfig, ModeModelConfig } from "./types.js";
5
5
 
6
6
  /** Mutable state — shared within the extension module. */
7
7
  const state: {
8
8
  mode: AgentMode;
9
9
  activePlanFile: string | null;
10
+ restoringModel: ModeModelConfig | null;
10
11
  refining: boolean;
11
12
  refineCount: number;
12
13
  } = {
13
14
  mode: "off",
14
15
  activePlanFile: null,
16
+ restoringModel: null,
15
17
  refining: false,
16
18
  refineCount: 0,
17
19
  };
@@ -29,8 +31,9 @@ function syncGlobalThis(): void {
29
31
  mode: m,
30
32
  appearance: getModeDefinition(m)?.appearance,
31
33
  } satisfies { mode: AgentMode; appearance?: ModeAppearanceConfig };
32
- // Legacy globals for backward compat with chat-input and footer segments
33
- (globalThis as Record<string, unknown>).__planMode = { mode: m === "chat" ? "off" : m };
34
+ // Legacy globals for backward compat with chat-input and footer segments. Only the
35
+ // plan-family modes light up __planMode, so a custom mode cannot read as "Plan mode ON".
36
+ (globalThis as Record<string, unknown>).__planMode = { mode: m === "plan" || m === "execute" ? m : "off" };
34
37
  (globalThis as Record<string, unknown>).__chatMode = { mode: m === "chat" ? "chat" : "off" };
35
38
  const requestRender = (globalThis as Record<string, unknown>).__footerRequestRender;
36
39
  if (typeof requestRender === "function") requestRender();
@@ -47,7 +50,17 @@ export function setRefining(value: boolean): void {
47
50
 
48
51
  export function getRefineCount(): number { return state.refineCount; }
49
52
  export function incrementRefineCount(): void { state.refineCount++; }
50
- export function resetRefineCount(): void { state.refineCount = 0; }
53
+
54
+ /** Model reference to restore when the active mode ends (null = nothing to restore). */
55
+ export function getRestoringModel(): ModeModelConfig | null { return state.restoringModel; }
56
+
57
+ export function setRestoringModel(
58
+ model: ModeModelConfig | null,
59
+ pi: { appendEntry: (type: string, data?: unknown) => void },
60
+ ): void {
61
+ state.restoringModel = model;
62
+ persist(pi);
63
+ }
51
64
 
52
65
  export function setActivePlanFile(
53
66
  file: string | null,
@@ -85,6 +98,7 @@ function persist(pi: { appendEntry: (type: string, data?: unknown) => void }): v
85
98
  pi.appendEntry(ENTRY_TYPE, {
86
99
  mode: state.mode,
87
100
  activePlanFile: state.activePlanFile,
101
+ restoreModel: state.restoringModel,
88
102
  } satisfies AgentModeBlob);
89
103
  }
90
104
 
@@ -95,6 +109,13 @@ function normalizePlanFile(file: string | null | undefined): string | null {
95
109
  return file;
96
110
  }
97
111
 
112
+ /** Keep only a well-formed `{ provider, id }` pair; anything else means "nothing to restore". */
113
+ function normalizeModelRef(model: unknown): ModeModelConfig | null {
114
+ if (typeof model !== "object" || model === null) return null;
115
+ const { provider, id } = model as Partial<ModeModelConfig>;
116
+ return typeof provider === "string" && provider && typeof id === "string" && id ? { provider, id } : null;
117
+ }
118
+
98
119
  /**
99
120
  * Restore state from the most recent relevant session entry on the current branch.
100
121
  * Checks for native mode-switcher entries first; falls back to legacy plan-mode/chat-mode entries
@@ -105,20 +126,20 @@ export function restore(
105
126
  ): boolean {
106
127
  for (let i = entries.length - 1; i >= 0; i--) {
107
128
  const entry = entries[i];
108
- if (entry.type !== "custom") continue;
109
-
110
- if (entry.customType === ENTRY_TYPE) {
111
- const data = entry.data as AgentModeBlob | undefined;
112
- // Fail closed: only apply modes the registry actually knows, otherwise a stale or
113
- // foreign-branch entry would leave the session unrestricted (no tools, no patterns).
114
- if (data?.mode && getModeDefinition(data.mode)) {
115
- state.mode = data.mode;
116
- state.activePlanFile = normalizePlanFile(data.activePlanFile);
117
- state.refining = false;
118
- syncGlobalThis();
119
- return true;
120
- }
121
- }
129
+ if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
130
+
131
+ const data = entry.data as AgentModeBlob | undefined;
132
+ if (!data?.mode) continue; // Malformed/incomplete blob: keep looking back.
133
+ // The newest well-formed entry decides. A mode the registry does not know (a removed
134
+ // custom mode, a foreign branch) restores nothing at all: falling through to an older
135
+ // entry could resume a chat/execute mode the user had already left.
136
+ if (!getModeDefinition(data.mode)) return false;
137
+ state.mode = data.mode;
138
+ state.activePlanFile = normalizePlanFile(data.activePlanFile);
139
+ state.restoringModel = normalizeModelRef(data.restoreModel);
140
+ state.refining = false;
141
+ syncGlobalThis();
142
+ return true;
122
143
  }
123
144
 
124
145
  // Backward compat: restore from legacy entries written by the old extensions.
@@ -155,6 +176,7 @@ export function restore(
155
176
  export function resetState(): void {
156
177
  state.mode = "off";
157
178
  state.activePlanFile = null;
179
+ state.restoringModel = null;
158
180
  state.refining = false;
159
181
  state.refineCount = 0;
160
182
  syncGlobalThis();
@@ -7,6 +7,8 @@ export type AgentMode = string;
7
7
  export interface AgentModeBlob {
8
8
  mode: AgentMode;
9
9
  activePlanFile: string | null;
10
+ /** Model to restore when the mode ends. Persisted so /reload and /resume keep the restore point. */
11
+ restoreModel?: ModeModelConfig | null;
10
12
  }
11
13
 
12
14
  export interface PlanFileSummary {