@openlines/opl-pi-sht 0.2.12 → 0.3.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.
Files changed (83) hide show
  1. package/README.md +101 -85
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package-lock.json +3 -3
  12. package/extensions/opl-browser/package.json +4 -4
  13. package/extensions/opl-browser/validate.ts +313 -60
  14. package/extensions/opl-configurator/README.md +40 -0
  15. package/extensions/opl-configurator/configurator.ts +151 -0
  16. package/extensions/opl-configurator/index.ts +12 -0
  17. package/extensions/opl-ctxtrim/README.md +45 -14
  18. package/extensions/opl-ctxtrim/index.ts +33 -13
  19. package/extensions/opl-footer/README.md +31 -6
  20. package/extensions/opl-footer/config.ts +11 -10
  21. package/extensions/opl-footer/configure.ts +159 -115
  22. package/extensions/opl-footer/git-status.ts +58 -1
  23. package/extensions/opl-footer/icons.ts +3 -0
  24. package/extensions/opl-footer/index.ts +209 -105
  25. package/extensions/opl-footer/segments/caveman.ts +9 -1
  26. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  27. package/extensions/opl-footer/segments/compactions.ts +13 -0
  28. package/extensions/opl-footer/segments/context.ts +6 -5
  29. package/extensions/opl-footer/segments/index.ts +2 -0
  30. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  31. package/extensions/opl-footer/segments/path.ts +1 -1
  32. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  33. package/extensions/opl-footer/theme.ts +5 -2
  34. package/extensions/opl-footer/types.ts +4 -0
  35. package/extensions/opl-guardian/README.md +17 -5
  36. package/extensions/opl-guardian/config.ts +72 -6
  37. package/extensions/opl-guardian/guardian.ts +42 -10
  38. package/extensions/opl-guardian/index.ts +52 -17
  39. package/extensions/opl-guardian/policies.ts +52 -16
  40. package/extensions/opl-init/README.md +12 -4
  41. package/extensions/opl-init/index.ts +228 -27
  42. package/extensions/opl-input/README.md +17 -3
  43. package/extensions/opl-input/config.ts +59 -5
  44. package/extensions/opl-input/configure.ts +146 -0
  45. package/extensions/opl-input/index.ts +38 -30
  46. package/extensions/opl-input/mode-style.ts +6 -2
  47. package/extensions/opl-modes/README.md +22 -8
  48. package/extensions/opl-modes/config.ts +62 -8
  49. package/extensions/opl-modes/index.ts +87 -28
  50. package/extensions/opl-modes/state.ts +50 -2
  51. package/extensions/opl-modes/types.ts +6 -0
  52. package/extensions/opl-modes/utils.ts +18 -8
  53. package/extensions/opl-questionnaire/index.ts +10 -4
  54. package/extensions/opl-questionnaire/types.ts +4 -0
  55. package/extensions/opl-simplebench/README.md +21 -15
  56. package/extensions/opl-simplebench/artifact.ts +7 -4
  57. package/extensions/opl-simplebench/benchmark.ts +96 -35
  58. package/extensions/opl-simplebench/coding.ts +9 -4
  59. package/extensions/opl-simplebench/index.ts +19 -19
  60. package/extensions/opl-simplebench/llama-server.ts +3 -2
  61. package/extensions/opl-simplebench/report.ts +0 -6
  62. package/extensions/opl-simplebench/research.ts +5 -4
  63. package/extensions/opl-simplebench/scoring.ts +4 -2
  64. package/extensions/opl-simplebench/util/config.ts +15 -8
  65. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  66. package/extensions/opl-simplebench/util/format.ts +2 -2
  67. package/extensions/opl-simplebench/util/providers.ts +23 -194
  68. package/extensions/opl-todo/README.md +5 -5
  69. package/extensions/opl-todo/config.ts +7 -2
  70. package/extensions/opl-todo/index.ts +50 -9
  71. package/extensions/opl-webaccess/README.md +31 -5
  72. package/extensions/opl-webaccess/config.ts +36 -7
  73. package/extensions/opl-webaccess/extract.ts +37 -47
  74. package/extensions/opl-webaccess/http.ts +259 -0
  75. package/extensions/opl-webaccess/index.ts +12 -6
  76. package/extensions/opl-webaccess/package-lock.json +3 -3
  77. package/extensions/opl-webaccess/package.json +4 -9
  78. package/extensions/opl-webaccess/pdf.ts +4 -4
  79. package/extensions/opl-webaccess/search.ts +19 -8
  80. package/extensions/opl-webaccess/storage.ts +134 -2
  81. package/extensions/opl-webaccess/types.ts +4 -0
  82. package/extensions/opl-webaccess/utils.ts +221 -33
  83. package/package.json +19 -12
