@openlines/opl-pi-sht 0.2.11 → 0.3.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.
Files changed (89) hide show
  1. package/README.md +92 -82
  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.json +4 -4
  12. package/extensions/opl-browser/validate.ts +313 -60
  13. package/extensions/opl-configurator/README.md +40 -0
  14. package/extensions/opl-configurator/configurator.ts +148 -0
  15. package/extensions/opl-configurator/index.ts +12 -0
  16. package/extensions/opl-ctxtrim/README.md +45 -14
  17. package/extensions/opl-ctxtrim/index.ts +33 -13
  18. package/extensions/opl-footer/README.md +32 -7
  19. package/extensions/opl-footer/config.ts +11 -10
  20. package/extensions/opl-footer/configure.ts +157 -115
  21. package/extensions/opl-footer/git-status.ts +58 -1
  22. package/extensions/opl-footer/icons.ts +3 -0
  23. package/extensions/opl-footer/index.ts +287 -109
  24. package/extensions/opl-footer/segments/caveman.ts +9 -1
  25. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  26. package/extensions/opl-footer/segments/compactions.ts +13 -0
  27. package/extensions/opl-footer/segments/context.ts +6 -5
  28. package/extensions/opl-footer/segments/index.ts +2 -0
  29. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  30. package/extensions/opl-footer/segments/path.ts +1 -1
  31. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  32. package/extensions/opl-footer/theme.ts +5 -2
  33. package/extensions/opl-footer/types.ts +4 -0
  34. package/extensions/opl-guardian/README.md +17 -5
  35. package/extensions/opl-guardian/config.ts +72 -6
  36. package/extensions/opl-guardian/guardian.ts +42 -10
  37. package/extensions/opl-guardian/index.ts +52 -17
  38. package/extensions/opl-guardian/policies.ts +52 -16
  39. package/extensions/opl-init/README.md +12 -4
  40. package/extensions/opl-init/index.ts +228 -27
  41. package/extensions/opl-input/README.md +17 -3
  42. package/extensions/opl-input/config.ts +59 -5
  43. package/extensions/opl-input/configure.ts +144 -0
  44. package/extensions/opl-input/index.ts +38 -30
  45. package/extensions/opl-input/mode-style.ts +6 -2
  46. package/extensions/opl-modes/README.md +16 -4
  47. package/extensions/opl-modes/config.ts +62 -8
  48. package/extensions/opl-modes/index.ts +48 -17
  49. package/extensions/opl-modes/state.ts +50 -2
  50. package/extensions/opl-modes/types.ts +6 -0
  51. package/extensions/opl-modes/utils.ts +18 -8
  52. package/extensions/opl-questionnaire/index.ts +10 -4
  53. package/extensions/opl-questionnaire/types.ts +4 -0
  54. package/extensions/opl-simplebench/README.md +21 -15
  55. package/extensions/opl-simplebench/artifact.ts +7 -4
  56. package/extensions/opl-simplebench/benchmark.ts +96 -35
  57. package/extensions/opl-simplebench/coding.ts +9 -4
  58. package/extensions/opl-simplebench/index.ts +19 -19
  59. package/extensions/opl-simplebench/llama-server.ts +3 -2
  60. package/extensions/opl-simplebench/report.ts +0 -6
  61. package/extensions/opl-simplebench/research.ts +5 -4
  62. package/extensions/opl-simplebench/scoring.ts +4 -2
  63. package/extensions/opl-simplebench/util/config.ts +15 -8
  64. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  65. package/extensions/opl-simplebench/util/format.ts +2 -2
  66. package/extensions/opl-simplebench/util/providers.ts +23 -194
  67. package/extensions/opl-todo/README.md +5 -5
  68. package/extensions/opl-todo/config.ts +7 -2
  69. package/extensions/opl-todo/index.ts +50 -9
  70. package/extensions/opl-webaccess/README.md +31 -5
  71. package/extensions/opl-webaccess/config.ts +36 -7
  72. package/extensions/opl-webaccess/extract.ts +37 -47
  73. package/extensions/opl-webaccess/http.ts +259 -0
  74. package/extensions/opl-webaccess/index.ts +12 -6
  75. package/extensions/opl-webaccess/package.json +4 -9
  76. package/extensions/opl-webaccess/pdf.ts +4 -4
  77. package/extensions/opl-webaccess/search.ts +19 -8
  78. package/extensions/opl-webaccess/storage.ts +134 -2
  79. package/extensions/opl-webaccess/types.ts +4 -0
  80. package/extensions/opl-webaccess/utils.ts +221 -33
  81. package/images/ss-init.png +0 -0
  82. package/images/ss-input-footer.png +0 -0
  83. package/images/ss-input-pet.png +0 -0
  84. package/images/ss-mode-custom.png +0 -0
  85. package/images/ss-questionnaire0.png +0 -0
  86. package/images/ss-questionnaire1.png +0 -0
  87. package/images/ss-simplebench.png +0 -0
  88. package/images/ss-todo.png +0 -0
  89. package/package.json +20 -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
 
