@openlines/opl-pi-sht 0.2.12 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +101 -85
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package-lock.json +3 -3
  12. package/extensions/opl-browser/package.json +4 -4
  13. package/extensions/opl-browser/validate.ts +313 -60
  14. package/extensions/opl-configurator/README.md +40 -0
  15. package/extensions/opl-configurator/configurator.ts +151 -0
  16. package/extensions/opl-configurator/index.ts +12 -0
  17. package/extensions/opl-ctxtrim/README.md +45 -14
  18. package/extensions/opl-ctxtrim/index.ts +33 -13
  19. package/extensions/opl-footer/README.md +31 -6
  20. package/extensions/opl-footer/config.ts +11 -10
  21. package/extensions/opl-footer/configure.ts +159 -115
  22. package/extensions/opl-footer/git-status.ts +58 -1
  23. package/extensions/opl-footer/icons.ts +3 -0
  24. package/extensions/opl-footer/index.ts +209 -105
  25. package/extensions/opl-footer/segments/caveman.ts +9 -1
  26. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  27. package/extensions/opl-footer/segments/compactions.ts +13 -0
  28. package/extensions/opl-footer/segments/context.ts +6 -5
  29. package/extensions/opl-footer/segments/index.ts +2 -0
  30. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  31. package/extensions/opl-footer/segments/path.ts +1 -1
  32. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  33. package/extensions/opl-footer/theme.ts +5 -2
  34. package/extensions/opl-footer/types.ts +4 -0
  35. package/extensions/opl-guardian/README.md +17 -5
  36. package/extensions/opl-guardian/config.ts +72 -6
  37. package/extensions/opl-guardian/guardian.ts +42 -10
  38. package/extensions/opl-guardian/index.ts +52 -17
  39. package/extensions/opl-guardian/policies.ts +52 -16
  40. package/extensions/opl-init/README.md +12 -4
  41. package/extensions/opl-init/index.ts +228 -27
  42. package/extensions/opl-input/README.md +17 -3
  43. package/extensions/opl-input/config.ts +59 -5
  44. package/extensions/opl-input/configure.ts +146 -0
  45. package/extensions/opl-input/index.ts +38 -30
  46. package/extensions/opl-input/mode-style.ts +6 -2
  47. package/extensions/opl-modes/README.md +22 -8
  48. package/extensions/opl-modes/config.ts +62 -8
  49. package/extensions/opl-modes/index.ts +87 -28
  50. package/extensions/opl-modes/state.ts +50 -2
  51. package/extensions/opl-modes/types.ts +6 -0
  52. package/extensions/opl-modes/utils.ts +18 -8
  53. package/extensions/opl-questionnaire/index.ts +10 -4
  54. package/extensions/opl-questionnaire/types.ts +4 -0
  55. package/extensions/opl-simplebench/README.md +21 -15
  56. package/extensions/opl-simplebench/artifact.ts +7 -4
  57. package/extensions/opl-simplebench/benchmark.ts +96 -35
  58. package/extensions/opl-simplebench/coding.ts +9 -4
  59. package/extensions/opl-simplebench/index.ts +19 -19
  60. package/extensions/opl-simplebench/llama-server.ts +3 -2
  61. package/extensions/opl-simplebench/report.ts +0 -6
  62. package/extensions/opl-simplebench/research.ts +5 -4
  63. package/extensions/opl-simplebench/scoring.ts +4 -2
  64. package/extensions/opl-simplebench/util/config.ts +15 -8
  65. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  66. package/extensions/opl-simplebench/util/format.ts +2 -2
  67. package/extensions/opl-simplebench/util/providers.ts +23 -194
  68. package/extensions/opl-todo/README.md +5 -5
  69. package/extensions/opl-todo/config.ts +7 -2
  70. package/extensions/opl-todo/index.ts +50 -9
  71. package/extensions/opl-webaccess/README.md +31 -5
  72. package/extensions/opl-webaccess/config.ts +36 -7
  73. package/extensions/opl-webaccess/extract.ts +37 -47
  74. package/extensions/opl-webaccess/http.ts +259 -0
  75. package/extensions/opl-webaccess/index.ts +12 -6
  76. package/extensions/opl-webaccess/package-lock.json +3 -3
  77. package/extensions/opl-webaccess/package.json +4 -9
  78. package/extensions/opl-webaccess/pdf.ts +4 -4
  79. package/extensions/opl-webaccess/search.ts +19 -8
  80. package/extensions/opl-webaccess/storage.ts +134 -2
  81. package/extensions/opl-webaccess/types.ts +4 -0
  82. package/extensions/opl-webaccess/utils.ts +221 -33
  83. package/package.json +19 -12
