@openlines/opl-pi-sht 0.2.12 → 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 (81) hide show
  1. package/README.md +92 -84
  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 +31 -6
  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 +209 -105
  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/package.json +19 -12
@@ -6,7 +6,7 @@ const NUMERIC_ANSWER = /^\d+$/;
6
6
 
7
7
  function lastNumericToken(line: string): string | null {
8
8
  const tokens = line.match(/\d+/g);
9
- return tokens && tokens.length ? tokens[tokens.length - 1] : null;
9
+ return tokens?.[tokens.length - 1] ?? null;
10
10
  }
11
11
 
12
12
  export function extractAnswer(response: string, expected?: string): { answer: string; method: string } {
@@ -16,7 +16,9 @@ export function extractAnswer(response: string, expected?: string): { answer: st
16
16
  // bare-number remnants that thinking templates leak after the real final line.
17
17
  if (expected && NUMERIC_ANSWER.test(expected)) {
18
18
  for (let i = lines.length - 1; i >= 0; i -= 1) {
19
- const token = lastNumericToken(lines[i]);
19
+ const line = lines[i];
20
+ if (line === undefined) continue;
21
+ const token = lastNumericToken(line);
20
22
  if (token === expected) {
21
23
  return { answer: token, method: i === lines.length - 1 ? "final-line" : "expected-last-numeric" };
22
24
  }
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
- import * as os from "node:os";
12
11
  import * as path from "node:path";
12
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
13
 
14
14
  // ============================================================================
15
15
  // Configuration Constants
@@ -59,9 +59,15 @@ export const CONFIG = {
59
59
  // User Configuration Overrides
60
60
  // ============================================================================
61
61
 
62
- const TEST_CONFIG_DIR = process.env.PI_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
63
- export const TEST_CONFIG_PATH = path.join(TEST_CONFIG_DIR, "configs", "opl-simplebench.json");
64
- const LEGACY_TEST_CONFIG_PATH = path.join(TEST_CONFIG_DIR, "simplebench-config.json");
62
+ /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
63
+ export function configPath(): string {
64
+ return path.join(getAgentDir(), "configs", "opl-simplebench.json");
65
+ }
66
+
67
+ /** Pre-rename location, still read when the current path is absent. */
68
+ function legacyConfigPath(): string {
69
+ return path.join(getAgentDir(), "simplebench-config.json");
70
+ }
65
71
 
66
72
  /** Shape of the user configuration file. */
67
73
  export interface ModelTestUserConfig {
@@ -107,8 +113,8 @@ export interface RunSequenceProfile {
107
113
  */
108
114
  export function readTestConfig(): ModelTestUserConfig {
109
115
  try {
110
- const configPath = fs.existsSync(TEST_CONFIG_PATH) ? TEST_CONFIG_PATH : LEGACY_TEST_CONFIG_PATH;
111
- if (fs.existsSync(configPath)) return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ModelTestUserConfig;
116
+ const file = fs.existsSync(configPath()) ? configPath() : legacyConfigPath();
117
+ if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf-8")) as ModelTestUserConfig;
112
118
  } catch { /* config read/parse failure is non-critical — defaults are used */ }
113
119
  return {};
114
120
  }
@@ -161,8 +167,9 @@ export const WEATHER_TOOL_DEFINITION = {
161
167
  // Tool Support Cache (path only — backing file is no longer written)
162
168
  // ============================================================================
163
169
 
164
- const TOOL_SUPPORT_CACHE_DIR = path.join(os.homedir(), ".pi", "agent", "cache");
165
- export const TOOL_SUPPORT_CACHE_PATH = path.join(TOOL_SUPPORT_CACHE_DIR, "tool_support.json");
170
+ export function toolSupportCachePath(): string {
171
+ return path.join(getAgentDir(), "cache", "tool_support.json");
172
+ }
166
173
 
167
174
  // ============================================================================
168
175
  // ChatFn Abstraction
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Environment for processes that run model-authored code.
3
+ *
4
+ * `coding_lite` and `test_all` write the model's answer to disk and execute it,
5
+ * so whatever the model wrote runs with this process's parent environment. The
6
+ * harness is normally started from a shell that holds provider keys, cloud
7
+ * credentials and agent paths, and none of those belong in front of generated
8
+ * code. This is therefore an allowlist, not a denylist: anything not listed here
9
+ * is absent from the child, including every `*_API_KEY`, `AWS_*`, `GH_*` and
10
+ * `PI_*` variable.
11
+ *
12
+ * `NODE_OPTIONS` and `NODE_PATH` are deliberately never forwarded: both can make
13
+ * Node load attacker-chosen files.
14
+ */
15
+ export const INHERITED_KEYS = [
16
+ // POSIX: node resolution, temp files, locale and timezone.
17
+ "PATH", "HOME", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SHELL", "USER", "LOGNAME",
18
+ // TLS trust the child may legitimately need.
19
+ "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
20
+ // Terminal capabilities only; no secrets live here.
21
+ "TERM", "NO_COLOR", "FORCE_COLOR", "COLORTERM",
22
+ // Windows: spawning and node resolution fail without these.
23
+ "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "USERPROFILE", "HOMEDRIVE", "HOMEPATH",
24
+ "APPDATA", "LOCALAPPDATA",
25
+ ];
26
+
27
+ /** Build the environment for a child that executes untrusted, model-authored code. */
28
+ export function scrubbedEnv(base: NodeJS.ProcessEnv = process.env): Record<string, string> {
29
+ const out: Record<string, string> = {};
30
+ for (const key of INHERITED_KEYS) {
31
+ const value = base[key];
32
+ // NUL-containing values are rejected by spawn(); drop them instead of throwing.
33
+ if (typeof value === "string" && value.length > 0 && !value.includes("\0")) out[key] = value;
34
+ }
35
+ return out;
36
+ }
@@ -258,7 +258,7 @@ export function sanitizeForReport(s: string, maxLines = 40): string {
258
258
  // Use strict detection: require a closing tag AND a common HTML opening tag to avoid
259
259
  // false positives on code that mentions HTML-like syntax (e.g., "use <Item> from 'react'")
260
260
  if (/<!DOCTYPE\b|<html[\s>]/i.test(cleaned) || (/<[a-z][\s\S]*>/i.test(cleaned) && cleaned.includes("</") && /<(?:div|span|p|head|body|html|table|form|script)\b/i.test(cleaned))) {
261
- const firstLine = cleaned.split("\n")[0];
261
+ const firstLine = cleaned.split("\n")[0] ?? "";
262
262
  return truncate(firstLine, 200) + "\n ℹ️ (HTML response truncated)";
263
263
  }
264
264
 
@@ -365,7 +365,7 @@ function parseParamCount(s: string): number | undefined {
365
365
  const match = str.match(/^([\d.]+)\s*([bmt]?|a(?:pple)?)$/);
366
366
  if (!match) return undefined;
367
367
 
368
- const num = parseFloat(match[1]);
368
+ const num = parseFloat(match[1] ?? "");
369
369
  if (isNaN(num) || num <= 0) return undefined;
370
370
 
371
371
  const suffix = match[2];
@@ -6,8 +6,8 @@
6
6
  * @writtenby VTSTech — https://www.vts-tech.org
7
7
  */
8
8
  import * as fs from "node:fs";
9
- import * as path from "node:path";
10
- import os from "node:os";
9
+ import { join } from "node:path";
10
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
11
  import { debugLog } from "./debug";
12
12
 
13
13
  /** Minimal shape of Pi's extension context that provider detection reads. Keeps this module
@@ -41,11 +41,23 @@ export const EXTENSION_VERSION = "1.3.4";
41
41
  *
42
42
  * @example
43
43
  * ```typescript
44
- * console.log(MODELS_JSON_PATH);
44
+ * Pi reads it from its own agent directory, so `modelsJsonPath()` resolves it per call
45
+ * and a custom `PI_AGENT_DIR` is honoured.
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * console.log(modelsJsonPath());
45
50
  * // Output: "/home/user/.pi/agent/models.json"
46
51
  * ```
47
52
  */
48
- export const MODELS_JSON_PATH: string = path.join(os.homedir(), ".pi", "agent", "models.json");
53
+ export function modelsJsonPath(): string {
54
+ return join(getAgentDir(), "models.json");
55
+ }
56
+
57
+ /** Pi's credential store, resolved the same way as {@link modelsJsonPath}. */
58
+ export function authJsonPath(): string {
59
+ return join(getAgentDir(), "auth.json");
60
+ }
49
61
 
50
62
  // ============================================================================
51
63
  // Types
@@ -189,8 +201,9 @@ export function getOllamaBaseUrl(): string {
189
201
  const now = Date.now();
190
202
  if (_ollamaBaseUrlCache && now - _ollamaBaseUrlCache.ts < CACHE_TTL_MS) return _ollamaBaseUrlCache.data;
191
203
  try {
192
- if (fs.existsSync(MODELS_JSON_PATH)) {
193
- const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8");
204
+ const file = modelsJsonPath();
205
+ if (fs.existsSync(file)) {
206
+ const raw = fs.readFileSync(file, "utf-8");
194
207
  const config = JSON.parse(raw) as PiModelsJson;
195
208
  const baseUrl = config?.providers?.["ollama"]?.baseUrl;
196
209
  if (baseUrl) {
@@ -234,8 +247,9 @@ export function readModelsJson(): PiModelsJson {
234
247
  const now = Date.now();
235
248
  if (_modelsJsonCache && now - _modelsJsonCache.ts < CACHE_TTL_MS) return _modelsJsonCache.data;
236
249
  try {
237
- if (fs.existsSync(MODELS_JSON_PATH)) {
238
- const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8");
250
+ const file = modelsJsonPath();
251
+ if (fs.existsSync(file)) {
252
+ const raw = fs.readFileSync(file, "utf-8");
239
253
  const data = JSON.parse(raw) as PiModelsJson;
240
254
  _modelsJsonCache = { data, ts: now };
241
255
  return data;
@@ -246,92 +260,6 @@ export function readModelsJson(): PiModelsJson {
246
260
  return empty;
247
261
  }
248
262
 
249
- /**
250
- * Write Pi's models.json configuration back to disk.
251
- *
252
- * Uses an atomic write-then-rename pattern: the new content is first written
253
- * to a `.tmp` sibling file, then renamed over the target. This ensures
254
- * concurrent readers never see a partially-written file.
255
- *
256
- * NOTE: The write-then-rename pattern provides filesystem-level atomicity
257
- * but does NOT protect against concurrent read-modify-write cycles (e.g.
258
- * two processes both read the file, mutate in memory, then write back).
259
- * If that becomes a concern in the future, file-based locking (e.g. `proper-lockfile`)
260
- * should be added around the read-modify-write sequence.
261
- *
262
- * @param data - The configuration object to write
263
- *
264
- * @example
265
- * ```typescript
266
- * const config = readModelsJson();
267
- * config.providers["ollama"] = {
268
- * baseUrl: "http://localhost:11434/v1",
269
- * models: [{ id: "qwen3:0.6b" }]
270
- * };
271
- * writeModelsJson(config);
272
- * ```
273
- */
274
- export function writeModelsJson(data: PiModelsJson): void {
275
- const dir = path.dirname(MODELS_JSON_PATH);
276
- if (!fs.existsSync(dir)) {
277
- fs.mkdirSync(dir, { recursive: true });
278
- }
279
- const tmpPath = MODELS_JSON_PATH + ".tmp";
280
- fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
281
- fs.renameSync(tmpPath, MODELS_JSON_PATH);
282
- // Invalidate cache so next read picks up the written data
283
- _modelsJsonCache = null;
284
- _ollamaBaseUrlCache = null;
285
- }
286
-
287
- // In-memory mutex for models.json write operations
288
- let _modelsJsonLock: Promise<void> | null = null;
289
-
290
- /**
291
- * Acquire the models.json write lock.
292
- * Returns a release function that MUST be called when done.
293
- * Prevents concurrent read-modify-write cycles from different extensions.
294
- */
295
- export async function acquireModelsJsonLock(): Promise<{ release: () => void }> {
296
- // Wait for any existing lock to be released
297
- while (_modelsJsonLock) {
298
- await _modelsJsonLock;
299
- }
300
- // Create new lock
301
- let releaseLock: () => void;
302
- _modelsJsonLock = new Promise<void>((resolve) => {
303
- releaseLock = resolve;
304
- });
305
- return {
306
- release: () => {
307
- releaseLock!();
308
- _modelsJsonLock = null;
309
- },
310
- };
311
- }
312
-
313
- /**
314
- * Safely read, modify, and write models.json under a lock.
315
- * Prevents concurrent read-modify-write cycles between extensions.
316
- *
317
- * @param modifier - Function that receives the current data and returns modified data (or null to abort)
318
- * @returns true if the write succeeded, false if aborted
319
- */
320
- export async function readModifyWriteModelsJson(
321
- modifier: (data: PiModelsJson) => PiModelsJson | null
322
- ): Promise<boolean> {
323
- const { release } = await acquireModelsJsonLock();
324
- try {
325
- const data = readModelsJson();
326
- const modified = modifier(data);
327
- if (modified === null) return false;
328
- writeModelsJson(modified);
329
- return true;
330
- } finally {
331
- release();
332
- }
333
- }
334
-
335
263
  // ============================================================================
336
264
  // Ollama API Helpers
337
265
  // ============================================================================
@@ -470,86 +398,6 @@ export async function fetchOllamaModels(baseUrl: string): Promise<OllamaModel[]>
470
398
  });
471
399
  }
472
400
 
473
- /**
474
- * Fetch detailed model info from Ollama's /api/show endpoint.
475
- *
476
- * Returns the model's context window size (`num_ctx`), along with
477
- * other details like the template and system prompt. Used by
478
- * ollama-sync to enrich model entries with context length metadata.
479
- *
480
- * @param baseUrl - The Ollama base URL (without /v1 suffix)
481
- * @param modelName - The model tag name (e.g., "qwen3:0.6b")
482
- * @returns Context length in tokens, or undefined if unavailable
483
- *
484
- * @example
485
- * ```typescript
486
- * const ctx = await fetchModelContextLength("http://localhost:11434", "qwen3:0.6b");
487
- * console.log(ctx); // 8192
488
- * ```
489
- */
490
- export async function fetchModelContextLength(
491
- baseUrl: string,
492
- modelName: string
493
- ): Promise<number | undefined> {
494
- return withRetry(async () => {
495
- try {
496
- const res = await fetch(`${baseUrl}/api/show`, {
497
- method: "POST",
498
- headers: { "Content-Type": "application/json" },
499
- body: JSON.stringify({ name: modelName }),
500
- signal: AbortSignal.timeout(30000),
501
- });
502
- if (!res.ok) return undefined;
503
- const data = (await res.json()) as {
504
- model_info?: Record<string, unknown>;
505
- template?: string;
506
- };
507
- const modelInfo = data.model_info ?? {};
508
- // Ollama uses architecture-specific keys like "qwen3.context_length"
509
- for (const key of Object.keys(modelInfo)) {
510
- if (key.endsWith(".context_length")) {
511
- const val = modelInfo[key];
512
- if (typeof val === "number") return val;
513
- }
514
- }
515
- // Fallback: generic "num_ctx" key
516
- const numCtx = data?.model_info?.["num_ctx"];
517
- if (typeof numCtx === "number") return numCtx;
518
- } catch (err) {
519
- debugLog("ollama", `failed to fetch context length for ${modelName}`, err);
520
- return undefined;
521
- }
522
- return undefined;
523
- });
524
- }
525
-
526
- /**
527
- * Fetch context lengths for multiple models, processing them in small batches
528
- * to avoid overwhelming the connection (especially over tunnels).
529
- *
530
- * @param baseUrl - The Ollama base URL (without /v1 suffix)
531
- * @param modelNames - Array of model tag names
532
- * @param batchSize - Number of concurrent requests (default: 3)
533
- * @returns Map of model name to context length (undefined if unavailable)
534
- */
535
- export async function fetchContextLengthsBatched(
536
- baseUrl: string,
537
- modelNames: string[],
538
- batchSize = 3
539
- ): Promise<Map<string, number | undefined>> {
540
- const result = new Map<string, number | undefined>();
541
- for (let i = 0; i < modelNames.length; i += batchSize) {
542
- const batch = modelNames.slice(i, i + batchSize);
543
- const results = await Promise.allSettled(
544
- batch.map((name) => fetchModelContextLength(baseUrl, name))
545
- );
546
- results.forEach((r, idx) => {
547
- result.set(batch[idx], r.status === "fulfilled" ? r.value : undefined);
548
- });
549
- }
550
- return result;
551
- }
552
-
553
401
  /**
554
402
  * Check if an Ollama model name suggests reasoning capability.
555
403
  *
@@ -728,7 +576,7 @@ function expandEnvVars(value: string): string {
728
576
  */
729
577
  function readAuthJsonKey(providerName: string): string {
730
578
  try {
731
- const p = path.join(os.homedir(), ".pi", "agent", "auth.json");
579
+ const p = authJsonPath();
732
580
  if (!fs.existsSync(p)) return "";
733
581
  const entry = JSON.parse(fs.readFileSync(p, "utf-8"))[providerName];
734
582
  return typeof entry?.key === "string" ? entry.key : "";
@@ -825,22 +673,3 @@ export function detectProvider(ctx: PiExtensionContext, modelsJson?: PiModelsJso
825
673
  // Tier 3: Unknown provider
826
674
  return { kind: "unknown", name: providerName };
827
675
  }
828
-
829
- // ============================================================================
830
- // Local Provider Detection
831
- // ============================================================================
832
-
833
- /**
834
- * Check if a provider URL indicates a local (on-machine) provider.
835
- * Used by api.ts, status.ts, and diag.ts to determine whether
836
- * system metrics (CPU/RAM/Swap) are meaningful.
837
- *
838
- * @param baseUrl - The provider's base URL (e.g. "http://localhost:11434/v1")
839
- * @param providerName - Optional provider name (e.g. "ollama")
840
- * @returns `true` if the provider is local, `false` otherwise
841
- */
842
- export function isLocalProvider(baseUrl: string, providerName?: string): boolean {
843
- if (providerName === "ollama") return true;
844
- const url = baseUrl || "";
845
- return url.includes("localhost") || url.includes("127.0.0.1") || url.includes("0.0.0.0");
846
- }
@@ -7,19 +7,19 @@ Provides branch-aware model-managed tasks with a non-capturing overlay and full-
7
7
  - `/todos`: open the current branch's full-screen task list; `Esc` closes it.
8
8
  - Tool: `todo` with `list`, `add`, `toggle`, and `clear` actions.
9
9
  - `shortcuts.toggleWidget`: show or hide the overlay, default `ctrl+alt+t`.
10
- - `shortcuts.resetDone`: clear only a fully completed list, default `ctrl+alt+r`.
10
+ - `shortcuts.resetDone`: clear completed todos while keeping unfinished items, default `ctrl+alt+r`.
11
11
 
12
12
  ## Extension features
13
13
 
14
14
  - Stores todos in session tool results; switching session branches with `/tree` restores that branch's list.
15
15
  - Starts a fresh batch at ID `#1` when adding after every prior task is complete.
16
- - Displays a top-right overlay without taking editor focus; hides below 80 columns and caps its height to the configured terminal percentage.
16
+ - Displays a top-right overlay without taking editor focus; hides below 80 columns and caps its height to the configured terminal percentage. The row count comes from the overlay renderer - pi-tui reports the dimensions it is about to draw into on every frame - so a redirected stdout or an embedded terminal of a different shape cannot make the widget size itself against the wrong screen. Until the first frame reports a height, a 24-row terminal is assumed.
17
17
  - Shows `✓ All done!` and hides after the configured delay; `clear` hides immediately.
18
18
  - Reconstructs state on session start, `/reload`, and session-tree changes. A completed restored list is cleared so the next add begins a new batch.
19
19
 
20
20
  ## Configuration
21
21
 
22
- Copy [`configs/opl-todo.json.sample`](../../configs/opl-todo.json.sample) to `~/.pi/agent/configs/opl-todo.json`, then `/reload` or restart Pi.
22
+ Copy [`configs/opl-todo.json.sample`](../../configs/opl-todo.json.sample) to `~/.pi/agent/configs/opl-todo.json`, then `/reload` or restart Pi. The example below shows **chosen values, not defaults** - the defaults are in the table.
23
23
 
24
24
  ```json
25
25
  {
@@ -33,9 +33,9 @@ Copy [`configs/opl-todo.json.sample`](../../configs/opl-todo.json.sample) to `~/
33
33
  |---|---:|---|
34
34
  | `allDoneHideMs` | `5000` | Delay before a completed overlay hides; `0` hides immediately. |
35
35
  | `shortcuts.toggleWidget` | `ctrl+alt+t` | Toggle overlay visibility. |
36
- | `shortcuts.resetDone` | `ctrl+alt+r` | Clear only a fully completed list. |
36
+ | `shortcuts.resetDone` | `ctrl+alt+r` | Clear completed todos while keeping unfinished items. |
37
37
  | `widget.widthPercent` | `33` | Overlay width, greater than 0 through 100. |
38
- | `widget.maxHeightPercent` | `50` | Terminal-row percentage used for the item cap. |
38
+ | `widget.maxHeightPercent` | `50` | Percentage of the **renderer-reported** terminal height used for the item cap (minus the box's own chrome, floor of 3 items). |
39
39
  | `widget.minWidth` | `32` | Minimum overlay width; values below 20 are rejected. |
40
40
 
41
41
  ## Architecture
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
2
  import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  export interface TodoConfig {
6
6
  allDoneHideMs: number;
@@ -28,7 +28,12 @@ const DEFAULT: TodoConfig = {
28
28
  },
29
29
  };
30
30
 
31
- function load(path = join(homedir(), ".pi", "agent", "configs", "opl-todo.json")): TodoConfig {
31
+ /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
32
+ export function configPath(): string {
33
+ return join(getAgentDir(), "configs", "opl-todo.json");
34
+ }
35
+
36
+ function load(path = configPath()): TodoConfig {
32
37
  try {
33
38
  if (!existsSync(path)) return DEFAULT;
34
39
  const raw = JSON.parse(readFileSync(path, "utf8"));
@@ -24,6 +24,13 @@ interface TodoDetails {
24
24
  error?: string;
25
25
  }
26
26
 
27
+ interface TodoStateSnapshot {
28
+ todos: Todo[];
29
+ nextId: number;
30
+ }
31
+
32
+ const TODO_STATE_ENTRY = "opl-todo-state";
33
+
27
34
  // ── Widget constants ─────────────────────────────────────────────────────────
28
35
 
29
36
  const TodoParams = Type.Object({
@@ -32,20 +39,34 @@ const TodoParams = Type.Object({
32
39
  id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
33
40
  });
34
41
 
42
+ /** Rows per terminal before the overlay has reported one (first frame, or a non-TUI host). */
43
+ const FALLBACK_ROWS = 24;
44
+
45
+ /** How many todo lines fit in `rows` terminal rows, given the configured height budget and the
46
+ * box's own chrome (border, title, progress line, footer). */
47
+ export function widgetMaxItems(rows: number): number {
48
+ const usable = (rows > 0 ? rows : FALLBACK_ROWS) * CONFIG.widget.maxHeightPercent;
49
+ return Math.max(3, Math.floor(usable / 100) - 5);
50
+ }
51
+
35
52
  /**
36
53
  * Persistent top-right overlay. Non-capturing — never steals editor focus.
37
54
  * Reads the shared `todos` array directly so render() always reflects live state.
38
55
  */
39
- class TodoWidgetComponent {
56
+ export class TodoWidgetComponent {
40
57
  constructor(
41
58
  private readonly todos: Todo[],
42
59
  private readonly theme: Theme,
60
+ // The height comes from the overlay renderer, which reports the terminal it is drawing
61
+ // into on every frame. The harness terminal size is not that: with stdout redirected, or
62
+ // an embedded terminal of a different shape, it describes some other screen.
63
+ private readonly terminalRows: () => number = () => FALLBACK_ROWS,
43
64
  ) {}
44
65
 
45
66
  render(width: number): string[] {
46
67
  const th = this.theme;
47
68
  const t = this.todos;
48
- const maxItems = Math.max(3, Math.floor((process.stdout.rows || 24) * CONFIG.widget.maxHeightPercent / 100) - 5);
69
+ const maxItems = widgetMaxItems(this.terminalRows());
49
70
  const W = Math.max(width, CONFIG.widget.minWidth);
50
71
 
51
72
  const b = (s: string) => th.fg("border", s);
@@ -168,6 +189,8 @@ export default function (pi: ExtensionAPI) {
168
189
  // ── Widget handles ─────────────────────────────────────────────────────
169
190
  let overlayHandle: OverlayHandle | undefined;
170
191
  let tuiRef: TUI | undefined;
192
+ // Terminal height as the overlay renderer last reported it. 0 until the first frame.
193
+ let overlayRows = 0;
171
194
  let autoHideTimer: ReturnType<typeof setTimeout> | undefined;
172
195
 
173
196
  function showWidget(): void {
@@ -194,6 +217,15 @@ export default function (pi: ExtensionAPI) {
194
217
  nextId = 1;
195
218
 
196
219
  for (const entry of ctx.sessionManager.getBranch()) {
220
+ if (entry.type === "custom" && entry.customType === TODO_STATE_ENTRY) {
221
+ const state = entry.data as TodoStateSnapshot | undefined;
222
+ if (state && Array.isArray(state.todos) && typeof state.nextId === "number") {
223
+ todos.length = 0;
224
+ todos.push(...state.todos);
225
+ nextId = state.nextId;
226
+ }
227
+ continue;
228
+ }
197
229
  if (entry.type !== "message") continue;
198
230
  const msg = entry.message;
199
231
  if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
@@ -226,7 +258,7 @@ export default function (pi: ExtensionAPI) {
226
258
  void ctx.ui.custom<void>(
227
259
  (tui, theme, _kb, _done) => {
228
260
  tuiRef = tui;
229
- return new TodoWidgetComponent(todos, theme);
261
+ return new TodoWidgetComponent(todos, theme, () => overlayRows);
230
262
  },
231
263
  {
232
264
  overlay: true,
@@ -236,7 +268,12 @@ export default function (pi: ExtensionAPI) {
236
268
  offsetY: 0,
237
269
  width: `${CONFIG.widget.widthPercent}%`,
238
270
  nonCapturing: true,
239
- visible: (w) => w >= 80,
271
+ // pi-tui calls this every frame with the dimensions it is about to draw into, so the
272
+ // widget sizes itself against the screen it is actually on.
273
+ visible: (w, h) => {
274
+ overlayRows = h;
275
+ return w >= 80;
276
+ },
240
277
  },
241
278
  onHandle: (h) => {
242
279
  overlayHandle = h;
@@ -259,12 +296,16 @@ export default function (pi: ExtensionAPI) {
259
296
  });
260
297
 
261
298
  pi.registerShortcut(CONFIG.shortcuts.resetDone as KeyId, {
262
- description: "Clear completed todos (only when all are done)",
299
+ description: "Clear completed todos",
263
300
  handler: (_ctx) => {
264
- if (todos.length === 0 || !todos.every((t) => t.done)) return;
265
- todos.length = 0;
266
- nextId = 1;
267
- hideWidget();
301
+ const remaining = todos.filter((todo) => !todo.done);
302
+ if (remaining.length === todos.length) return;
303
+ todos.splice(0, todos.length, ...remaining);
304
+ if (todos.length === 0) nextId = 1;
305
+ clearTimeout(autoHideTimer);
306
+ pi.appendEntry(TODO_STATE_ENTRY, { todos: [...todos], nextId } satisfies TodoStateSnapshot);
307
+ if (todos.length === 0) hideWidget();
308
+ else showWidget();
268
309
  },
269
310
  });
270
311
 
@@ -13,9 +13,10 @@ Provides configurable web search and readable URL/PDF retrieval with session-bac
13
13
  ## Extension features
14
14
 
15
15
  - Searches through `gemini`, `tavily`, `ddgs`, `searxng`, or `exa`; multiple queries run concurrently and results include citations where available.
16
- - Extracts HTML with Readability and Markdown conversion, falls back to full-document Turndown conversion, passes PDF responses to the PDF extractor, and returns plain text, Markdown, and JSON directly.
17
- - Restricts `fetch_content` to http/https and blocks private-range, link-local, and reserved hosts by default, re-checks every redirect hop, and always blocks cloud-metadata endpoints (SSRF guard). Loopback (`localhost`/`127.0.0.0/8`/`::1`) is allowed by default for local development; set `allowPrivateNetwork: true` to reach private ranges. Caps each response at 10 MB and applies a 30s timeout; Gemini keys are sent via the `x-goog-api-key` header rather than the query string.
18
- - Caps initial tool output at a configurable length (default 30,000 characters), then keeps it for retrieval for one hour (expiry is enforced on read, not just on write) or until the session ends.
16
+ - Extracts HTML with Readability and Markdown conversion, falls back to full-document Turndown conversion, normalizes PDF response buffers to plain `Uint8Array` input for text extraction, and returns plain text, Markdown, and JSON directly.
17
+ - Restricts `fetch_content` to http/https and applies its host policy to **resolved addresses**: a URL is rejected when any DNS answer for its hostname falls in blocked space, not merely when the hostname is written as an internal literal. Private/link-local/reserved answers are blocked by default (`allowPrivateNetwork` to opt in), loopback answers are refused unless `allowLoopback: true` opts in, and cloud-metadata and unspecified addresses (`0.0.0.0/8`, `::`) are always blocked. Every redirect hop is re-validated and re-resolved, so a public first hop cannot bounce into internal space. Caps each response at 10 MB and applies a 30s timeout; the DNS round-trip is bounded by the same signal, and concurrent lookups for one host share a single query. Gemini keys are sent via the `x-goog-api-key` header rather than the query string. The addresses that pass are the ones the socket dials, so a short-TTL record cannot answer one address for the check and another for the connection.
18
+ - Caps initial tool output at a configurable length (default 30,000 characters) and keeps results retrievable for one hour (expiry is enforced on read, not just on write) or until the session ends.
19
+ - Keeps the full body **out of the session transcript**: each result spills to `<agent dir>/web-access-cache/<responseId>.json` (the directory pi itself uses, so a relocated `PI_CODING_AGENT_DIR` moves it) and the session entry stores a bounded preview (512 bytes per answer or URL content, marked `truncated`) plus the file path. Reads rehydrate from disk; if the spill file is gone or expired, the preview is returned with a note to re-fetch rather than a silent stub. The cache is pruned by age, then oldest-first past a 32 MB ceiling.
19
20
  - Pages `get_search_content` results (default 30,000 characters per page) with `offset` continuation, so a model never loads the whole stored body in one call.
20
21
  - Honors abort signals and returns provider, HTTP, and per-result failures through the tool boundary rather than throwing.
21
22
 
@@ -43,7 +44,10 @@ Optional top-level caps control how much content reaches the model:
43
44
  | `maxRetrievalChars` | `30000` | Cap on one `get_search_content` page. Pass `offset` to continue. |
44
45
  | `maxSearchQueries` | `10` | Cap on how many queries one `web_search` call runs. Excess queries are skipped and reported. |
45
46
  | `maxFetchUrls` | `20` | Cap on how many URLs one `fetch_content` call fetches. Excess URLs are skipped and reported. |
46
- | `allowPrivateNetwork` | `false` | Allow `fetch_content` to reach private/link-local ranges (loopback is always allowed; cloud metadata is always blocked). Provider API endpoints (e.g. `ddgs.apiUrl`, `searxng.instanceUrl`) are excluded from this guard. |
47
+ | `timeoutMs` | `30000` | Deadline for one provider search or URL fetch, composed with the caller's abort signal. A slow provider fails as its own error instead of hanging the tool. |
48
+ | `maxResponseBytes` | `10485760` | Independent ceilings on received and decoded response bytes. Exceeding either stops reading and closes the socket and decoder before output truncation. |
49
+ | `allowPrivateNetwork` | `false` | Allow `fetch_content` to reach private/link-local ranges, literal or resolved (cloud metadata is always blocked). Provider API endpoints (e.g. `ddgs.apiUrl`, `searxng.instanceUrl`) are excluded from this guard. |
50
+ | `allowLoopback` | `false` | Allow `fetch_content` to reach loopback — `localhost`, `127.0.0.0/8`, `::1`, literal or resolved. Set `true` to fetch from a local dev server; metadata and private ranges are unaffected by this key. |
47
51
 
48
52
  Install extraction dependencies before use:
49
53
 
@@ -54,6 +58,28 @@ npm install
54
58
 
55
59
  `@mozilla/readability`, `linkedom`, `turndown`, and `unpdf` are runtime dependencies. The extension registers without them, but affected calls fail until installed; `unpdf` loads only for PDFs.
56
60
 
61
+ ### Network policy
62
+
63
+ `fetch_content` classifies the address it is about to contact, not the text in the URL:
64
+
65
+ 1. Scheme, userinfo and literal-host checks (`assertHttpUrl`).
66
+ 2. For a non-literal hostname, `node:dns` `lookup(host, { all: true, verbatim: true })`, then the same classifier over **every** answer. One internal answer among many public ones rejects.
67
+ 3. The addresses that passed are handed to the transport, which dials them through a pinned `lookup` (`http.ts`). Nothing in the connection path resolves the name again.
68
+ 4. Each redirect hop re-enters at step 2 with the new hostname — re-resolved and re-pinned, with no reuse across hops and no cache between calls.
69
+ 5. `Host` and TLS `servername` keep the hostname, so virtual hosting and certificate validation behave as they would unpinned. Sockets are never pooled, so a connection made under one hop's answer cannot carry a later request.
70
+
71
+ The transport requests `identity` encoding but decodes `gzip`, `deflate` and `br` when a server compresses anyway. `maxResponseBytes` (default 10485760) independently caps received and decoded bytes; malformed, unsupported or chained encodings return an error. Redirects (301/302/303/307/308 with `Location`) return headers immediately and discard their bodies before the next hop is validated. The overall `timeoutMs` budget spans all hops; cancellation, inactivity and byte-limit failures close the request, response and decoder.
72
+
73
+ Compressed and fully expanded IPv6 forms share the same policy, including DNS answers. NAT64 (`64:ff9b::/96`), 6to4 (`2002::/16`), mapped and IPv4-compatible addresses are classified by their embedded IPv4. The entire local-use translation prefix `64:ff9b:1::/48` is always blocked, regardless of `allowPrivateNetwork` or `allowLoopback`; this also blocks translations of public IPv4 addresses within that prefix.
74
+
75
+ A resolver failure rejects the fetch (fail closed): unlike a browser's subresources, this URL was named by the model, so a host that does not resolve is an error to report rather than a request to wave through. The lookup honors the caller's abort signal and the 30s fetch budget.
76
+
77
+ Residuals, deliberately left open:
78
+
79
+ - **Operator endpoints are not pinned.** Provider API endpoints (`ddgs.apiUrl`, `searxng.instanceUrl`) are chosen by you, not by the model, so they stay reachable on private or loopback hosts and go through the ordinary client.
80
+ - **A response body can describe internal services.** The guard decides where the tool connects, not what the text says.
81
+ - **`opl-browser` cannot pin.** Chromium resolves names internally and no Playwright API exposes a per-connection resolver; see its README. This pin covers `fetch_content`, not page traffic.
82
+
57
83
  ## Architecture
58
84
 
59
- `index.ts` registers the tools and session storage. Provider adapters live under `providers/`; `search.ts` dispatches search, `extract.ts` converts readable content, `pdf.ts` handles PDFs, and stored response data backs `get_search_content`. Smoke tests bundle with these runtime dependencies externalized; they do not exercise credentials, network, extraction, or PDF behavior.
85
+ `index.ts` registers the tools and session storage. Provider adapters live under `providers/`; `search.ts` dispatches search, `http.ts` owns the pinned transport (one request per hop, body cap enforced while reading), `extract.ts` converts readable content, `pdf.ts` handles PDFs, and stored response data backs `get_search_content`. `utils.ts` exports `resolveSafeHostUrl`, the only place a policy decision produces the address set a socket may use; `assertSafeHttpUrl` is the string-only wrapper for callers that never connect. Smoke tests bundle with these runtime dependencies externalized; they do not exercise credentials, network, extraction, or PDF behavior.