@@ -260,7 +266,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
260
266
  function getPlanFilePath(): string | null {
261
267
  const file = getActivePlanFile();
262
268
  if (!file) return null;
263
- return join(process.cwd(), PLAN_DIR, file);
269
+ return planFilePath(getSessionCwd(), file);
264
270
  }
265
271
 
266
272
  function getPlanDisplayTitle(): string | null {
@@ -269,7 +275,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
269
275
  const filePath = getPlanFilePath();
270
276
  if (filePath && existsSync(filePath)) {
271
277
  const heading = readFileSync(filePath, "utf-8").match(/^# Plan:\s*(.+)$/m);
272
- if (heading) return heading[1].trim();
278
+ const captured = heading?.[1];
279
+ if (captured) return captured.trim();
273
280
  }
274
281
  return titleFromFilename(file);
275
282
  }
@@ -444,7 +451,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
444
451
  /** Open the mode picker. Handles all mode transitions including cross-mode switches. */
445
452
  async function openModePicker(ctx: ExtensionContext): Promise<void> {
446
453
  const current = getMode();
447
- const planFiles = listPlanFiles();
454
+ const planFiles = listPlanFiles(getSessionCwd());
448
455
 
449
456
  // Build mode list dynamically from registry (skip invisible/disabled modes)
450
457
  const registryModes = Array.from(MODE_REGISTRY.entries())
@@ -551,6 +558,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
551
558
  // ─── Event: session_start ──────────────────────────────────────────────────────
552
559
 
553
560
  pi.on("session_start", async (event, ctx) => {
561
+ // Re-publish the footer seam: a previous session in this process cleared it on shutdown.
562
+ publishModeGlobals();
554
563
  if (event.reason === "startup") {
555
564
  if (pi.getFlag("chat") === true && getMode() === "off") {
556
565
  enterChatMode(ctx);
@@ -565,6 +574,12 @@ export default function modeSwitcher(pi: ExtensionAPI) {
565
574
  await syncStateFromBranch(ctx, event.reason === "resume" || event.reason === "fork");
566
575
  });
567
576
 
577
+ pi.on("session_shutdown", async () => {
578
+ // Hand the seam back: an orphaned mode global would keep rendering a mode that no
579
+ // longer belongs to any live session.
580
+ clearModeGlobals();
581
+ });
582
+
568
583
  // ─── Event: before_agent_start ──────────────────────────────────────────
569
584
 
570
585
  pi.on("before_agent_start", (event: BeforeAgentStartEvent): BeforeAgentStartEventResult => {
@@ -578,7 +593,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
578
593
  const filePath = getPlanFilePath();
579
594
  if (filePath && existsSync(filePath)) {
580
595
  const planContent = readFileSync(filePath, "utf-8");
581
- return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent) };
596
+ return { systemPrompt: event.systemPrompt + "\n\n" + buildRefinePrompt(planContent, filePath) };
582
597
  }
583
598
  }
584
599
 
@@ -590,12 +605,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
590
605
  const customTemplate = modeDef.prompt;
591
606
  if (typeof customTemplate === "string" && customTemplate.trim().length > 0) {
592
607
  // User-configured template via modes.execute.prompt — {plan} is replaced with the plan file content.
608
+ // Capped here too: a custom template must not become a way around the injection budget.
609
+ const capped = capPlan(planContent, USER_CONFIG.plan.maxInjectBytes, filePath).text;
593
610
  const filled = customTemplate.includes("{plan}")
594
- ? customTemplate.replace("{plan}", planContent)
595
- : `${customTemplate}\n\nPlan:\n${planContent}`;
611
+ ? customTemplate.replace("{plan}", capped)
612
+ : `${customTemplate}\n\nPlan:\n${capped}`;
596
613
  return { systemPrompt: event.systemPrompt + "\n\n" + filled };
597
614
  }
598
- return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent) };
615
+ return { systemPrompt: event.systemPrompt + "\n\n" + buildExecutePrompt(planContent, filePath) };
599
616
  }
600
617
 
601
618
  // Generic mode prompt from registry
@@ -628,7 +645,13 @@ export default function modeSwitcher(pi: ExtensionAPI) {
628
645
  if (event.toolName !== "bash") return {};
629
646
  if (!modeDef?.safePatterns && !modeDef?.destructivePatterns) return {};
630
647
 
631
- const command = event.input.command as string;
648
+ if (typeof event.input.command !== "string") {
649
+ // Uninspectable commands are refused rather than coerced: String(["rm","-rf"]) would
650
+ // reach the pattern matchers as "rm,-rf" and slip past a segment-based safe list.
651
+ return { block: true, reason: `[mode-switcher] Blocked a bash tool call whose command was not a string [${mode} mode].` };
652
+ }
653
+
654
+ const command = event.input.command;
632
655
  const reason = bashBlockReason(command, modeDef.safePatterns, modeDef.destructivePatterns);
633
656
  if (reason) {
634
657
  return { block: true, reason: `[mode-switcher] Command blocked — ${reason} [${mode} mode]` };
@@ -699,7 +722,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
699
722
  setRefining(false);
700
723
 
701
724
  if (planText) {
702
- const planDir = ensurePlanDir();
725
+ const planDir = ensurePlanDir(getSessionCwd());
703
726
  const activeFile = getActivePlanFile();
704
727
  let filename = activeFile;
705
728
  if (!filename) {
@@ -850,17 +873,20 @@ export default function modeSwitcher(pi: ExtensionAPI) {
850
873
  ctx.ui.notify(USER_CONFIG.labels.plan.notifyLoaded.replace("{title}", displayName), "info");
851
874
  }
852
875
 
853
- const filePath = join(process.cwd(), PLAN_DIR, filename);
876
+ const filePath = planFilePath(getSessionCwd(), filename);
854
877
  if (existsSync(filePath)) {
855
878
  const planContent = readFileSync(filePath, "utf-8");
856
- pi.appendEntry("plan-mode", { title: displayName, plan: planContent });
879
+ // The entry copy is display/session only (plain custom entries never reach the
880
+ // model), but it is written to the session file forever, so it gets its own budget.
881
+ const entry = capPlan(planContent, USER_CONFIG.plan.maxEntryBytes, filePath);
882
+ pi.appendEntry("plan-mode", { title: displayName, plan: entry.text, truncated: entry.truncated, file: filePath });
857
883
  }
858
884
  }
859
885
 
860
886
  // ─── Entry renderer for TUI-only plan cards (never sent to the model) ──────
861
887
 
862
888
  pi.registerEntryRenderer("plan-mode", (entry, _options, theme) => {
863
- const data = (entry.data ?? {}) as { title?: string; plan?: string };
889
+ const data = (entry.data ?? {}) as { title?: string; plan?: string; truncated?: boolean; file?: string };
864
890
  const border = new DynamicBorder((s: string) => theme.fg("border", s));
865
891
  const container = new Container();
866
892
  container.addChild(border);
@@ -870,6 +896,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
870
896
  container.addChild(new Spacer());
871
897
  container.addChild(new Text(body));
872
898
  }
899
+ if (data.truncated) {
900
+ container.addChild(new Text(theme.fg("muted", `✂ truncated copy — full plan: ${data.file ?? PLAN_DIR}`)));
901
+ }
873
902
  container.addChild(border);
874
903
  return container;
875
904
  });
@@ -982,7 +1011,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
982
1011
  if (!input) {
983
1012
  const current = getMode();
984
1013
  if (current === "off") {
985
- const files = listPlanFiles();
1014
+ const files = listPlanFiles(getSessionCwd());
986
1015
  if (files.length === 0) {
987
1016
  await promptNameAndEnterPlanMode(ctx);
988
1017
  } else {
@@ -1025,7 +1054,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1025
1054
  return;
1026
1055
  }
1027
1056
  const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
1028
- const filePath = join(process.cwd(), PLAN_DIR, filename);
1057
+ const filePath = planFilePath(getSessionCwd(), filename);
1029
1058
 
1030
1059
  if (existsSync(filePath)) {
1031
1060
  if (getMode() !== "off") enterOffMode(ctx, undefined, true);
@@ -1078,13 +1107,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1078
1107
  pi.sendUserMessage("Execute the plan steps now.", { deliverAs: "followUp" });
1079
1108
  return;
1080
1109
  }
1081
- const files = listPlanFiles();
1110
+ const files = listPlanFiles(getSessionCwd());
1082
1111
  if (files.length === 0) {
1083
1112
  if (ctx.hasUI) ctx.ui.notify("No plan files found. Run /plan first.", "warning");
1084
1113
  return;
1085
1114
  }
1086
1115
  if (files.length === 1) {
1087
- await startExecute(files[0].name);
1116
+ const only = files[0];
1117
+ if (only) await startExecute(only.name);
1088
1118
  return;
1089
1119
  }
1090
1120
  const items: SelectItem[] = files.map(f => ({ value: f.name, label: f.title }));
@@ -1100,7 +1130,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1100
1130
  return;
1101
1131
  }
1102
1132
  const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
1103
- const filePath = join(process.cwd(), PLAN_DIR, filename);
1133
+ const filePath = planFilePath(getSessionCwd(), filename);
1104
1134
  if (!existsSync(filePath)) {
1105
1135
  if (ctx.hasUI) ctx.ui.notify(`Plan "${input}" not found. Check /plan for available plans.`, "warning");
1106
1136
  return;
@@ -1124,6 +1154,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
1124
1154
 
1125
1155
  const idx = order.indexOf(current);
1126
1156
  const next = idx === -1 ? order[0] : order[(idx + 1) % order.length];
1157
+ if (!next) return; // order is non-empty above; this is what lets the branches below take a string
1127
1158
  if (next === current) return;
1128
1159
 
1129
1160
  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;
@@ -32,7 +32,7 @@ simplebench({ model: "global.openai.gpt-5.6-terra", coding_lite: true })
32
32
  simplebench({ model: "global.openai.gpt-5.6-terra", test_all: true })
33
33
  ```
34
34
 
35
- `--no-artifact` and `no_artifact: true` suppress the JSON file. Default runs use the provider's sampling and reasoning defaults and write one JSON report per model to Pi's current working directory. `--thinking-max` and `thinking_max: true` request `reasoning_effort: "max"` for OpenAI-compatible providers, or Pi's model-aware Bedrock max-thinking path for direct Bedrock models that advertise `reasoning: true` and `thinkingLevelMap.max`. Other providers reject that mode rather than silently using defaults.
35
+ `--no-artifact` and `no_artifact: true` suppress the JSON file. Default runs use the provider's sampling and reasoning defaults and write one JSON report per model to the current Pi session directory (`ExtensionContext.cwd`). Without an active session context, artifacts fall back to the harness working directory. `--thinking-max` and `thinking_max: true` request `reasoning_effort: "max"` for OpenAI-compatible providers, or Pi's model-aware Bedrock max-thinking path for direct Bedrock models that advertise `reasoning: true` and `thinkingLevelMap.max`. Other providers reject that mode rather than silently using defaults.
36
36
 
37
37
  ## Extension features
38
38
 
@@ -172,7 +172,7 @@ The stats endpoint is metadata-only and does not change inference routing. Its s
172
172
 
173
173
  ## Artifacts and privacy
174
174
 
175
- Baseline and coding-lite artifacts are written to `process.cwd()` as JSON. `--test-all` writes a bundle:
175
+ Baseline and coding-lite artifacts use the current Pi session directory (`ExtensionContext.cwd`), passed down explicitly; without a Pi session, they fall back to the harness working directory. `--test-all` writes a bundle:
176
176
 
177
177
  ```text
178
178
  simplebench--test-all-<sanitized-model>-<thinking>-<UTC-timestamp>/
@@ -211,6 +211,8 @@ The model receives weather and calculation tools. Simplebench validates expected
211
211
 
212
212
  Coding-lite is the execution-backed coding-agent suite. It runs model-directed edits in temporary directories. The model can list, search, read, write, and run the fixture's public tests, but cannot execute arbitrary commands, access the real repository, read hidden verification code, or use the network. The host runs hidden verification after the model stops.
213
213
 
214
+ **The verifier executes model-authored code.** Verification runs `node --eval` over the model's files, so that process is started with an allowlisted environment (`util/exec-env.ts`): `PATH`, `HOME`, temp-directory and locale variables, TLS trust paths, terminal capability flags, and the Windows equivalents. Everything else is absent — every `*_API_KEY`, `AWS_*`, `GH_*`, `PI_*`, and any other secret your shell happens to export — and `NODE_OPTIONS`/`NODE_PATH` are never forwarded because both can make Node load attacker-chosen files. Anything the model's code spawns inherits that environment, so a nested `node src/cli.mjs` sees the same allowlist. This limits what generated code can *read out of the process*; it is not sandboxing. The child still runs with your user's filesystem permissions and network reach, and a `HOME` it can see points at files it could read by path anyway. Use `--no-artifact` and a dedicated shell if that is not acceptable for your environment.
215
+
214
216
  The agent loop defaults to **5 turns** per task (reduced from 12). This tighter budget forces the model to converge on a solution quickly and makes turn count a meaningful quality signal. Results are scored on three dimensions:
215
217
 
216
218
  - **STRONG** — solved in 1–2 turns (first or second attempt correct)
@@ -230,17 +232,17 @@ Use `--coding-lite` for coding tasks only. Use `--test-all` to run the existing
230
232
 
231
233
  ### Recommendation policy
232
234
 
233
- The recommendation is based on the three (or four) displayed categories:
235
+ Four categories can be in play: the closed-answer contract, instruction following, tool usage, and (with `--coding-lite`) coding. A category counts as passed when its own rule succeeds - the closed-answer contract counts as passed at `STRONG` **or** `MODERATE`, and coding counts as passed on its efficiency-weighted average. The label then depends on how many passed, and on how many categories actually ran:
234
236
 
235
- | Result | Recommendation |
236
- |---|---|
237
- | Strong reasoning, JSON pass, tool pass, all coding STRONG | STRONG |
238
- | Strong reasoning, JSON pass, tool pass (coding not all STRONG) | GOOD |
239
- | Any three categories pass | USABLE |
240
- | Two categories pass | LIMITED |
241
- | One or zero categories pass | WEAK |
237
+ | Categories passed | Baseline (3 categories) | With coding (4 categories) |
238
+ |---|---|---|
239
+ | All | STRONG if the contract is `STRONG` and, when coding ran, every task is `STRONG`; GOOD otherwise | |
240
+ | All but one | GOOD | GOOD |
241
+ | Two | USABLE | LIMITED |
242
+ | One | LIMITED | LIMITED |
243
+ | None | WEAK | WEAK |
242
244
 
243
- When coding-lite is included, the coding category uses efficiency-weighted scoring (STRONG=1.0, MODERATE=0.7, WEAK=0.4, FAIL=0). The category counts as passing when the weighted average ≥ 0.5 across all tasks.
245
+ `WEAK` therefore requires that **nothing** passed; one passing category is `LIMITED`. A run that omits coding can report `USABLE` where the same results with coding report `LIMITED`, because the denominator changes. For runs with no clean category counts (an error or a cancelled run), `report.ts` falls back to the average `tests[].score`: `>= 0.8` `GOOD`, `>= 0.5` `USABLE`, `>= 0.2` `LIMITED`, otherwise `WEAK`. The coding category is scored efficiency-weighted (STRONG=1.0, MODERATE=0.7, WEAK=0.4, FAIL=0) and passes when that weighted average is >= 0.5 across all tasks.
244
246
 
245
247
  ## Provider authentication
246
248
 
@@ -260,6 +262,8 @@ Simplebench derives the region from the selected model's Bedrock base URL, signs
260
262
  aws configure export-credentials --profile "$AWS_PROFILE"
261
263
  ```
262
264
 
265
+ The CLI is invoked with an argument vector, never through a shell, so the profile value is never interpolated into a command string; and it runs asynchronously, so a slow SSO or assume-role chain cannot stall the TUI. A profile name that is not a plain token (`^[A-Za-z0-9][A-Za-z0-9_.:@+-]{0,127}$`) is refused before any process is spawned — `AWS_PROFILE` set by repo tooling such as `.envrc` or a Makefile is not trusted to reach the CLI. Set `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` (plus `AWS_SESSION_TOKEN`) to bypass the CLI entirely.
266
+
263
267
  AWS CLI v2 is therefore required when the selected profile uses SSO or an assume-role chain.
264
268
 
265
269
  ## Troubleshooting
@@ -270,7 +274,7 @@ AWS CLI v2 is therefore required when the selected profile uses SSO or an assume
270
274
  | OpenRouter returns 401 | Set `OPENROUTER_API_KEY`, configure the provider key, or authenticate Pi so `auth.json` has an API key. |
271
275
  | Bedrock says credentials are unavailable | Run `aws configure export-credentials --profile "$AWS_PROFILE"` and resolve profile login/role issues first. |
272
276
  | Bedrock rejects `--thinking-max` | The selected model does not advertise max thinking in Pi metadata, or the provider rejected its model-specific reasoning request. |
273
- | Artifact is absent | Check the report for `--no-artifact` or an artifact-write warning; the file is written to Pi's cwd. |
277
+ | Artifact is absent | Check the report for `--no-artifact` or an artifact-write warning; the file lands in the session directory, not where the shell was standing. |
274
278
  | Token or TTFT values are null | The provider response did not expose authoritative usage/timing values. |
275
279
 
276
280
  ## Architecture
@@ -279,13 +283,15 @@ AWS CLI v2 is therefore required when the selected profile uses SSO or an assume
279
283
  index.ts Pi registration, command parsing, tool schema
280
284
  benchmark.ts provider adapters and benchmark orchestration
281
285
  llama-server.ts direct llama-server /props and /metrics capture
286
+ coding.ts the six execution-backed coding tasks and their agent loop
287
+ research.ts grounded research fixtures and the deterministic verifier
282
288
  tests.ts benchmark fixtures
283
289
  scoring.ts extraction and scoring
284
290
  metrics.ts request/usage aggregation
285
- artifact.ts cwd JSON artifact writer
291
+ artifact.ts session-directory JSON artifact writer
286
292
  report.ts terminal output and recommendation policy
287
293
  types.ts shared benchmark types
288
- util/ local configuration, formatting, debugging, and provider helpers
294
+ util/ local configuration, formatting, debugging, exec-environment scrubbing, and provider helpers
289
295
  ```
290
296
 
291
- The extension is self-contained under `extensions/opl-simplebench/`, ready to copy into the `opl-pi-sht` extension stack after local verification.
297
+ Everything lives under `extensions/opl-simplebench/` because `install.sh` installs one extension directory at a time; the only environment boundary model-authored code gets is `util/exec-env.ts`, which is where a new child process must go through.
@@ -1,5 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+
4
+ // The output directory is always passed in: artifacts land in the session directory
5
+ // (`ExtensionContext.cwd`), not wherever the harness was started.
3
6
  import type { RunArtifact } from "./types";
4
7
 
5
8
  export function artifactFileName(model: string, suite: "baseline" | "coding-lite" | "test-all" = "baseline", thinking: "default" | "max" = "default", date = new Date(), tag?: string): string {
@@ -9,10 +12,10 @@ export function artifactFileName(model: string, suite: "baseline" | "coding-lite
9
12
  return `simplebench${tag ? `-${tag}-` : "--"}${suiteName}-${safeModel}-${thinking}-${stamp}.json`;
10
13
  }
11
14
 
12
- export function writeArtifactBundle(artifact: RunArtifact, files: Record<string, string>): string {
15
+ export function writeArtifactBundle(artifact: RunArtifact, files: Record<string, string>, cwd: string): string {
13
16
  const name = artifactFileName(artifact.benchmark.model, artifact.benchmark.suite || "baseline", artifact.benchmark.thinking.requested, new Date(), artifact.benchmark.tag).replace(/\.json$/, "");
14
17
  for (let suffix = 0; ; suffix += 1) {
15
- const output = path.resolve(process.cwd(), suffix ? `${name}-${suffix}` : name);
18
+ const output = path.resolve(cwd, suffix ? `${name}-${suffix}` : name);
16
19
  try {
17
20
  fs.mkdirSync(output);
18
21
  fs.writeFileSync(path.join(output, "result.json"), JSON.stringify(artifact, null, 2) + "\n", "utf8");
@@ -24,13 +27,13 @@ export function writeArtifactBundle(artifact: RunArtifact, files: Record<string,
24
27
  }
25
28
  }
26
29
 
27
- export function writeArtifact(artifact: RunArtifact): string {
30
+ export function writeArtifact(artifact: RunArtifact, cwd: string): string {
28
31
  const name = artifactFileName(artifact.benchmark.model, artifact.benchmark.suite || "baseline", artifact.benchmark.thinking.requested, new Date(), artifact.benchmark.tag);
29
32
  const extension = path.extname(name);
30
33
  const base = name.slice(0, -extension.length);
31
34
  const contents = JSON.stringify(artifact, null, 2) + "\n";
32
35
  for (let suffix = 0; ; suffix += 1) {
33
- const output = path.resolve(process.cwd(), suffix ? `${base}-${suffix}${extension}` : name);
36
+ const output = path.resolve(cwd, suffix ? `${base}-${suffix}${extension}` : name);
34
37
  try {
35
38
  fs.writeFileSync(output, contents, { encoding: "utf8", flag: "wx" });
36
39
  return output;