@openlines/opl-pi-sht 0.1.21 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,16 @@
1
- /** Pure utilities: isSafeCommand, extractPlanText, plan file I/O, color helpers */
1
+ /** Pure utilities: read-only Bash gate, plan file I/O, color helpers */
2
2
 
3
3
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
4
4
  import { existsSync, readFileSync, readdirSync, mkdirSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
 
7
- import { SAFE_COMMAND_PATTERNS, DESTRUCTIVE_PATTERNS, PLAN_DIR, PLAN_FILE_PREFIX } from "./config.js";
7
+ import { PLAN_DIR, PLAN_FILE_PREFIX } from "./config.js";
8
8
  import type { PlanFileSummary } from "./types.js";
9
9
 
10
10
  /** Serialize async model changes so the final requested model wins. */
11
11
  export function createLatestModelQueue() {
12
12
  let latest = 0;
13
- let tail = Promise.resolve();
13
+ let tail: Promise<unknown> = Promise.resolve();
14
14
 
15
15
  return <T>(change: () => Promise<T>): Promise<T | undefined> => {
16
16
  const request = ++latest;
@@ -19,17 +19,124 @@ export function createLatestModelQueue() {
19
19
  };
20
20
  }
21
21
 
22
- /** Check if command matches safe patterns and not destructive patterns. */
23
- export function isSafeCommand(command: string): boolean {
24
- return SAFE_COMMAND_PATTERNS.some((p) => p.test(command))
25
- && !isDestructive(command, DESTRUCTIVE_PATTERNS);
22
+ const stripQuotes = (text: string): string => text.replace(/["'\\]/g, "");
23
+
24
+ /**
25
+ * Blind split on every operator, quotes ignored — used only for the blocklist scan, where
26
+ * over-splitting is fail-safe. A lone `&` counts as a separator unless it belongs to `&&`,
27
+ * `||`, or an `>&`/`<&` descriptor duplication (`echo hi 2>&1` stays one command).
28
+ */
29
+ const BLIND_SPLIT = /\$\(|`|[<>]\(|&&|\|\||(?<![<>&])&(?![>&])|[;|\n\r]/;
30
+
31
+ /** Quote/substitution nesting level. `quote` is the string quote open inside this level. */
32
+ interface Frame { kind: "root" | "sub" | "tick"; quote: "" | "'" | '"'; parens: number }
33
+
34
+ /**
35
+ * Split a command into the pieces the shell actually executes, so each one can be required
36
+ * to clear the allowlist: chaining (`&&`, `||`), backgrounding (`&`), pipes, `;`, newlines,
37
+ * process substitution, and the payload of `$(...)`/backticks. Quote-aware, so `grep "a|b"`
38
+ * stays one command, while a substitution inside double quotes still expands —
39
+ * `echo "$(node -e ...)"` yields the `node` payload as its own segment instead of blanking
40
+ * the whole string away. Quote characters are dropped from the segments (matching is
41
+ * prefix-anchored, so a leading `"` must not defeat a safe pattern).
42
+ */
43
+ function commandSegments(command: string): string[] {
44
+ const out: string[] = [];
45
+ const stack: Frame[] = [{ kind: "root", quote: "", parens: 0 }];
46
+ let buf = "";
47
+ const flush = () => {
48
+ const s = buf.trim();
49
+ if (s) out.push(s);
50
+ buf = "";
51
+ };
52
+ for (let i = 0; i < command.length; i++) {
53
+ const ch = command[i];
54
+ const top = stack[stack.length - 1];
55
+ // Single quotes are literal: nothing expands, only the closing ' matters.
56
+ if (top.quote === "'") {
57
+ if (ch === "'") top.quote = "";
58
+ else buf += ch;
59
+ continue;
60
+ }
61
+ // Double quotes keep their text literal, except for `$(...)` and backticks, which run.
62
+ if (top.quote === '"') {
63
+ if (ch === '"') { top.quote = ""; continue; }
64
+ if (ch === "\\") { i++; continue; } // \X is a literal X, so `\$(x)` does not expand
65
+ if (ch === "$" && command[i + 1] === "(") { stack.push({ kind: "sub", quote: "", parens: 0 }); flush(); i++; continue; }
66
+ if (ch === "`") { stack.push({ kind: "tick", quote: "", parens: 0 }); flush(); continue; }
67
+ buf += ch;
68
+ continue;
69
+ }
70
+ if (ch === "'") { top.quote = "'"; continue; }
71
+ if (ch === '"') { top.quote = '"'; continue; }
72
+ if (ch === "\\") { buf += ch + (command[++i] ?? ""); continue; }
73
+ if (ch === "$" && command[i + 1] === "(") { stack.push({ kind: "sub", quote: "", parens: 0 }); flush(); i++; continue; }
74
+ if (ch === "`") {
75
+ if (top.kind === "tick") stack.pop();
76
+ else stack.push({ kind: "tick", quote: "", parens: 0 });
77
+ flush();
78
+ continue;
79
+ }
80
+ if (top.kind === "sub") {
81
+ if (ch === "(") top.parens++;
82
+ else if (ch === ")") {
83
+ if (top.parens === 0) { stack.pop(); flush(); continue; }
84
+ top.parens--;
85
+ }
86
+ } else if ((ch === "<" || ch === ">") && command[i + 1] === "(") {
87
+ stack.push({ kind: "sub", quote: "", parens: 0 });
88
+ flush();
89
+ i++;
90
+ continue;
91
+ }
92
+ if (ch === ";" || ch === "|" || ch === "\n" || ch === "\r") { flush(); continue; }
93
+ if (ch === "&") {
94
+ if (command[i + 1] === "&") { i++; flush(); continue; } // &&
95
+ // A lone & backgrounds the command before it. `>&`/`<&` is descriptor duplication and
96
+ // `&>` is a redirect, so neither starts a new command here (the blocklist judges those).
97
+ if (!/[<>&|]/.test(command[i - 1] ?? "") && command[i + 1] !== ">") { flush(); continue; }
98
+ }
99
+ buf += ch;
100
+ }
101
+ flush();
102
+ return out;
103
+ }
104
+
105
+ /** Quote-blind segments for the blocklist, on the raw text and its quote-stripped skeleton. */
106
+ function blindSegments(text: string): string[] {
107
+ return text.split(BLIND_SPLIT).map((s) => s.trim()).filter((s) => s.length > 0);
26
108
  }
27
109
 
28
- /** Whether a command, or its quote/backslash-stripped skeleton, matches any destructive
29
- * pattern. Stripping defeats shell expands like `r"m"` -> `rm` that dodge \brm\b. */
110
+ /** Whether a destructive pattern matches the command, any of its segments, or either
111
+ * with quotes/backslashes stripped, so `r"m"` and `-del"ete"` cannot dodge the list. */
30
112
  export function isDestructive(command: string, patterns: RegExp[]): boolean {
31
- const skeleton = command.replace(/["'\\]/g, "");
32
- return patterns.some((p) => p.test(command) || p.test(skeleton));
113
+ const haystacks = [
114
+ command,
115
+ stripQuotes(command),
116
+ ...blindSegments(command).flatMap((s) => [s, stripQuotes(s)]),
117
+ ];
118
+ return patterns.some((p) => haystacks.some((h) => p.test(h)));
119
+ }
120
+
121
+ /**
122
+ * Why a Bash command must be blocked in a read-only mode, or null when it is allowed.
123
+ * Every shell segment has to match a safe pattern (so `cat f && node -e '...'` cannot
124
+ * ride the first command's allowance), and no destructive pattern may match anywhere.
125
+ * An undefined or empty safe list means "no allowlist gate".
126
+ */
127
+ export function bashBlockReason(
128
+ command: string,
129
+ safePatterns?: RegExp[],
130
+ destructivePatterns?: RegExp[],
131
+ ): string | null {
132
+ if (safePatterns && safePatterns.length > 0) {
133
+ const offender = commandSegments(command).find((s) => !safePatterns.some((p) => p.test(s)));
134
+ if (offender !== undefined) return `not in safe pattern list: ${offender}`;
135
+ }
136
+ if (destructivePatterns && destructivePatterns.length > 0 && isDestructive(command, destructivePatterns)) {
137
+ return `destructive pattern in: ${command}`;
138
+ }
139
+ return null;
33
140
  }
34
141
 
35
142
  const PLAN_ACTION_VERB = /^\s*\d+\.\s+(?:\*{1,2})?(?:add|creat|updat|fix|remov|refactor|implement|modif|chang|edit|writ|build|run|install|configur|set\s+up|delet|mov|renam|inject|migrat|replac|extract|test|deploy|integrat|convert)/i;
@@ -90,6 +197,8 @@ export function sanitizePlanName(name: string): string | null {
90
197
  if (!trimmed) return null;
91
198
  if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) return null;
92
199
  if (!/^[\w\s.-]+$/.test(trimmed)) return null;
200
+ // A name with no letter or digit (".", "--", ". ") would create plan-.md and an empty title.
201
+ if (!/\w/.test(trimmed)) return null;
93
202
  return trimmed.replace(/\s+/g, "-");
94
203
  }
95
204
 
@@ -134,7 +243,9 @@ export function isHexColor(color: string): boolean {
134
243
 
135
244
  /** Convert hex color string to ANSI truecolor escape. */
136
245
  function hexToAnsi(hex: string): string {
137
- const h = hex.replace("#", "");
246
+ // Accept the #abc shorthand so a three-digit value renders the same as #aabbcc.
247
+ let h = hex.replace("#", "");
248
+ if (/^[0-9a-fA-F]{3}$/.test(h)) h = h.split("").map((c) => c + c).join("");
138
249
  if (!/^[0-9a-fA-F]{6}$/.test(h)) return "";
139
250
  const r = parseInt(h.slice(0, 2), 16);
140
251
  const g = parseInt(h.slice(2, 4), 16);
@@ -312,7 +312,9 @@ function makePiBedrockThinkingChatFn(modelConfig: BenchmarkModel): ChatFn {
312
312
  ...(systemPrompt ? { systemPrompt } : {}),
313
313
  messages: messages.filter(m => m.role !== "system") as any,
314
314
  ...(tools?.length ? { tools } : {}),
315
- }, { reasoning: "max" });
315
+ // pi-ai's TranscriptContext carries a private brand, so a literal cannot satisfy it
316
+ // structurally; the fields above are the shape streamSimple actually reads.
317
+ } as any, { reasoning: "max" });
316
318
  const message: any = await stream.result();
317
319
  const content = (message.content || []).filter((block: any) => block.type === "text").map((block: any) => block.text).join("");
318
320
  const toolCalls = (message.content || []).filter((block: any) => block.type === "toolCall").map((block: any) => ({
@@ -387,13 +389,22 @@ function makeOllamaToolChatFn(): ChatFn {
387
389
 
388
390
  // ── Ollama Chat Functions ─────────────────────────────────────────────
389
391
 
392
+ /** Both Ollama chat helpers return this shape; the unused fields stay undefined per mode. */
393
+ interface OllamaChatResult {
394
+ response: any;
395
+ elapsedMs: number;
396
+ requestCount?: number;
397
+ retryCount?: number;
398
+ timeToFirstTokenMs?: number | null;
399
+ }
400
+
390
401
  async function ollamaChat(
391
402
  model: string,
392
403
  messages: Array<{ role: string; content: string }>,
393
404
  options: Record<string, unknown> = {},
394
405
  timeoutMs = CONFIG.DEFAULT_TIMEOUT_MS,
395
406
  retries = CONFIG.MAX_RETRIES
396
- ): Promise<{ response: any; elapsedMs: number }> {
407
+ ): Promise<OllamaChatResult> {
397
408
  const { tools, ...generationOptions } = options as any;
398
409
  const body: any = { model, messages, stream: false, ...(tools ? { tools } : {}), ...(Object.keys(generationOptions).length ? { options: generationOptions } : {}) };
399
410
  const url = `${ollamaBase()}/api/chat`;
@@ -425,7 +436,9 @@ async function ollamaChat(
425
436
  throw new Error(`Empty response from Ollama after ${attempt + 1} attempt(s)`);
426
437
  }
427
438
  const parsed = JSON.parse(text);
428
- return { response: parsed, elapsedMs };
439
+ // Surface the retry loop so artifacts record real request/retry counts (metricsFromChat
440
+ // used to receive undefined and silently report 1 request, 0 retries).
441
+ return { response: parsed, elapsedMs, requestCount: attempt + 1, retryCount: attempt };
429
442
  } catch (e: unknown) {
430
443
  const msg = e instanceof Error ? e.message : String(e);
431
444
  if (e instanceof Error && e.name === "AbortError") {
@@ -455,7 +468,7 @@ async function ollamaChatStream(
455
468
  messages: Array<{ role: string; content: string }>,
456
469
  options: Record<string, unknown> = {},
457
470
  timeoutMs = CONFIG.DEFAULT_TIMEOUT_MS,
458
- ): Promise<{ response: any; elapsedMs: number }> {
471
+ ): Promise<OllamaChatResult> {
459
472
  const { tools, ...generationOptions } = options as any;
460
473
  const body: any = { model, messages, stream: true, ...(tools ? { tools } : {}), ...(Object.keys(generationOptions).length ? { options: generationOptions } : {}) };
461
474
  const url = `${ollamaBase()}/api/chat`;
@@ -484,6 +497,7 @@ async function ollamaChatStream(
484
497
  let thinkingContent = "";
485
498
  const toolCalls: any[] = [];
486
499
  let done = false;
500
+ let timeToFirstTokenMs: number | null = null;
487
501
 
488
502
  const reader = res.body.getReader();
489
503
  const decoder = new TextDecoder();
@@ -497,6 +511,9 @@ async function ollamaChatStream(
497
511
  for (const line of lines) {
498
512
  try {
499
513
  const parsed = JSON.parse(line);
514
+ if (timeToFirstTokenMs === null && (parsed.message?.content || parsed.message?.thinking || parsed.message?.tool_calls)) {
515
+ timeToFirstTokenMs = Date.now() - start;
516
+ }
500
517
  if (parsed.message?.content) messageContent += parsed.message.content;
501
518
  if (parsed.message?.thinking) thinkingContent += parsed.message.thinking;
502
519
  if (parsed.message?.tool_calls) toolCalls.push(...parsed.message.tool_calls);
@@ -521,7 +538,7 @@ async function ollamaChatStream(
521
538
  done: true,
522
539
  };
523
540
 
524
- return { response, elapsedMs };
541
+ return { response, elapsedMs, timeToFirstTokenMs };
525
542
  } catch (e: unknown) {
526
543
  if (e instanceof Error && e.name === "AbortError") {
527
544
  throw new Error(`Ollama API timed out after ${msHuman(timeoutMs)}`);
@@ -562,7 +579,7 @@ async function testReasoningExtended(chatFn: ChatFn, model: string, onProgress?:
562
579
  const msg = result.content.trim();
563
580
  const scored = scoreReasoning(msg, test.expectedAnswer);
564
581
  const answer = scored.answer;
565
- results.push({ name: test.name, category: test.category, prompt: test.prompt, response: msg, elapsedMs: result.elapsedMs, metrics: metricsFromChat({ ...result, startedAt: result.startedAt ?? requestedAt, finishedAt: result.finishedAt ?? new Date().toISOString() }), score: scored.score, answer, expectedAnswer: test.expectedAnswer, pass: scored.pass, details: scored.details });
582
+ results.push({ name: test.name, category: test.category, prompt: test.prompt, response: msg, elapsedMs: result.elapsedMs, metrics: metricsFromChat({ ...result, startedAt: result.startedAt ?? requestedAt, finishedAt: result.finishedAt ?? new Date().toISOString() }), score: scored.score, answer, expectedAnswer: test.expectedAnswer, pass: scored.pass });
566
583
  onProgress?.(`[1/3] Reasoning ${i + 1}/${total}: ${test.name} → ${scored.score}`);
567
584
  } catch (e: any) {
568
585
  results.push({ name: test.name, category: test.category, prompt: test.prompt, response: null, error: e?.message || String(e), elapsedMs: 0, metrics: emptyMetrics(), score: "ERROR", answer: "?", expectedAnswer: test.expectedAnswer, pass: false });
@@ -683,7 +700,7 @@ async function testModelExtended(model: string, ctx?: any, options: SimplebenchO
683
700
  const providerInfo = ctx ? detectProvider(ctx) : { kind: "ollama" as const, name: "ollama" };
684
701
  const resolvedModel = resolveBenchmarkModel(ctx, model);
685
702
  const thinking = resolveThinkingMode(providerInfo, resolvedModel.model, options.thinkingMax === true);
686
- const suite = options.testAll ? "test-all" : options.codingLite ? "coding-lite" : "baseline";
703
+ const suite: "baseline" | "coding-lite" | "test-all" = options.testAll ? "test-all" : options.codingLite ? "coding-lite" : "baseline";
687
704
 
688
705
  // Direct llama-server probes are separate from the provider route. This is
689
706
  // intentional: --llamagputop also works when inference goes through LiteLLM.
@@ -65,11 +65,10 @@ export default function (pi: ExtensionAPI) {
65
65
 
66
66
  pi.registerCommand("simplebench", {
67
67
  description: "Benchmark a model with auditable closed-answer, instruction, and tool-use tests.",
68
- detailedHelp: "\n\n🔍 Simplebench Extension\n\nThis extension tests AI models across multiple dimensions:\n• Closed-answer contract: 20 deterministic final-line answers (logic, math, spatial, commonsense)\n• Tool Usage: Ability to use available tools effectively\n• Instruction Following: How well the model follows complex JSON instructions\n• Coding Lite: Six isolated, execution-backed coding tasks\n\n📋 Usage Examples:\n /simplebench # Test current model\n /simplebench <model> # Test a specific model\n /simplebench --all # Test all Ollama models\n /simplebench <model> --3ptest # Run the default 3ptest baseline explicitly\n /simplebench <model> --coding-lite # Run only coding tasks\n /simplebench <model> --test-all # Run baseline, coding, and grounded research\n /simplebench <model> --research-live # Add live search integration smoke test\n /simplebench --all --test-all # Run complete suite for every Ollama model\n /simplebench <model> --sequence[=<name>] # Run a configured runSequence profile\n /simplebench <model> --thinking-max # Request max reasoning\n /simplebench <model> --llama-server # Capture configured /props and /metrics\n /simplebench <model> --llamagputop # Capture configured llama.cpp stats\n /simplebench <model> --tag=coldrun # Label the run: benchmark.tag plus artifact filename prefix\n /simplebench --help # Show this help\n /simplebench --clear-cache # Clear tool support cache\n\nCoding tasks run in disposable directories and never access the user repository.\n",
69
68
  getArgumentCompletions: async (prefix) => {
70
69
  try {
71
70
  const models = await getOllamaModels();
72
- return models.map(m => ({ label: m, description: `Test ${m}` }))
71
+ return models.map(m => ({ value: m, label: m, description: `Test ${m}` }))
73
72
  .filter(m => m.label.startsWith(prefix));
74
73
  } catch (err) { debugLog("simplebench", "failed to get model completions", err); return []; }
75
74
  },
@@ -151,7 +150,7 @@ pi.registerCommand("simplebench", {
151
150
  pi.sendMessage({
152
151
  customType: "simplebench-report",
153
152
  content: report,
154
- display: { type: "content", content: report },
153
+ display: true,
155
154
  details: { model, timestamp: new Date().toISOString() },
156
155
  });
157
156
  } catch (e: any) {
@@ -191,7 +190,7 @@ pi.registerCommand("simplebench", {
191
190
  pi.sendMessage({
192
191
  customType: "simplebench-report",
193
192
  content: report,
194
- display: { type: "content", content: report },
193
+ display: true,
195
194
  details: { model, timestamp: new Date().toISOString() },
196
195
  });
197
196
  } catch (e: any) {
@@ -214,7 +213,7 @@ pi.registerCommand("simplebench", {
214
213
  pi.sendMessage({
215
214
  customType: "simplebench-report",
216
215
  content: report,
217
- display: { type: "content", content: report },
216
+ display: true,
218
217
  details: { model, timestamp: new Date().toISOString() },
219
218
  });
220
219
  } catch (e: any) {
@@ -256,6 +255,7 @@ pi.registerTool({
256
255
  if (params?.tag !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(params.tag))) {
257
256
  return {
258
257
  content: [{ type: "text", text: "--tag must be a single word (letters, digits, dot, dash, underscore)" }],
258
+ details: undefined,
259
259
  isError: true,
260
260
  } as AgentToolResult;
261
261
  }
@@ -264,6 +264,7 @@ pi.registerTool({
264
264
  if (!model) {
265
265
  return {
266
266
  content: [{ type: "text", text: "No model currently selected to test." }],
267
+ details: undefined,
267
268
  isError: true,
268
269
  } as AgentToolResult;
269
270
  }
@@ -271,6 +272,7 @@ pi.registerTool({
271
272
  const report = await testModel(model, ctx, options);
272
273
  return {
273
274
  content: [{ type: "text", text: report }],
275
+ details: undefined,
274
276
  isError: false,
275
277
  } as AgentToolResult;
276
278
  } catch (e: any) {
@@ -281,6 +283,7 @@ pi.registerTool({
281
283
 
282
284
  return {
283
285
  content: [{ type: "text", text: errorMessage }],
286
+ details: undefined,
284
287
  isError: true,
285
288
  } as AgentToolResult;
286
289
  }
@@ -88,7 +88,10 @@ function normalizeClaim(value: string): string {
88
88
  }
89
89
 
90
90
  export function verifyGroundedResearch(markdown: string, fixture: GroundedResearchFixture): { passed: boolean; missingClaims: string[]; wrongCitations: string[]; unknownCitations: string[] } {
91
- const knownSources = new Map(fixture.sources.map(source => [source.id, source]));
91
+ // Key is string: citation ids arrive from a regex capture, so a "S1"|"S2"|"S3" key would reject them.
92
+ const knownSources = new Map<string, GroundedResearchFixture["sources"][number]>(
93
+ fixture.sources.map(source => [source.id, source]),
94
+ );
92
95
  const findings = markdown.match(/##\s+Findings\s*\n([\s\S]*?)(?=\n##\s|$)/i)?.[1] ?? "";
93
96
  const sourceSection = markdown.match(/##\s+Sources\s*\n([\s\S]*?)(?=\n##\s|$)/i)?.[1] ?? "";
94
97
  const citedClaims = [...findings.matchAll(/^\s*[-*]\s+(.+?)\s+\[([A-Za-z0-9]+)\]\s*$/gm)];
@@ -9,7 +9,12 @@ import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import os from "node:os";
11
11
  import { debugLog } from "./debug";
12
- import type { PiExtensionContext } from "../../../shared/types";
12
+
13
+ /** Minimal shape of Pi's extension context that provider detection reads. Keeps this module
14
+ * standalone instead of importing a Pi type the extension host resolves at load time. */
15
+ interface PiExtensionContext {
16
+ model?: { provider?: string; id?: string };
17
+ }
13
18
 
14
19
  // ============================================================================
15
20
  // Constants
@@ -401,8 +401,8 @@ export default function (pi: ExtensionAPI) {
401
401
  }
402
402
  // Fallback: switch above must never fall through. Returning undefined here
403
403
  // makes pi wrap it in a MouseRegion with an undefined child and crashes the TUI.
404
- const fb = result.content[0];
405
- return new Text(theme.fg("error", String(fb?.type === "text" ? fb.text : "Unknown todo result")), 0, 0);
404
+ // The action is known here, so report it instead of digging through content blocks.
405
+ return new Text(theme.fg("error", `Unhandled todo result for action "${String(details.action ?? "")}"`), 0, 0);
406
406
  },
407
407
  });
408
408
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlines/opl-pi-sht",
3
- "version": "0.1.21",
3
+ "version": "0.2.0",
4
4
  "description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, safe modes, fingerprinted /init, model benchmarking, web access, and browser automation.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,7 +45,7 @@
45
45
  "test:opl-footer": "bun test tests/opl-footer-helpers.test.mjs && OPL_EXTENSION=opl-footer bun test tests/extension-smoke.test.mjs",
46
46
  "test:opl-init": "bun test tests/opl-init-crawl.test.mjs && OPL_EXTENSION=opl-init bun test tests/extension-smoke.test.mjs",
47
47
  "test:opl-input": "bun test tests/opl-input-style.test.mjs && OPL_EXTENSION=opl-input bun test tests/extension-smoke.test.mjs",
48
- "test:opl-modes": "bun test tests/opl-modes-helpers.test.mjs && OPL_EXTENSION=opl-modes bun test tests/extension-smoke.test.mjs",
48
+ "test:opl-modes": "bun test tests/opl-modes-helpers.test.mjs tests/opl-modes-lifecycle.test.mjs && OPL_EXTENSION=opl-modes bun test tests/extension-smoke.test.mjs",
49
49
  "test:opl-questionnaire": "OPL_EXTENSION=opl-questionnaire bun test tests/extension-smoke.test.mjs",
50
50
  "test:opl-todo": "bun test tests/opl-todo-config.test.mjs tests/opl-todo-render.test.mjs && OPL_EXTENSION=opl-todo bun test tests/extension-smoke.test.mjs",
51
51
  "test:opl-webaccess": "bun test tests/opl-webaccess-helpers.test.mjs && OPL_EXTENSION=opl-webaccess bun test tests/extension-smoke.test.mjs",