@@ -41,6 +41,7 @@ import {
41
41
  PLAN_DIR,
42
42
  buildExecutePrompt,
43
43
  buildRefinePrompt,
44
+ capPlan,
44
45
  USER_CONFIG,
45
46
  MODE_REGISTRY,
46
47
  getModeDefinition,
@@ -57,6 +58,7 @@ import {
57
58
  extractPlanText,
58
59
  isPlanLike,
59
60
  ensurePlanDir,
61
+ planFilePath,
60
62
  titleFromFilename,
61
63
  listPlanFiles,
62
64
  sanitizePlanName,
@@ -78,6 +80,10 @@ import {
78
80
  resetState,
79
81
  getRefineCount,
80
82
  incrementRefineCount,
83
+ publishModeGlobals,
84
+ clearModeGlobals,
85
+ getSessionCwd,
86
+ setSessionCwd,
81
87
  } from "./state.js";
82
88
  import { showSelectMenu } from "./menus.js";
83
89
 
@@ -105,7 +111,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
105
111
  async execute(_toolCallId, params) {
106
112
  const mode = getMode();
107
113
  const modeDef = getModeDefinition(mode);
108
- const modeAllowed = modeDef?.tools ?? null; // null = mode inherits all tools
114
+ const modeAllowed = modeDef?.tools ?? getModeDefinition("off")?.tools ?? null; // Unlisted modes inherit the pinned baseline, if any.
109
115
  const current = pi.getActiveTools();
110
116
  const enable = lazyToolsToEnable(params.tools as string[] | undefined, current, modeAllowed);
111
117
  if (enable.length > 0) pi.setActiveTools([...current, ...enable]);
@@ -123,6 +129,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
123
129
  // ─── Saved model for restoring ─────────────────────────────────────────────
124
130
  let savedModel: Model<any> | null = null;
125
131
  const queueModelChange = createLatestModelQueue();
132
+ let pendingPlanModel: Promise<void> | null = null;
126
133
  // Consecutive restore failures for the same model, so an un-authable restore point is
127
134
  // retried once and then released instead of blocking future captures forever.
128
135
  let restoreAttempts = 0;
@@ -260,7 +267,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
260
267
  function getPlanFilePath(): string | null {
261
268
  const file = getActivePlanFile();
262
269
  if (!file) return null;
263
- return join(process.cwd(), PLAN_DIR, file);
270
+ return planFilePath(getSessionCwd(), file);
264
271
  }
265
272
 
266
273
  function getPlanDisplayTitle(): string | null {
@@ -269,7 +276,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
269
276
  const filePath = getPlanFilePath();
270
277
  if (filePath && existsSync(filePath)) {
271
278
  const heading = readFileSync(filePath, "utf-8").match(/^# Plan:\s*(.+)$/m);
272
- if (heading) return heading[1].trim();
279
+ const captured = heading?.[1];
280
+ if (captured) return captured.trim();
273
281
  }
274
282
  return titleFromFilename(file);
275
283
  }
@@ -356,7 +364,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
356
364
  transition("plan", pi);
357
365
  const modeDef = getModeDefinition("plan");
358
366
  saveAndSetActiveTools(planModeTools());
359
- applyModeModel(ctx, modeDef);
367
+ pendingPlanModel = applyModeModel(ctx, modeDef);
360
368
  updateStatus(ctx);
361
369
  if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
362
370
  const notify = modeDef?.labels?.notify ?? USER_CONFIG.labels.plan.notify;
@@ -367,10 +375,15 @@ export default function modeSwitcher(pi: ExtensionAPI) {
367
375
 
368
376
  async function enterExecuteMode(ctx: ExtensionContext): Promise<void> {
369
377
  transition("execute", pi);
370
- const baseNames = savedToolNames ?? pi.getActiveTools();
371
- pi.setActiveTools(applyLazyPolicy([...toolsWithoutPlanComplete(baseNames), "plan_complete"]));
372
- savedToolNames = null;
373
378
  const modeDef = getModeDefinition("execute");
379
+ if (modeDef?.tools) {
380
+ saveAndSetActiveTools(withPlanComplete("execute", toolsWithoutPlanComplete(modeDef.tools)));
381
+ } else {
382
+ const baseNames = savedToolNames ?? pi.getActiveTools();
383
+ pi.setActiveTools(applyLazyPolicy(withPlanComplete("execute", toolsWithoutPlanComplete(baseNames))));
384
+ savedToolNames = null;
385
+ }
386
+ await pendingPlanModel;
374
387
  await applyModeModel(ctx, modeDef);
375
388
  updateStatus(ctx);
376
389
  const title = getPlanDisplayTitle();
@@ -390,6 +403,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
390
403
  */
391
404
  function enterOffMode(ctx: ExtensionContext, message?: string, silent = false): void {
392
405
  transition("off", pi);
406
+ pendingPlanModel = null;
393
407
  const offModeDef = getModeDefinition("off");
394
408
  // OFF gates tools only when the user declares modes.off.tools; otherwise it restores
395
409
  // whatever was active before the mode.
@@ -444,7 +458,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
444
458
  /** Open the mode picker. Handles all mode transitions including cross-mode switches. */
445
459
  async function openModePicker(ctx: ExtensionContext): Promise<void> {
446
460
  const current = getMode();
447
- const planFiles = listPlanFiles();
461
+ const planFiles = listPlanFiles(getSessionCwd());
448
462
 
449
463
  // Build mode list dynamically from registry (skip invisible/disabled modes)
450
464
  const registryModes = Array.from(MODE_REGISTRY.entries())
@@ -504,7 +518,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
504
518
 
505
519
  if (!restored) {
506
520
  resetState();
507
- restoreAllTools();
521
+ const offTools = getModeDefinition("off")?.tools;
522
+ if (offTools) saveAndSetActiveTools(withPlanComplete("off", offTools), true);
523
+ else restoreAllTools();
508
524
  applyOffModel(ctx);
509
525
  updateStatus(ctx);
510
526
  return;
@@ -521,13 +537,21 @@ export default function modeSwitcher(pi: ExtensionAPI) {
521
537
  const mode = getMode();
522
538
  const modeDef = getModeDefinition(mode);
523
539
 
540
+ // A restored plan or execution needs the pinned resting set as its inherited baseline,
541
+ // not the host tools active before this branch was restored.
542
+ const offTools = getModeDefinition("off")?.tools;
543
+ if ((mode === "plan" || mode === "execute") && offTools) {
544
+ saveAndSetActiveTools(withPlanComplete("off", offTools), true);
545
+ }
546
+
524
547
  if (modeDef?.tools) {
525
548
  // Honor allowPlanComplete here too, otherwise a resume drops the tool a mode was given.
526
549
  // OFF's declared list is the resting baseline, so it must not keep a stale snapshot.
527
- saveAndSetActiveTools(withPlanComplete(mode, modeDef.tools), mode === "off");
550
+ const tools = mode === "execute" ? toolsWithoutPlanComplete(modeDef.tools) : modeDef.tools;
551
+ saveAndSetActiveTools(withPlanComplete(mode, tools), mode === "off");
528
552
  } else if (mode === "execute") {
529
553
  const allNames = pi.getActiveTools();
530
- pi.setActiveTools(applyLazyPolicy([...toolsWithoutPlanComplete(allNames), "plan_complete"]));
554
+ pi.setActiveTools(applyLazyPolicy(withPlanComplete(mode, toolsWithoutPlanComplete(allNames))));
531
555
  } else {
532
556
  // Default: the active set minus plan_complete, re-added when the mode allows it.
533
557
  const allNames = pi.getActiveTools();
@@ -551,7 +575,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
551
575
  // ─── Event: session_start ──────────────────────────────────────────────────────
552
576
 
553
577
  pi.on("session_start", async (event, ctx) => {
578
+ setSessionCwd(ctx.cwd);
579
+ // Re-publish the footer seam: a previous session in this process cleared it on shutdown.
580
+ publishModeGlobals();
554
581
  if (event.reason === "startup") {
582
+ if (getMode() === "off" && (pi.getFlag("chat") === true || pi.getFlag("plan") === true)) {
583
+ const offTools = getModeDefinition("off")?.tools;
584
+ if (offTools) saveAndSetActiveTools(withPlanComplete("off", offTools), true);
585
+ }
555
586
  if (pi.getFlag("chat") === true && getMode() === "off") {
556
587
  enterChatMode(ctx);
557
588
  return;
@@ -565,6 +596,12 @@ export default function modeSwitcher(pi: ExtensionAPI) {
565
596
  await syncStateFromBranch(ctx, event.reason === "resume" || event.reason === "fork");
566
597
  });
567
598
 
599
+ pi.on("session_shutdown", async () => {
600
+ // Hand the seam back: an orphaned mode global would keep rendering a mode that no
601
+ // longer belongs to any live session.
602
+ clearModeGlobals();
603
+ });
604
+
568
605
  // ─── Event: before_agent_start ──────────────────────────────────────────
569
606
 
570
607
  pi.on("before_agent_start", (event: BeforeAgentStartEvent): BeforeAgentStartEventResult => {
@@ -578,7 +615,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
578
615
  const filePath = getPlanFilePath();
579
616
  if (filePath && existsSync(filePath)) {
580
617
  const planContent = readFileSync(filePath, "utf-8");
581
- return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent) };
618
+ return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent, filePath) };
582
619
  }
583
620
  }
584
621
 
@@ -590,12 +627,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
590
627
  const customTemplate = modeDef.prompt;
591
628
  if (typeof customTemplate === "string" && customTemplate.trim().length > 0) {
592
629
  // User-configured template via modes.execute.prompt — {plan} is replaced with the plan file content.
630
+ // Capped here too: a custom template must not become a way around the injection budget.
631
+ const capped = capPlan(planContent, USER_CONFIG.plan.maxInjectBytes, filePath).text;
593
632
  const filled = customTemplate.includes("{plan}")
594
- ? customTemplate.replace("{plan}", planContent)
595
- : `${customTemplate}\n\nPlan:\n${planContent}`;
633
+ ? customTemplate.replace("{plan}", capped)
634
+ : `${customTemplate}\n\nPlan:\n${capped}`;
596
635
  return { systemPrompt: event.systemPrompt + "\n\n" + filled };
597
636
  }
598
- return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent) };
637
+ return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent, filePath) };
599
638
  }
600
639
 
601
640
  // Generic mode prompt from registry
@@ -628,7 +667,13 @@ export default function modeSwitcher(pi: ExtensionAPI) {
628
667
  if (event.toolName !== "bash") return {};
629
668
  if (!modeDef?.safePatterns && !modeDef?.destructivePatterns) return {};
630
669
 
631
- const command = event.input.command as string;
670
+ if (typeof event.input.command !== "string") {
671
+ // Uninspectable commands are refused rather than coerced: String(["rm","-rf"]) would
672
+ // reach the pattern matchers as "rm,-rf" and slip past a segment-based safe list.
673
+ return { block: true, reason: `[mode-switcher] Blocked a bash tool call whose command was not a string [${mode} mode].` };
674
+ }
675
+
676
+ const command = event.input.command;
632
677
  const reason = bashBlockReason(command, modeDef.safePatterns, modeDef.destructivePatterns);
633
678
  if (reason) {
634
679
  return { block: true, reason: `[mode-switcher] Command blocked — ${reason} [${mode} mode]` };
@@ -699,7 +744,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
699
744
  setRefining(false);
700
745
 
701
746
  if (planText) {
702
- const planDir = ensurePlanDir();
747
+ const planDir = ensurePlanDir(getSessionCwd());
703
748
  const activeFile = getActivePlanFile();
704
749
  let filename = activeFile;
705
750
  if (!filename) {
@@ -776,6 +821,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
776
821
  const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
777
822
  enterPlanWithFile(filename, pi);
778
823
  saveAndSetActiveTools(planModeTools());
824
+ pendingPlanModel = applyModeModel(ctx, getModeDefinition("plan"));
825
+ await pendingPlanModel;
779
826
  updateStatus(ctx);
780
827
  const createTitle = titleFromFilename(filename);
781
828
  if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
@@ -842,25 +889,30 @@ export default function modeSwitcher(pi: ExtensionAPI) {
842
889
  }
843
890
 
844
891
  /** Load an existing plan file and show the action menu. */
845
- function loadPlanAndShowMenu(ctx: ExtensionContext, filename: string, displayName: string): void {
892
+ async function loadPlanAndShowMenu(ctx: ExtensionContext, filename: string, displayName: string): Promise<void> {
846
893
  enterPlanWithFile(filename, pi);
847
894
  saveAndSetActiveTools(planModeTools());
895
+ pendingPlanModel = applyModeModel(ctx, getModeDefinition("plan"));
896
+ await pendingPlanModel;
848
897
  updateStatus(ctx);
849
898
  if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
850
899
  ctx.ui.notify(USER_CONFIG.labels.plan.notifyLoaded.replace("{title}", displayName), "info");
851
900
  }
852
901
 
853
- const filePath = join(process.cwd(), PLAN_DIR, filename);
902
+ const filePath = planFilePath(getSessionCwd(), filename);
854
903
  if (existsSync(filePath)) {
855
904
  const planContent = readFileSync(filePath, "utf-8");
856
- pi.appendEntry("plan-mode", { title: displayName, plan: planContent });
905
+ // The entry copy is display/session only (plain custom entries never reach the
906
+ // model), but it is written to the session file forever, so it gets its own budget.
907
+ const entry = capPlan(planContent, USER_CONFIG.plan.maxEntryBytes, filePath);
908
+ pi.appendEntry("plan-mode", { title: displayName, plan: entry.text, truncated: entry.truncated, file: filePath });
857
909
  }
858
910
  }
859
911
 
860
912
  // ─── Entry renderer for TUI-only plan cards (never sent to the model) ──────
861
913
 
862
914
  pi.registerEntryRenderer("plan-mode", (entry, _options, theme) => {
863
- const data = (entry.data ?? {}) as { title?: string; plan?: string };
915
+ const data = (entry.data ?? {}) as { title?: string; plan?: string; truncated?: boolean; file?: string };
864
916
  const border = new DynamicBorder((s: string) => theme.fg("border", s));
865
917
  const container = new Container();
866
918
  container.addChild(border);
@@ -870,6 +922,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
870
922
  container.addChild(new Spacer());
871
923
  container.addChild(new Text(body));
872
924
  }
925
+ if (data.truncated) {
926
+ container.addChild(new Text(theme.fg("muted", `✂ truncated copy — full plan: ${data.file ?? PLAN_DIR}`)));
927
+ }
873
928
  container.addChild(border);
874
929
  return container;
875
930
  });
@@ -982,7 +1037,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
982
1037
  if (!input) {
983
1038
  const current = getMode();
984
1039
  if (current === "off") {
985
- const files = listPlanFiles();
1040
+ const files = listPlanFiles(getSessionCwd());
986
1041
  if (files.length === 0) {
987
1042
  await promptNameAndEnterPlanMode(ctx);
988
1043
  } else {
@@ -995,7 +1050,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
995
1050
  if (choice === "__new__") {
996
1051
  await promptNameAndEnterPlanMode(ctx);
997
1052
  } else {
998
- loadPlanAndShowMenu(ctx, choice, titleFromFilename(choice));
1053
+ await loadPlanAndShowMenu(ctx, choice, titleFromFilename(choice));
999
1054
  await showPlanMenu(ctx);
1000
1055
  }
1001
1056
  }
@@ -1025,11 +1080,11 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1025
1080
  return;
1026
1081
  }
1027
1082
  const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
1028
- const filePath = join(process.cwd(), PLAN_DIR, filename);
1083
+ const filePath = planFilePath(getSessionCwd(), filename);
1029
1084
 
1030
1085
  if (existsSync(filePath)) {
1031
1086
  if (getMode() !== "off") enterOffMode(ctx, undefined, true);
1032
- loadPlanAndShowMenu(ctx, filename, titleFromFilename(filename));
1087
+ await loadPlanAndShowMenu(ctx, filename, titleFromFilename(filename));
1033
1088
  await showPlanMenu(ctx);
1034
1089
  } else {
1035
1090
  if (getMode() === "plan" || getMode() === "execute") {
@@ -1039,6 +1094,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1039
1094
  if (getMode() !== "off") enterOffMode(ctx, undefined, true);
1040
1095
  enterPlanWithFile(filename, pi);
1041
1096
  saveAndSetActiveTools(planModeTools());
1097
+ pendingPlanModel = applyModeModel(ctx, getModeDefinition("plan"));
1098
+ await pendingPlanModel;
1042
1099
  updateStatus(ctx);
1043
1100
  if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
1044
1101
  ctx.ui.notify(USER_CONFIG.labels.plan.notifyWithTitle.replace("{title}", titleFromFilename(filename)), USER_CONFIG.labels.plan.notifyType as "info" | "warning" | "error");
@@ -1078,13 +1135,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1078
1135
  pi.sendUserMessage("Execute the plan steps now.", { deliverAs: "followUp" });
1079
1136
  return;
1080
1137
  }
1081
- const files = listPlanFiles();
1138
+ const files = listPlanFiles(getSessionCwd());
1082
1139
  if (files.length === 0) {
1083
1140
  if (ctx.hasUI) ctx.ui.notify("No plan files found. Run /plan first.", "warning");
1084
1141
  return;
1085
1142
  }
1086
1143
  if (files.length === 1) {
1087
- await startExecute(files[0].name);
1144
+ const only = files[0];
1145
+ if (only) await startExecute(only.name);
1088
1146
  return;
1089
1147
  }
1090
1148
  const items: SelectItem[] = files.map(f => ({ value: f.name, label: f.title }));
@@ -1100,7 +1158,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1100
1158
  return;
1101
1159
  }
1102
1160
  const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
1103
- const filePath = join(process.cwd(), PLAN_DIR, filename);
1161
+ const filePath = planFilePath(getSessionCwd(), filename);
1104
1162
  if (!existsSync(filePath)) {
1105
1163
  if (ctx.hasUI) ctx.ui.notify(`Plan "${input}" not found. Check /plan for available plans.`, "warning");
1106
1164
  return;
@@ -1124,6 +1182,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1124
1182
 
1125
1183
  const idx = order.indexOf(current);
1126
1184
  const next = idx === -1 ? order[0] : order[(idx + 1) % order.length];
1185
+ if (!next) return; // order is non-empty above; this is what lets the branches below take a string
1127
1186
  if (next === current) return;
1128
1187
 
1129
1188
  if (next === "off") {
@@ -7,12 +7,17 @@ import type { AgentMode, AgentModeBlob, ModeAppearanceConfig, ModeModelConfig }
7
7
  const state: {
8
8
  mode: AgentMode;
9
9
  activePlanFile: string | null;
10
+ sessionCwd: string;
10
11
  restoringModel: ModeModelConfig | null;
11
12
  refining: boolean;
12
13
  refineCount: number;
13
14
  } = {
14
15
  mode: "off",
15
16
  activePlanFile: null,
17
+ // Until the first `session_start` hands us the real one, the directory the harness was started
18
+ // in is the only candidate. Plans and the footer path must never be resolved from
19
+ // `process.cwd()` at the call site: a session opened elsewhere would write its plans there.
20
+ sessionCwd: process.cwd(),
16
21
  restoringModel: null,
17
22
  refining: false,
18
23
  refineCount: 0,
@@ -23,6 +28,23 @@ const state: {
23
28
  (globalThis as Record<string, unknown>).__planMode = { mode: "off" };
24
29
  (globalThis as Record<string, unknown>).__chatMode = { mode: "off" };
25
30
 
31
+ /**
32
+ * Cross-extension seam. `opl-footer` publishes `__footerRequestRender` while a footer is
33
+ * mounted; `__agentMode`, `__planMode` and `__chatMode` are read by the footer's mode
34
+ * segment and by `opl-input`. Extensions install one directory at a time and load in any
35
+ * combination, so nothing here may assume the other side exists or is healthy: the value can
36
+ * be missing, be something else entirely, or close over a TUI that was already torn down.
37
+ */
38
+ function requestFooterRender(): void {
39
+ const requestRender = (globalThis as Record<string, unknown>).__footerRequestRender;
40
+ if (typeof requestRender !== "function") return;
41
+ try {
42
+ (requestRender as () => void)();
43
+ } catch {
44
+ // A dead footer must not fail a mode transition.
45
+ }
46
+ }
47
+
26
48
  /** Derive and sync all globalThis snapshots from unified state. */
27
49
  function syncGlobalThis(): void {
28
50
  const m = state.mode;
@@ -35,8 +57,23 @@ function syncGlobalThis(): void {
35
57
  // plan-family modes light up __planMode, so a custom mode cannot read as "Plan mode ON".
36
58
  (globalThis as Record<string, unknown>).__planMode = { mode: m === "plan" || m === "execute" ? m : "off" };
37
59
  (globalThis as Record<string, unknown>).__chatMode = { mode: m === "chat" ? "chat" : "off" };
38
- const requestRender = (globalThis as Record<string, unknown>).__footerRequestRender;
39
- if (typeof requestRender === "function") requestRender();
60
+ requestFooterRender();
61
+ }
62
+
63
+ /** Re-publish the seam after a fresh session starts in this process. */
64
+ export function publishModeGlobals(): void {
65
+ syncGlobalThis();
66
+ }
67
+
68
+ /**
69
+ * Drop the seam when the session ends. A leftover value would let a footer in a later
70
+ * session render a mode that belongs to nobody, and the footer's own render trigger is
71
+ * removed with it (see `opl-footer`).
72
+ */
73
+ export function clearModeGlobals(): void {
74
+ delete (globalThis as Record<string, unknown>).__agentMode;
75
+ delete (globalThis as Record<string, unknown>).__planMode;
76
+ delete (globalThis as Record<string, unknown>).__chatMode;
40
77
  }
41
78
 
42
79
  export function getMode(): AgentMode { return state.mode; }
@@ -126,6 +163,7 @@ export function restore(
126
163
  ): boolean {
127
164
  for (let i = entries.length - 1; i >= 0; i--) {
128
165
  const entry = entries[i];
166
+ if (!entry) continue; // a hole in the persisted array is not a restore candidate
129
167
  if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
130
168
 
131
169
  const data = entry.data as AgentModeBlob | undefined;
@@ -145,6 +183,7 @@ export function restore(
145
183
  // Backward compat: restore from legacy entries written by the old extensions.
146
184
  for (let i = entries.length - 1; i >= 0; i--) {
147
185
  const entry = entries[i];
186
+ if (!entry) continue; // a hole in the persisted array is not a restore candidate
148
187
  if (entry.type !== "custom") continue;
149
188
 
150
189
  if (entry.customType === "plan-mode") {
@@ -181,3 +220,12 @@ export function resetState(): void {
181
220
  state.refineCount = 0;
182
221
  syncGlobalThis();
183
222
  }
223
+
224
+ /** The directory the current session lives in (`.pi/plans/` is resolved against this). */
225
+ export function getSessionCwd(): string {
226
+ return state.sessionCwd;
227
+ }
228
+
229
+ export function setSessionCwd(cwd: string): void {
230
+ if (cwd) state.sessionCwd = cwd;
231
+ }
@@ -154,6 +154,12 @@ export interface ModeSwitcherUserConfig {
154
154
  shortcuts?: ShortcutsUserConfig;
155
155
  /** @deprecated Use `modes` registry instead. Kept for backward compat. */
156
156
  labels?: LabelsUserConfig;
157
+ plan?: {
158
+ /** Ceiling for a plan body injected into a system prompt, in bytes (default 24000). Larger plans truncate with a marker pointing at the file. */
159
+ maxInjectBytes?: number;
160
+ /** Ceiling for the plan copy kept in the `plan-mode` session entry, in bytes (default 4096). */
161
+ maxEntryBytes?: number;
162
+ };
157
163
  bashPatterns?: BashPatternsUserConfig;
158
164
  /** Replace-only: provide a list of tool names to replace the default CHAT mode tool set. Omit to keep defaults. */
159
165
  chatAllowedTools?: string[];
@@ -52,6 +52,7 @@ function commandSegments(command: string): string[] {
52
52
  for (let i = 0; i < command.length; i++) {
53
53
  const ch = command[i];
54
54
  const top = stack[stack.length - 1];
55
+ if (!top) break; // stack drained: the rest of the command is literal text
55
56
  // Single quotes are literal: nothing expands, only the closing ' matters.
56
57
  if (top.quote === "'") {
57
58
  if (ch === "'") top.quote = "";
@@ -165,9 +166,9 @@ export function extractPlanText(message: string): string | null {
165
166
 
166
167
  // ─── Plan File I/O ────────────────────────────────────────────────────────────
167
168
 
168
- /** Ensure .pi/plans/ exists, return its absolute path. */
169
- export function ensurePlanDir(): string {
170
- const dir = join(process.cwd(), PLAN_DIR);
169
+ /** Ensure <sessionCwd>/.pi/plans/ exists, return its absolute path. */
170
+ export function ensurePlanDir(cwd: string): string {
171
+ const dir = join(cwd, PLAN_DIR);
171
172
  mkdirSync(dir, { recursive: true });
172
173
  return dir;
173
174
  }
@@ -181,7 +182,12 @@ export function titleFromFilename(filename: string): string {
181
182
  // Detect timestamp pattern: YYYY-MM-DD-HH-MM
182
183
  const tsMatch = stem.match(/^(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})$/);
183
184
  if (tsMatch) {
184
- const [, y, m, d, h, min] = tsMatch;
185
+ // Five digit groups by the shape test above; the explicit checks are what let the compiler see
186
+ // that, rather than a cast.
187
+ const [y, m, d, h, min] = tsMatch.slice(1);
188
+ if (y === undefined || m === undefined || d === undefined || h === undefined || min === undefined) {
189
+ return stem.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
190
+ }
185
191
  const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
186
192
  const month = months[parseInt(m, 10) - 1] ?? m;
187
193
  return `${month} ${parseInt(d, 10)}, ${y} ${h}:${min}`;
@@ -203,8 +209,8 @@ export function sanitizePlanName(name: string): string | null {
203
209
  }
204
210
 
205
211
  /** List available plan files in .pi/plans/ with titles from # Plan: heading. */
206
- export function listPlanFiles(): PlanFileSummary[] {
207
- const dir = join(process.cwd(), PLAN_DIR);
212
+ export function listPlanFiles(cwd: string): PlanFileSummary[] {
213
+ const dir = join(cwd, PLAN_DIR);
208
214
  if (!existsSync(dir)) return [];
209
215
 
210
216
  const files = readdirSync(dir)
@@ -214,7 +220,7 @@ export function listPlanFiles(): PlanFileSummary[] {
214
220
  return files.map((filename) => {
215
221
  const content = readFileSync(join(dir, filename), "utf-8");
216
222
  const titleMatch = content.match(/^# Plan:\s*(.+)$/m);
217
- const title = titleMatch ? titleMatch[1].trim() : titleFromFilename(filename);
223
+ const title = titleMatch?.[1] ? titleMatch[1].trim() : titleFromFilename(filename);
218
224
  return { name: filename, title };
219
225
  });
220
226
  }
@@ -265,4 +271,8 @@ export function applyLabelColor(theme: Theme, color: string, text: string): stri
265
271
  } catch {
266
272
  return text;
267
273
  }
268
- }
274
+ }
275
+ /** Absolute path of a plan file inside the session directory. */
276
+ export function planFilePath(cwd: string, filename: string): string {
277
+ return join(cwd, PLAN_DIR, filename);
278
+ }
@@ -22,6 +22,7 @@ import {
22
22
  import {
23
23
  type Answer,
24
24
  type Question,
25
+ type QuestionCheck,
25
26
  QuestionnaireParams,
26
27
  type QuestionnaireResult,
27
28
  type RenderOption,
@@ -42,7 +43,7 @@ export function errorResult(
42
43
  /** Validate the question list. Returns the first error message, or null when valid.
43
44
  * Blank ids are rejected (answers are keyed by id and "" would produce an empty key),
44
45
  * as are duplicate ids and questions with nothing selectable. */
45
- export function validateQuestions(questions: Question[]): string | null {
46
+ export function validateQuestions(questions: readonly QuestionCheck[]): string | null {
46
47
  const seenIds = new Set<string>();
47
48
  for (const q of questions) {
48
49
  if (q.id.trim() === "") {
@@ -303,8 +304,10 @@ export default function questionnaire(pi: ExtensionAPI) {
303
304
  const tabs: string[] = ["← "];
304
305
  for (let i = 0; i < questions.length; i++) {
305
306
  const isActive = i === currentTab;
306
- const isAnswered = answers.has(questions[i].id);
307
- const lbl = questions[i].label;
307
+ const question = questions[i];
308
+ if (!question) continue;
309
+ const isAnswered = answers.has(question.id);
310
+ const lbl = question.label;
308
311
  const box = isAnswered ? "■" : "□";
309
312
  const color = isAnswered ? "success" : "muted";
310
313
  const text = ` ${box} ${lbl} `;
@@ -326,6 +329,7 @@ export default function questionnaire(pi: ExtensionAPI) {
326
329
  function renderOptions() {
327
330
  for (let i = 0; i < opts.length; i++) {
328
331
  const opt = opts[i];
332
+ if (!opt) continue; // a hole in the option list renders nothing rather than throwing mid-frame
329
333
  const selected = i === optionIndex;
330
334
  const isOther = opt.isOther === true;
331
335
  const prefix = selected ? theme.fg("accent", "> ") : " ";
@@ -435,7 +439,9 @@ export default function questionnaire(pi: ExtensionAPI) {
435
439
  },
436
440
 
437
441
  renderResult(result, _options, theme, _context) {
438
- if (result.isError) {
442
+ // `isError` on tool results is newer than the declared floor: pi 0.87.x does not carry
443
+ // it in its types, and an older host leaves it undefined, which reads as "not an error".
444
+ if ((result as { isError?: boolean }).isError) {
439
445
  const text = result.content[0];
440
446
  return new Text(theme.fg("error", text?.type === "text" ? text.text : ""), 0, 0);
441
447
  }
@@ -18,6 +18,10 @@ export interface Question {
18
18
  allowOther: boolean;
19
19
  }
20
20
 
21
+ /** The fields `validateQuestions` reads: the same shape on raw tool params (where `label`
22
+ * still has to be defaulted) and on normalized questions. */
23
+ export type QuestionCheck = Pick<Question, "id" | "options"> & Partial<Pick<Question, "allowOther">>;
24
+
21
25
  export interface Answer {
22
26
  id: string;
23
27
  value: string;