@@ -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.
@@ -26,15 +26,28 @@ export interface WebAccessConfig {
26
26
  maxSearchQueries?: number;
27
27
  /** Cap on the number of URLs a single fetch_content call may fetch. */
28
28
  maxFetchUrls?: number;
29
- /** Allow fetch_content to reach private/link-local ranges (loopback is always
30
- * allowed; cloud metadata is always blocked). Default false. */
29
+ /** Allow fetch_content to reach private/link-local ranges (default false). Cloud
30
+ * metadata is always blocked. */
31
31
  allowPrivateNetwork?: boolean;
32
+ /** Allow fetch_content to reach loopback hosts — localhost, 127.0.0.0/8, ::1
33
+ * (default false: opt in). Cloud metadata is unaffected. */
34
+ allowLoopback?: boolean;
35
+ /** Deadline applied to one provider search request or one fetched URL (ms,
36
+ * default 30000). The agent's own abort signal is combined with it, so the
37
+ * shorter of the two wins. */
38
+ timeoutMs?: number;
39
+ /** Ceiling on one fetched response body (bytes, default 10485760). The transport stops
40
+ * reading and drops the socket past it, so an oversized page cannot cost memory before
41
+ * content truncation gets a say. */
42
+ maxResponseBytes?: number;
32
43
  }
33
44
 
34
45
  export const DEFAULT_MAX_CONTENT_CHARS = 30_000;
35
46
  export const DEFAULT_MAX_RETRIEVAL_CHARS = 30_000;
36
47
  export const DEFAULT_MAX_SEARCH_QUERIES = 10;
37
48
  export const DEFAULT_MAX_FETCH_URLS = 20;
49
+ export const DEFAULT_TIMEOUT_MS = 30_000;
50
+ export const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
38
51
 
39
52
  /** Coerce an unknown value into a positive integer, else the fallback. */
40
53
  function positiveInt(value: unknown, fallback: number): number {
@@ -55,7 +68,20 @@ export function resolveCaps(
55
68
  };
56
69
  }
57
70
 
58
- const CONFIG_PATH = join(getAgentDir(), "configs", "opl-webaccess.json");
71
+ /** Per-request deadline for provider calls and URL fetches (ms). */
72
+ export function resolveTimeoutMs(cfg: Pick<WebAccessConfig, "timeoutMs">): number {
73
+ return positiveInt(cfg.timeoutMs, DEFAULT_TIMEOUT_MS);
74
+ }
75
+
76
+ /** Per-response body ceiling for URL fetches (bytes). */
77
+ export function resolveMaxResponseBytes(cfg: Pick<WebAccessConfig, "maxResponseBytes">): number {
78
+ return positiveInt(cfg.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES);
79
+ }
80
+
81
+ /** Resolved per call so a custom `PI_CODING_AGENT_DIR` (pi's own agent dir) is honoured. */
82
+ export function configPath(): string {
83
+ return join(getAgentDir(), "configs", "opl-webaccess.json");
84
+ }
59
85
 
60
86
  const DEFAULT_CONFIG: WebAccessConfig = {
61
87
  provider: "gemini",
@@ -68,13 +94,13 @@ const DEFAULT_CONFIG: WebAccessConfig = {
68
94
  },
69
95
  };
70
96
 
71
- export function loadConfig(): WebAccessConfig {
72
- if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG;
97
+ export function loadConfig(file = configPath()): WebAccessConfig {
98
+ if (!existsSync(file)) return DEFAULT_CONFIG;
73
99
  let parsed: unknown;
74
100
  try {
75
- parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
101
+ parsed = JSON.parse(readFileSync(file, "utf-8"));
76
102
  } catch {
77
- console.error(`[opl-webaccess] failed to parse ${CONFIG_PATH}, using defaults`);
103
+ console.error(`[opl-webaccess] failed to parse ${file}, using defaults`);
78
104
  return DEFAULT_CONFIG;
79
105
  }
80
106
  return normalizeConfig(parsed);
@@ -106,6 +132,9 @@ function normalizeConfig(parsed: unknown): WebAccessConfig {
106
132
  maxSearchQueries: record.maxSearchQueries as number | undefined,
107
133
  maxFetchUrls: record.maxFetchUrls as number | undefined,
108
134
  allowPrivateNetwork: record.allowPrivateNetwork === true,
135
+ allowLoopback: record.allowLoopback === true,
136
+ timeoutMs: record.timeoutMs as number | undefined,
137
+ maxResponseBytes: record.maxResponseBytes as number | undefined,
109
138
  };
110
139
  }
111
140