@underactive/pi-topping-web-tools 0.1.0 → 0.2.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.
@@ -1,15 +1,6 @@
1
- /**
2
- * pdf_extract — extract a text layer from remote or local PDFs.
3
- *
4
- * Fills a gap the other web tools cannot: fetch_markdown classifies application/pdf
5
- * as binary and pi's read tool cannot parse PDFs at all.
6
- *
7
- * Security: remote URLs reuse fetch_markdown's validation, redirect rules, and the
8
- * preapproved-host allowlist. Local paths are resolved through realpath before any
9
- * check so symlinks cannot escape, and only files inside cwd skip the prompt.
10
- */
11
-
12
- import { defineTool, formatSize, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ /** Security: remote URLs reuse fetch_markdown's validation, redirect rules, and preapproved-host allowlist; local paths are realpath-resolved and only files inside cwd skip the prompt. */
2
+
3
+ import { defineTool, formatSize, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
4
  import { Text } from "@earendil-works/pi-tui";
14
5
  import { LRUCache } from "lru-cache";
15
6
  import { basename, isAbsolute, relative, resolve } from "node:path";
@@ -17,11 +8,11 @@ import { readFile, realpath, stat } from "node:fs/promises";
17
8
  import { fileURLToPath } from "node:url";
18
9
  import { Type } from "typebox";
19
10
  import { extractText, getDocumentProxy } from "unpdf";
20
- import { isPreapprovedHost } from "./permissions.ts";
11
+ import { combineSignals } from "./abort-utils.ts";
12
+ import { isPreapprovedHost, permissionKey } from "./permissions.ts";
13
+ import { requestHostPermission } from "./permission-prompt.ts";
21
14
  import { getWithPermittedRedirects, isPermittedRedirect, sliceContent, validateURL } from "./fetch-markdown.ts";
22
- import { permissionKey, upgradeHttpToHttps } from "./web-browser/permissions.ts";
23
-
24
- // --- Constants ---
15
+ import { upgradeHttpToHttps } from "./web-browser/permissions.ts";
25
16
 
26
17
  const MAX_PDF_BYTES = 25 * 1024 * 1024;
27
18
  const MAX_PDF_PAGES = 2000;
@@ -34,8 +25,6 @@ const PDF_MAGIC = "%PDF-";
34
25
  // The PDF spec tolerates leading bytes before the header, and so does PDF.js.
35
26
  const MAGIC_SEARCH_WINDOW = 1024;
36
27
 
37
- // --- Cache ---
38
-
39
28
  type PdfCacheEntry = {
40
29
  pages: string[];
41
30
  totalPages: number;
@@ -71,24 +60,6 @@ export function _cleanup(): void {
71
60
  _activeFetches.clear();
72
61
  }
73
62
 
74
- // --- Helpers ---
75
-
76
- function combineSignals(userSignal: AbortSignal | undefined): { signal: AbortSignal; cleanup: () => void } {
77
- const controller = new AbortController();
78
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
79
- if (typeof timer.unref === "function") timer.unref();
80
- controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
81
- _activeFetches.add(controller);
82
- const signal = userSignal ? AbortSignal.any([userSignal, controller.signal]) : controller.signal;
83
- return {
84
- signal,
85
- cleanup: () => {
86
- clearTimeout(timer);
87
- _activeFetches.delete(controller);
88
- },
89
- };
90
- }
91
-
92
63
  async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
93
64
  let timer: ReturnType<typeof setTimeout> | undefined;
94
65
  try {
@@ -96,7 +67,7 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, message: string):
96
67
  promise,
97
68
  new Promise<never>((_resolve, reject) => {
98
69
  timer = setTimeout(() => reject(new Error(message)), ms);
99
- if (typeof timer.unref === "function") timer.unref();
70
+ timer.unref();
100
71
  }),
101
72
  ]);
102
73
  } finally {
@@ -177,7 +148,7 @@ export async function resolveLocalPdf(input: string): Promise<LocalPdfTarget> {
177
148
  }
178
149
 
179
150
  async function fetchPdfBytes(url: string, userSignal: AbortSignal | undefined): Promise<Uint8Array> {
180
- const { signal, cleanup } = combineSignals(userSignal);
151
+ const { signal, cleanup } = combineSignals(userSignal, FETCH_TIMEOUT_MS, _activeFetches);
181
152
  let response: Awaited<ReturnType<typeof getWithPermittedRedirects>>;
182
153
  try {
183
154
  response = await getWithPermittedRedirects(url, signal, isPermittedRedirect, {
@@ -289,8 +260,6 @@ async function loadPdf(
289
260
  return { ...entry, cached: false, source };
290
261
  }
291
262
 
292
- // --- Tool ---
293
-
294
263
  interface PdfExtractDetails {
295
264
  source: string;
296
265
  totalPages: number;
@@ -399,9 +368,10 @@ Usage notes:
399
368
  const offset = Math.max(0, Math.floor(params.offset ?? 0));
400
369
  const slice = sliceContent(body, offset);
401
370
  const sanitizedText = slice.text.replaceAll("</untrusted-content", "");
371
+ const safeSource = source.replaceAll('"', "%22");
402
372
 
403
373
  return {
404
- content: [{ type: "text" as const, text: `<untrusted-content source="${source}">\n${sanitizedText}\n</untrusted-content>` }],
374
+ content: [{ type: "text" as const, text: `<untrusted-content source="${safeSource}">\n${sanitizedText}\n</untrusted-content>` }],
405
375
  details: {
406
376
  ...baseDetails,
407
377
  totalChars: slice.totalChars,
@@ -468,39 +438,6 @@ Usage notes:
468
438
  },
469
439
  });
470
440
 
471
- // --- Permission gating ---
472
-
473
- async function promptForAccess(
474
- ctx: ExtensionContext,
475
- label: string,
476
- key: string,
477
- sessionPermissions: Map<string, "allow" | "deny">,
478
- ): Promise<{ block: true; reason: string } | undefined> {
479
- const sessionDecision = sessionPermissions.get(key);
480
- if (sessionDecision === "allow") return undefined;
481
- if (sessionDecision === "deny") return { block: true, reason: `Denied by user for ${key}` };
482
-
483
- if (!ctx.hasUI) {
484
- return { block: true, reason: `pdf_extract from ${key} blocked (no UI for confirmation)` };
485
- }
486
-
487
- const choice = await ctx.ui.select(label, ["Allow once", "Allow for this session", "Deny"]);
488
-
489
- if (choice === "Allow for this session") {
490
- sessionPermissions.set(key, "allow");
491
- return undefined;
492
- }
493
- if (choice === "Allow once") {
494
- return undefined;
495
- }
496
- if (choice === "Deny") {
497
- sessionPermissions.set(key, "deny");
498
- }
499
- return { block: true, reason: `Denied by user for ${key}` };
500
- }
501
-
502
- // --- Extension factory ---
503
-
504
441
  export default function (pi: ExtensionAPI) {
505
442
  const sessionPermissions = new Map<string, "allow" | "deny">();
506
443
 
@@ -522,12 +459,17 @@ export default function (pi: ExtensionAPI) {
522
459
  return undefined;
523
460
  }
524
461
  if (isPreapprovedHost(hostname, pathname)) return undefined;
525
- return promptForAccess(
526
- ctx,
527
- `Allow pdf_extract from ${hostname}?`,
528
- permissionKey(input.url),
462
+ const result = await requestHostPermission(ctx, {
463
+ scope: "pdf_extract",
464
+ label: `Allow pdf_extract from ${hostname}?`,
465
+ key: permissionKey(input.url),
529
466
  sessionPermissions,
530
- );
467
+ durable: true,
468
+ });
469
+ if (!result.allowed) {
470
+ return { block: true, reason: result.reason };
471
+ }
472
+ return undefined;
531
473
  }
532
474
 
533
475
  if (input.path) {
@@ -539,12 +481,17 @@ export default function (pi: ExtensionAPI) {
539
481
  }
540
482
  // Files in the working directory carry the same trust as the read tool.
541
483
  if (isInsideCwd(target.path)) return undefined;
542
- return promptForAccess(
543
- ctx,
544
- `Allow pdf_extract to read local file ${target.path}?`,
545
- `file://${target.path}`,
484
+ const result = await requestHostPermission(ctx, {
485
+ scope: "pdf_extract",
486
+ label: `Allow pdf_extract to read local file ${target.path}?`,
487
+ key: `file://${target.path}`,
546
488
  sessionPermissions,
547
- );
489
+ durable: false,
490
+ });
491
+ if (!result.allowed) {
492
+ return { block: true, reason: result.reason };
493
+ }
494
+ return undefined;
548
495
  }
549
496
 
550
497
  return undefined;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared host-permission prompt for web tools.
3
+ *
4
+ * Replaces the duplicated select/session-map logic in fetch_markdown,
5
+ * pdf_extract, and web_browser. Supports durable grants for http/https origins
6
+ * and session-only prompts for file:// origins.
7
+ */
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { isGranted, addGrant, type PermissionScope, DAY_MS, WEEK_MS, MONTH_MS } from "./permission-store.ts";
11
+
12
+ export type PermissionPromptResult = { allowed: true } | { allowed: false; reason: string };
13
+
14
+ type SessionPermissions = Map<string, "allow" | "deny">;
15
+
16
+ /** Prompt the user for host access; `durable` adds 1-day/1-week/30-day options, otherwise session-only. */
17
+ export async function requestHostPermission(
18
+ ctx: ExtensionContext,
19
+ {
20
+ scope,
21
+ label,
22
+ key,
23
+ sessionPermissions,
24
+ durable,
25
+ }: {
26
+ scope: PermissionScope;
27
+ label: string;
28
+ key: string;
29
+ sessionPermissions: SessionPermissions;
30
+ durable: boolean;
31
+ },
32
+ ): Promise<PermissionPromptResult> {
33
+ // Session deny
34
+ const sessionDecision = sessionPermissions.get(key);
35
+ if (sessionDecision === "deny") {
36
+ return { allowed: false, reason: `Denied by user for ${key}` };
37
+ }
38
+
39
+ // Session allow
40
+ if (sessionDecision === "allow") {
41
+ return { allowed: true };
42
+ }
43
+
44
+ // Durable grant
45
+ if (durable && isGranted(scope, key)) {
46
+ return { allowed: true };
47
+ }
48
+
49
+ // No UI — block
50
+ if (!ctx.hasUI) {
51
+ return { allowed: false, reason: `${scope} to ${key} blocked (no UI for confirmation)` };
52
+ }
53
+
54
+ // Prompt
55
+ const options = durable
56
+ ? ["Allow once", "Allow for this session", "Allow for 1 day", "Allow for 1 week", "Allow for 30 days", "Deny"]
57
+ : ["Allow once", "Allow for this session", "Deny"];
58
+
59
+ const choice = await ctx.ui.select(label, options);
60
+
61
+ if (choice === "Allow for this session") {
62
+ sessionPermissions.set(key, "allow");
63
+ return { allowed: true };
64
+ }
65
+
66
+ if (choice === "Allow once") {
67
+ return { allowed: true };
68
+ }
69
+
70
+ if (choice === "Deny") {
71
+ sessionPermissions.set(key, "deny");
72
+ return { allowed: false, reason: `Denied by user for ${key}` };
73
+ }
74
+
75
+ // Durable duration choices
76
+ let ttlMs: number | undefined;
77
+ if (choice === "Allow for 1 day") ttlMs = DAY_MS;
78
+ else if (choice === "Allow for 1 week") ttlMs = WEEK_MS;
79
+ else if (choice === "Allow for 30 days") ttlMs = MONTH_MS;
80
+
81
+ if (ttlMs !== undefined) {
82
+ const persisted = await addGrant(scope, key, ttlMs);
83
+ if (!persisted) {
84
+ // Persist failure — downgrade to session grant.
85
+ sessionPermissions.set(key, "allow");
86
+ ctx.ui.notify(
87
+ `Could not save durable permission; allowing for this session only.`,
88
+ "warning",
89
+ );
90
+ }
91
+ return { allowed: true };
92
+ }
93
+
94
+ // Fallback (unexpected choice)
95
+ sessionPermissions.set(key, "deny");
96
+ return { allowed: false, reason: `Denied by user for ${key}` };
97
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Durable permission store for web tools.
3
+ *
4
+ * Persists user-approved host grants to a JSON file in the agent config
5
+ * directory. Grants expire after their TTL and are pruned on read/write.
6
+ * Storage is global (not cwd-scoped) so cwd-scoped write tools cannot reach it.
7
+ */
8
+
9
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
+ import { readFileSync } from "node:fs";
11
+ import { mkdir, rename, stat, unlink, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { permissionKey } from "./permissions.ts";
14
+
15
+ export type PermissionScope = "fetch_markdown" | "pdf_extract" | "web_browser";
16
+
17
+ export type Grant = {
18
+ scope: PermissionScope;
19
+ origin: string;
20
+ grantedAt: number;
21
+ expiresAt: number;
22
+ };
23
+
24
+ type StoreFile = {
25
+ version: 1;
26
+ grants: Grant[];
27
+ };
28
+
29
+ export const DAY_MS = 24 * 60 * 60 * 1000;
30
+ export const WEEK_MS = 7 * DAY_MS;
31
+ export const MONTH_MS = 30 * DAY_MS;
32
+
33
+ const MAX_FILE_BYTES = 1024 * 1024; // 1 MB
34
+ const MAX_GRANTS = 500;
35
+ const LOCK_RETRY_MS = 50;
36
+ const LOCK_RETRIES = 20; // ~1 s total
37
+ const LOCK_STALE_MS = 10_000;
38
+
39
+ function storePath(): string {
40
+ return process.env.PI_WEB_TOOLS_PERMISSIONS_FILE ?? join(getAgentDir(), "web-permissions.json");
41
+ }
42
+
43
+ function lockPath(): string {
44
+ return `${storePath()}.lock`;
45
+ }
46
+
47
+ function normalizeOrigin(origin: string): string {
48
+ // Round-trip through permissionKey to enforce consistent format.
49
+ return permissionKey(origin);
50
+ }
51
+
52
+ function isValidScope(scope: string): scope is PermissionScope {
53
+ return scope === "fetch_markdown" || scope === "pdf_extract" || scope === "web_browser";
54
+ }
55
+
56
+ /** Read and validate the store file. Returns empty list on any parse/validation failure. */
57
+ export function listGrants(): Grant[] {
58
+ let raw: string;
59
+ try {
60
+ raw = readFileSync(storePath(), "utf-8");
61
+ } catch {
62
+ return [];
63
+ }
64
+
65
+ if (Buffer.byteLength(raw) > MAX_FILE_BYTES) {
66
+ return [];
67
+ }
68
+
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch {
73
+ return [];
74
+ }
75
+
76
+ if (typeof parsed !== "object" || parsed === null) return [];
77
+ const file = parsed as StoreFile;
78
+ if (file.version !== 1 || !Array.isArray(file.grants)) return [];
79
+
80
+ const now = Date.now();
81
+ const valid: Grant[] = [];
82
+ for (const entry of file.grants) {
83
+ if (typeof entry !== "object" || entry === null) continue;
84
+ const g = entry as Record<string, unknown>;
85
+ if (!isValidScope(g.scope as string)) continue;
86
+ if (typeof g.origin !== "string") continue;
87
+ if (typeof g.grantedAt !== "number" || !Number.isFinite(g.grantedAt)) continue;
88
+ if (typeof g.expiresAt !== "number" || !Number.isFinite(g.expiresAt)) continue;
89
+ if (g.expiresAt <= now) continue; // expired
90
+ if (normalizeOrigin(g.origin as string) !== g.origin) continue; // malformed
91
+ valid.push({
92
+ scope: g.scope as PermissionScope,
93
+ origin: g.origin as string,
94
+ grantedAt: g.grantedAt as number,
95
+ expiresAt: g.expiresAt as number,
96
+ });
97
+ }
98
+
99
+ return valid;
100
+ }
101
+
102
+ /** Check whether a specific scope+origin has an active durable grant. */
103
+ export function isGranted(scope: PermissionScope, origin: string): boolean {
104
+ const key = normalizeOrigin(origin);
105
+ return listGrants().some((g) => g.scope === scope && g.origin === key);
106
+ }
107
+
108
+ /** Prune expired entries from a grant list. */
109
+ function pruneExpired(grants: Grant[]): Grant[] {
110
+ const now = Date.now();
111
+ return grants.filter((g) => g.expiresAt > now);
112
+ }
113
+
114
+ /** Attempt to acquire a lock file. Returns true if acquired. */
115
+ async function acquireLock(): Promise<boolean> {
116
+ const lp = lockPath();
117
+ for (let i = 0; i < LOCK_RETRIES; i++) {
118
+ try {
119
+ // wx fails if the file already exists.
120
+ await writeFile(lp, String(process.pid), { flag: "wx" });
121
+ return true;
122
+ } catch {
123
+ // Check for stale lock.
124
+ try {
125
+ const { mtimeMs } = await stat(lp);
126
+ if (Date.now() - mtimeMs > LOCK_STALE_MS) {
127
+ // Stale lock — take it over.
128
+ await writeFile(lp, String(process.pid), { flag: "w" });
129
+ // Verify the takeover won (another process may have raced us).
130
+ if (readFileSync(lp, "utf-8") !== String(process.pid)) continue;
131
+ return true;
132
+ }
133
+ } catch {
134
+ // Lock file disappeared between check and stat — retry.
135
+ }
136
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
137
+ }
138
+ }
139
+ return false;
140
+ }
141
+
142
+ async function releaseLock(): Promise<void> {
143
+ try {
144
+ await unlink(lockPath());
145
+ } catch {
146
+ // ignore
147
+ }
148
+ }
149
+
150
+ async function writeStore(grants: Grant[]): Promise<void> {
151
+ const dir = join(storePath(), "..");
152
+ await mkdir(dir, { recursive: true });
153
+
154
+ const file: StoreFile = { version: 1, grants };
155
+ const tmp = `${storePath()}.tmp`;
156
+ await writeFile(tmp, JSON.stringify(file, null, "\t"), { mode: 0o600 });
157
+ await rename(tmp, storePath());
158
+ }
159
+
160
+ /** Add a durable grant. Returns false if the lock could not be acquired. */
161
+ export async function addGrant(scope: PermissionScope, origin: string, ttlMs: number): Promise<boolean> {
162
+ const normalized = normalizeOrigin(origin);
163
+ const locked = await acquireLock();
164
+ if (!locked) return false;
165
+
166
+ try {
167
+ const grants = pruneExpired(listGrants());
168
+
169
+ // Remove existing grant for same scope+origin.
170
+ const filtered = grants.filter((g) => !(g.scope === scope && g.origin === normalized));
171
+
172
+ const now = Date.now();
173
+ filtered.push({
174
+ scope,
175
+ origin: normalized,
176
+ grantedAt: now,
177
+ expiresAt: now + Math.min(ttlMs, MONTH_MS),
178
+ });
179
+
180
+ // Cap at MAX_GRANTS.
181
+ const capped = filtered.length > MAX_GRANTS ? filtered.slice(-MAX_GRANTS) : filtered;
182
+ await writeStore(capped);
183
+ return true;
184
+ } finally {
185
+ await releaseLock();
186
+ }
187
+ }
188
+
189
+ /** Remove a durable grant. Returns false if the lock could not be acquired. */
190
+ export async function removeGrant(scope: PermissionScope, origin: string): Promise<boolean> {
191
+ const normalized = normalizeOrigin(origin);
192
+ const locked = await acquireLock();
193
+ if (!locked) return false;
194
+
195
+ try {
196
+ const grants = pruneExpired(listGrants());
197
+ const filtered = grants.filter((g) => !(g.scope === scope && g.origin === normalized));
198
+ await writeStore(filtered);
199
+ return true;
200
+ } finally {
201
+ await releaseLock();
202
+ }
203
+ }
@@ -115,6 +115,18 @@ const { HOSTNAME_ONLY, PATH_PREFIXES } = (() => {
115
115
  return { HOSTNAME_ONLY: hosts, PATH_PREFIXES: paths };
116
116
  })();
117
117
 
118
+ export function permissionKey(url: string): string {
119
+ try {
120
+ const parsed = new URL(url);
121
+ if (parsed.protocol === "file:") {
122
+ return `file://${parsed.pathname}`;
123
+ }
124
+ return `${parsed.protocol}//${parsed.host}`;
125
+ } catch {
126
+ return url;
127
+ }
128
+ }
129
+
118
130
  export function isPreapprovedHost(hostname: string, pathname: string): boolean {
119
131
  if (HOSTNAME_ONLY.has(hostname)) return true;
120
132
  const prefixes = PATH_PREFIXES.get(hostname);
@@ -100,12 +100,6 @@ export type ScreenshotResult = {
100
100
  fullPage: boolean;
101
101
  };
102
102
 
103
- /**
104
- * Async mutex that serializes browser operations.
105
- *
106
- * NOTE: `close()` and `forceClose()` bypass this mutex so shutdown is
107
- * never blocked by a queued or in-flight operation.
108
- */
109
103
  export class AsyncMutex {
110
104
  private chain: Promise<void> = Promise.resolve();
111
105
 
@@ -714,15 +708,13 @@ export class BrowserManager {
714
708
  let timerId: ReturnType<typeof setTimeout> | undefined;
715
709
  const timer =
716
710
  timeoutMs > 0
717
- ? Promise.resolve().then(
718
- () =>
719
- new Promise<never>((_, reject) => {
720
- timerId = setTimeout(
721
- () => reject(new Error("Close timed out")),
722
- timeoutMs,
723
- );
724
- }),
725
- )
711
+ ? new Promise<never>((_, reject) => {
712
+ timerId = setTimeout(
713
+ () => reject(new Error("Close timed out")),
714
+ timeoutMs,
715
+ );
716
+ if (typeof timerId.unref === "function") timerId.unref();
717
+ })
726
718
  : Promise.resolve();
727
719
 
728
720
  try {
@@ -814,7 +806,8 @@ export class BrowserManager {
814
806
  const diagnostics = await this.selectorDiagnostics(page, selector, frame);
815
807
  const snippet = await this.pageSnippet(page);
816
808
  const frameNote = frame ? ` within frame "${frame}"` : "";
817
- return `No element matches selector "${selector}"${frameNote}. ${diagnostics}\nPage snippet:\n<untrusted-content url="${page.url()}">\n${snippet.replaceAll("</untrusted-content", "")}\n</untrusted-content>`;
809
+ const safeUrl = page.url().replaceAll('"', "%22");
810
+ return `No element matches selector "${selector}"${frameNote}. ${diagnostics}\nPage snippet:\n<untrusted-content url="${safeUrl}">\n${snippet.replaceAll("</untrusted-content", "")}\n</untrusted-content>`;
818
811
  }
819
812
 
820
813
  private async ensureLaunched(): Promise<void> {
@@ -910,12 +903,6 @@ export class BrowserManager {
910
903
  });
911
904
  }
912
905
 
913
- /**
914
- * Registering a `dialog` listener switches Playwright from auto-dismiss
915
- * to "must be resolved" — an unhandled dialog freezes all further
916
- * actions on the page, so this unconditionally accepts or dismisses
917
- * based on the configured session behavior.
918
- */
919
906
  private attachDialogListener(page: Page): void {
920
907
  page.on("dialog", (dialog) => {
921
908
  const entry: DialogEntry = {
@@ -939,22 +926,22 @@ export class BrowserManager {
939
926
 
940
927
  private pushConsole(entry: ConsoleEntry): void {
941
928
  this.consoleBuffer.push(entry);
942
- if (this.consoleBuffer.length > MAX_BUFFER_ENTRIES) {
943
- this.consoleBuffer.shift();
929
+ if (this.consoleBuffer.length >= MAX_BUFFER_ENTRIES) {
930
+ this.consoleBuffer = this.consoleBuffer.slice(MAX_BUFFER_ENTRIES >> 1);
944
931
  }
945
932
  }
946
933
 
947
934
  private pushNetwork(entry: NetworkEntry): void {
948
935
  this.networkBuffer.push(entry);
949
- if (this.networkBuffer.length > MAX_BUFFER_ENTRIES) {
950
- this.networkBuffer.shift();
936
+ if (this.networkBuffer.length >= MAX_BUFFER_ENTRIES) {
937
+ this.networkBuffer = this.networkBuffer.slice(MAX_BUFFER_ENTRIES >> 1);
951
938
  }
952
939
  }
953
940
 
954
941
  private pushDialog(entry: DialogEntry): void {
955
942
  this.dialogBuffer.push(entry);
956
- if (this.dialogBuffer.length > MAX_BUFFER_ENTRIES) {
957
- this.dialogBuffer.shift();
943
+ if (this.dialogBuffer.length >= MAX_BUFFER_ENTRIES) {
944
+ this.dialogBuffer = this.dialogBuffer.slice(MAX_BUFFER_ENTRIES >> 1);
958
945
  }
959
946
  }
960
947
 
@@ -0,0 +1,52 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const BROWSER_FEED_TYPE = "pi-topping-web-tools/browser";
4
+
5
+ export interface BrowserFeedPayload {
6
+ label: string;
7
+ open: 0 | 1;
8
+ }
9
+
10
+ let extension: ExtensionAPI | undefined;
11
+ let latestPayload: BrowserFeedPayload | undefined;
12
+ let emittedPayload: BrowserFeedPayload | undefined;
13
+ let publishAfterFirstTurn = false;
14
+
15
+ function samePayload(left: BrowserFeedPayload | undefined, right: BrowserFeedPayload): boolean {
16
+ return left?.label === right.label && left.open === right.open;
17
+ }
18
+
19
+ function emitBrowserState(force = false): void {
20
+ if (!extension || !latestPayload || (!force && samePayload(emittedPayload, latestPayload))) return;
21
+ try {
22
+ extension.appendEntry(BROWSER_FEED_TYPE, latestPayload);
23
+ emittedPayload = latestPayload;
24
+ } catch {
25
+ // Feed publication is best effort; leave emitted state unchanged for retry.
26
+ }
27
+ }
28
+
29
+ export function registerBrowserFeeds(pi: ExtensionAPI): void {
30
+ extension = pi;
31
+ pi.on("session_start", () => {
32
+ emittedPayload = undefined;
33
+ publishAfterFirstTurn = true;
34
+ });
35
+ pi.on("turn_end", () => {
36
+ if (!publishAfterFirstTurn) return;
37
+ publishAfterFirstTurn = false;
38
+ emitBrowserState(true);
39
+ });
40
+ }
41
+
42
+ export function publishBrowserState(label: string, open: boolean | number): void {
43
+ latestPayload = { label, open: open ? 1 : 0 };
44
+ emitBrowserState();
45
+ }
46
+
47
+ export function resetBrowserFeedState(): void {
48
+ extension = undefined;
49
+ latestPayload = undefined;
50
+ emittedPayload = undefined;
51
+ publishAfterFirstTurn = false;
52
+ }