pi-agent-browser-native 0.2.2 → 0.2.3

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 - 2026-04-13
4
+
5
+ ### Fixed
6
+ - direct headless local Chrome launches to `chatgpt.com` and `chat.openai.com` now inject a normal Chrome user agent when the caller did not explicitly choose one, keeping authenticated ChatGPT/OpenAI browsing working without forcing `--headed` or `--auto-connect`
7
+ - profiled `open` / `goto` / `navigate` calls now best-effort switch back to the page that was just opened when restored profile tabs steal focus during launch, reducing confusing cross-tab drift in profile-backed sessions
8
+ - command parsing now treats additional value-taking global flags like `--user-agent`, `--args`, `--allowed-domains`, `--action-policy`, and related launch options as launch metadata instead of accidentally parsing their values as subcommands
9
+ - README, requirements, architecture notes, and tool-contract docs now describe the new headless ChatGPT/OpenAI compatibility behavior and the profiled-tab focus recovery path
10
+
3
11
  ## 0.2.2 - 2026-04-12
4
12
 
5
13
  ### Fixed
package/README.md CHANGED
@@ -178,6 +178,8 @@ Validated workflow examples:
178
178
  - click a link and confirm the destination title
179
179
  - use an explicit `--session` across multiple tool calls
180
180
  - use an explicit `--profile` and verify persisted browser storage across restarts
181
+ - open `chatgpt.com` headlessly with `--profile Default` without forcing `--headed` or `--auto-connect`
182
+ - verify `/reload` and full restart + `/resume` keep following the same implicit managed browser session
181
183
  - run `batch` with JSON via `stdin`
182
184
  - run `eval --stdin`
183
185
  - take a screenshot with inline attachment support
@@ -188,9 +190,12 @@ Inspection commands like `agent_browser --help` and `--version` are always suppo
188
190
  Current cautions:
189
191
  - passing `--profile` is an explicit upstream choice; this extension does not add its own profile-cloning or isolation layer
190
192
  - startup-scoped flags like `--profile`, `--session-name`, and `--cdp` are for the first command that launches a session; if the implicit session is already active, retry that call with `sessionMode: "fresh"` or provide an explicit `--session ...` for the new launch
191
- - implicit `piab-*` sessions are extension-managed convenience sessions; they are best-effort closed on `pi` shutdown, get an idle timeout to reduce stale background daemons, clean up private temp spill artifacts on shutdown, and are reconstructed from persisted tool details on resume/reload so later default calls keep following the active managed browser
193
+ - implicit `piab-*` sessions are extension-managed convenience sessions; they stay alive across `pi` shutdown/reload so later default calls can keep following the active managed browser on `/reload` or `/resume`, rely on the configured idle timeout to reduce stale background daemons, store persisted-session large snapshot spill files under a private session-scoped artifact directory with a bounded per-session budget so `details.fullOutputPath` survives reload/resume without unbounded growth, and still clean up process-private temp spill artifacts on shutdown
192
194
  - `sessionMode: "fresh"` without an explicit `--session` rotates that extension-managed session to the new browser so later auto calls keep using it
195
+ - for direct headless local Chrome launches to `chatgpt.com` and `chat.openai.com`, the extension injects a normal Chrome user agent when the caller did not explicitly provide `--user-agent`; this keeps the default headless workflow usable without forcing `--headed` or `--auto-connect`
196
+ - after profiled `open` calls, the extension best-effort re-selects the tab that matches the returned page URL when restored profile tabs steal focus during launch
193
197
  - explicit caller-provided `--session` values are treated as user-managed and are not auto-closed by the extension
198
+ - explicit caller-provided `--user-agent` values win over the ChatGPT/OpenAI compatibility workaround
194
199
  - tool progress/details redact sensitive invocation values such as `--headers`, proxy credentials, and auth-bearing URL parameters before echoing them back into Pi
195
200
 
196
201
  ### Switching from public browsing to a fresh profile/debug launch
@@ -78,17 +78,18 @@ V1 ownership rule:
78
78
  - implicit auto-generated sessions are extension-managed convenience sessions
79
79
  - unnamed `sessionMode: "fresh"` launches rotate that extension-managed session to a new upstream browser
80
80
  - explicit/user-managed sessions are not auto-managed by default
81
- - extension-managed sessions should be reusable during an active `pi` session, but should still be cleaned up predictably
81
+ - extension-managed sessions should be reusable during an active `pi` session and across `/reload` / `/resume`, while still being cleaned up predictably
82
82
 
83
83
  Practical policy:
84
- - on normal `pi` shutdown, best-effort close the current extension-managed session
85
- - also set an idle timeout on extension-managed sessions so abandoned daemons self-clean after inactivity
86
- - clean up private temp spill artifacts owned by the extension-managed session on shutdown
84
+ - preserve the current extension-managed session across normal `pi` shutdown/reload so persisted sessions can keep following the live browser after `/reload` or `/resume`
85
+ - set an idle timeout on extension-managed sessions so abandoned daemons self-clean after inactivity
86
+ - clean up process-private temp spill artifacts on shutdown, but keep persisted-session snapshot spill files in a private session-scoped artifact directory with a bounded per-session budget so `details.fullOutputPath` stays usable after reload/resume without unbounded growth
87
87
  - reconstruct the current extension-managed session from persisted tool details on resume/reload so later default calls keep following the active managed browser
88
88
  - if an unnamed fresh launch replaces an active extension-managed session, best-effort close the old managed session after the switch succeeds
89
89
  - leave explicit caller-provided `--session` choices alone unless the caller closes them explicitly
90
+ - after profiled `open` / `goto` / `navigate` calls, verify the active tab still matches the returned page URL and best-effort switch back when restored profile tabs steal focus
90
91
 
91
- This is primarily about ownership clarity and avoiding surprise, not adding a heavy safety wrapper. If the extension invented the session, the extension should clean it up. If the caller explicitly chose the upstream session model, the extension should stay out of the way.
92
+ This is primarily about ownership clarity and avoiding surprise, not adding a heavy safety wrapper. If the extension invented the session, the extension should own its lifecycle without breaking reload/resume semantics. If the caller explicitly chose the upstream session model, the extension should stay out of the way.
92
93
 
93
94
  ### Launch flags
94
95
 
@@ -97,6 +98,8 @@ The extension should surface that clearly and avoid hidden restart behavior in v
97
98
 
98
99
  That means explicit startup-scoping flags like `--profile`, `--session-name`, and `--cdp` should remain explicit upstream choices instead of being wrapped in extra hidden restart or cloning logic.
99
100
 
101
+ The wrapper may still apply narrow compatibility normalizations when observed behavior justifies them and the result remains thin, local, and opt-out. For example, if a specific site starts rejecting the default local headless Chrome user agent while the same flow works with a normal Chrome UA, the extension may inject a domain-specific fallback UA only when the caller did not already choose `--user-agent`, `--headed`, `--cdp`, `--auto-connect`, or a provider-backed launch.
102
+
100
103
  If the implicit session is already active and one of those startup-scoped flags appears again while `sessionMode` is still `"auto"`, the extension should fail clearly instead of silently sending a command shape that upstream would ignore.
101
104
 
102
105
  That failure should include a structured recovery hint pointing to `sessionMode: "fresh"` as the first-line fix, while still allowing an explicit `--session` when the caller wants to name the new upstream session.
package/docs/RELEASE.md CHANGED
@@ -57,13 +57,21 @@ Before publishing, also validate the explicit local-checkout path:
57
57
  2. Launch `pi --no-extensions -e .` from this repository root.
58
58
  3. Confirm the checkout extension loads from `extensions/agent-browser/index.ts`.
59
59
  4. Run a smoke prompt that exercises `agent_browser`.
60
+ 5. Validate managed-session continuity with both `/reload` and a full restart + `/resume`.
60
61
 
61
- Example prompt:
62
+ Example smoke prompt:
62
63
 
63
64
  ```text
64
65
  Use the agent_browser tool to open https://react.dev and then take an interactive snapshot.
65
66
  ```
66
67
 
68
+ Recommended lifecycle follow-up:
69
+
70
+ 1. Open a page with the implicit managed session and confirm the title.
71
+ 2. Run `/reload`, then ask for `snapshot -i` and confirm the same page is still active.
72
+ 3. Exit `pi`, relaunch it against the same session file or use `/resume`, then ask for `snapshot -i` again and confirm the same page is still active.
73
+ 4. Open a large page that compacts its snapshot output and confirm `details.fullOutputPath` still exists after the restart/resume flow.
74
+
67
75
  ## Post-publish install validation
68
76
 
69
77
  After publishing a release, validate the package-first install path explicitly:
@@ -73,7 +81,7 @@ pi install npm:pi-agent-browser-native@<version>
73
81
  pi -e npm:pi-agent-browser-native@<version>
74
82
  ```
75
83
 
76
- Then confirm `pi` exposes the native `agent_browser` tool and that a basic `open` + `snapshot -i` flow works.
84
+ Then confirm `pi` exposes the native `agent_browser` tool, that a basic `open` + `snapshot -i` flow works, and that `/reload` plus restart/`/resume` keep following the same implicit managed browser session.
77
85
 
78
86
  ## Release notes checklist
79
87
 
@@ -83,4 +91,5 @@ Before publishing:
83
91
  - confirm README install guidance still leads with the package-first flow
84
92
  - confirm the explicit local-checkout instructions still work for pre-release validation
85
93
  - rerun `npm run verify:release`
94
+ - manually exercise `/reload` and full restart + `/resume` continuity in local checkout validation
86
95
  - publish only after the tarball contents match expectations
@@ -71,7 +71,8 @@ Define the product requirements and constraints for `pi-agent-browser-native`.
71
71
 
72
72
  - The primary confidence path is a real `pi` session driven in `tmux`.
73
73
  - For local checkout validation, launch `pi --no-extensions -e .` from the repository root so only the checkout copy loads.
74
- - Prefer full `pi` restart over `/reload` when validating extension changes.
74
+ - Validate both `/reload` and a full `pi` restart with `/resume` when changes touch managed-session continuity, reload behavior, or persisted artifact paths.
75
+ - Prefer full `pi` restart over `/reload` when validating extension changes beyond a quick reload smoke check.
75
76
  - Use `/resume` when needed after restart.
76
77
  - Keep testing broader than a single smoke site like `example.com`.
77
78
  - Maintain a concrete release/package verification workflow in `docs/RELEASE.md` and matching repository scripts.
@@ -84,6 +85,7 @@ The design should comfortably support workflows such as:
84
85
  - web research
85
86
  - using browser UIs for other LLMs such as ChatGPT, Grok, Gemini, and Claude
86
87
  - isolated authenticated browser sessions
88
+ - headless authenticated ChatGPT/OpenAI browsing without forcing `--headed` or `--auto-connect`
87
89
  - upstream profile/debug workflows without adding a local profile-cloning layer in this package
88
90
 
89
91
  ## Implications for the implementation
@@ -94,6 +96,8 @@ The design should comfortably support workflows such as:
94
96
  - User-facing docs belong in `README.md` and the canonical published files under `docs/`.
95
97
  - Agent workflow and deeper testing procedures can stay in `AGENTS.md`, but published docs must not depend on that file being present.
96
98
  - Keep mitigations for legacy-skill coexistence simple; do not add extra moving parts unless observed behavior justifies them.
99
+ - Prefer narrow, evidence-backed compatibility mitigations over broad stealth layers when a specific upstream site starts rejecting the default headless launch fingerprint.
100
+ - Preserve the page that a profiled `open` just navigated to; if restored profile tabs steal focus during launch, the wrapper should best-effort switch back to the returned page URL before handing control back to the agent.
97
101
 
98
102
  ## Open design questions
99
103
 
@@ -141,13 +141,13 @@ Additional structured fields can appear when relevant:
141
141
  - `batchFailure` and `batchSteps` for `batch` rendering, including mixed-success runs
142
142
  - `navigationSummary` for navigation-style commands like `click`, `back`, `forward`, and `reload`
143
143
  - `imagePath` / `imagePaths` for screenshots and batched image outputs
144
- - `fullOutputPath` / `fullOutputPaths` when large snapshot output is compacted and spilled to a private temp file
144
+ - `fullOutputPath` / `fullOutputPaths` when large snapshot output is compacted and spilled to a private file; persisted sessions keep that path under a private session-scoped artifact directory with a bounded per-session budget so it survives reload/resume without unbounded growth
145
145
  - `sessionRecoveryHint` when startup-scoped flags need `sessionMode: "fresh"`
146
146
  - `inspection: true` plus `stdout` for successful plain-text inspection commands like `--help` and `--version`
147
147
 
148
148
  When the tool echoes `args` or `effectiveArgs` back into Pi, sensitive values such as `--headers`, proxy credentials, and auth-bearing URL parameters should be redacted first.
149
149
 
150
- For oversized snapshots, details should switch to a compact metadata object and include `fullOutputPath` pointing at a private temp JSON spill file with the full upstream snapshot payload.
150
+ For oversized snapshots, details should switch to a compact metadata object and include `fullOutputPath` pointing at a private JSON spill file with the full upstream snapshot payload. Persisted sessions should keep that spill file under a private session-scoped artifact directory so the path remains usable after reload/restart, with the oldest persisted spill files evicted as needed to stay within the per-session budget.
151
151
 
152
152
  ## High-value result rendering
153
153
 
@@ -156,6 +156,7 @@ For oversized snapshots, details should switch to a compact metadata object and
156
156
  Worth doing in v1:
157
157
  - screenshots → inline image attachment
158
158
  - snapshots → origin + ref count + main-content-first compact preview, with the raw snapshot spill path kept in `details.fullOutputPath` when the inline result would otherwise be too large
159
+ - extraction-style commands like `eval --stdin` and `get title` → scalar-first text with lightweight origin context when available
159
160
  - navigation actions like `click`, `back`, `forward`, and `reload` → lightweight post-action title/url summary when available
160
161
  - tab lists → compact summary/table
161
162
  - stream status → enabled/connected/port summary
@@ -173,16 +174,18 @@ If `agent-browser` is not on `PATH`, fail with a message that:
173
174
  - derive the base implicit session name from the official `pi` session id plus a cwd hash so same-named checkouts do not collide
174
175
  - respect explicit upstream `--session` with minimal interference
175
176
  - treat the extension-managed session as convenience state owned by the wrapper
176
- - on normal `pi` shutdown, best-effort close the current extension-managed session
177
+ - preserve the current extension-managed session across normal `pi` shutdown/reload so persisted sessions can keep following the live browser on `/reload` or `/resume`
177
178
  - set an idle timeout on extension-managed sessions so abandoned daemons eventually self-clean
178
- - clean up private temp spill artifacts owned by the extension-managed session on shutdown
179
+ - clean up process-private temp spill artifacts on shutdown, while keeping persisted-session snapshot spill files in a private session-scoped artifact directory so `details.fullOutputPath` survives reload/restart and the oldest spill files are evicted if the per-session artifact budget is exceeded
179
180
  - reconstruct the current extension-managed session from persisted tool details on resume/reload so later default calls keep following the active managed browser
180
181
  - when an unnamed `sessionMode: "fresh"` launch succeeds, make it the new extension-managed session so later default calls keep using it
181
182
  - if that unnamed fresh launch replaced an already-active managed session, best-effort close the old managed session after the switch succeeds
182
183
  - treat explicit caller-provided `--session` choices as user-managed
183
184
  - pass explicit `--profile` straight through to upstream `agent-browser`; no profile-cloning or isolation layer is added in v1
185
+ - after profiled `open` / `goto` / `navigate`, if upstream leaves a restored profile tab active instead of the page that was just opened, best-effort switch back to the tab whose URL matches the returned open result before returning control to the agent
184
186
  - treat successful plain-text inspection commands like `--help` and `--version` as stateless: do not inject the implicit managed session and do not let those calls claim the managed-session slot
185
187
  - if startup-scoped flags like `--profile`, `--session-name`, or `--cdp` are supplied after the implicit session is already active while `sessionMode` is `"auto"`, return a validation error with a structured recovery hint that recommends `sessionMode: "fresh"`
188
+ - for direct headless local Chrome launches to `chatgpt.com` / `chat.openai.com`, allow a narrow compatibility fallback that injects a normal Chrome `--user-agent` only when the caller did not explicitly provide one and did not choose `--headed`, `--cdp`, `--auto-connect`, or a provider-backed launch
186
189
 
187
190
  ## Non-goals
188
191
 
@@ -16,6 +16,7 @@ import { buildToolPresentation, getAgentBrowserErrorText, parseAgentBrowserEnvel
16
16
  import {
17
17
  buildExecutionPlan,
18
18
  buildPromptPolicy,
19
+ chooseOpenResultTabCorrection,
19
20
  createEphemeralSessionSeed,
20
21
  createFreshSessionName,
21
22
  createImplicitSessionName,
@@ -30,8 +31,10 @@ import {
30
31
  resolveManagedSessionState,
31
32
  shouldAppendBrowserSystemPrompt,
32
33
  validateToolArgs,
34
+ type CompatibilityWorkaround,
35
+ type OpenResultTabCorrection,
33
36
  } from "./lib/runtime.js";
34
- import { cleanupSecureTempArtifacts } from "./lib/temp.js";
37
+ import { cleanupSecureTempArtifacts, type PersistentSessionArtifactStore } from "./lib/temp.js";
35
38
 
36
39
  const DEFAULT_SESSION_MODE = "auto" as const;
37
40
 
@@ -96,15 +99,184 @@ function buildInvocationPreview(effectiveArgs: string[]): string {
96
99
  return preview.length > 120 ? `${preview.slice(0, 117)}...` : preview;
97
100
  }
98
101
 
99
- const AGENT_BROWSER_BASH_PREFIX = String.raw`(?:env(?:\s+[A-Za-z_][A-Za-z0-9_]*=[^\s;&|]+)*\s+)?(?:(?:npx|bunx)(?:\s+-[^\s;&|]+|\s+--[^\s;&|]+(?:=[^\s;&|]+)?)*\s+|(?:pnpm|yarn)\s+dlx(?:\s+-[^\s;&|]+|\s+--[^\s;&|]+(?:=[^\s;&|]+)?)*\s+)?`;
100
- const AGENT_BROWSER_BASH_EXECUTABLE = String.raw`(?:[.~]|\.\.?|\/)?(?:[^\s;&|]+\/)?agent-browser`;
101
- const DIRECT_AGENT_BROWSER_BASH_PATTERN = new RegExp(
102
- String.raw`(^|[\s;&|])${AGENT_BROWSER_BASH_PREFIX}${AGENT_BROWSER_BASH_EXECUTABLE}(?=\s|$)`,
103
- );
104
- const HARMLESS_AGENT_BROWSER_INSPECTION_PATTERN = /(command\s+-v|which|type\s+-P)\s+agent-browser\b/;
102
+ const DIRECT_AGENT_BROWSER_EXECUTABLE_PATTERN = /^(?:[.~]|\.\.?|\/)?(?:[^\s;&|]+\/)?agent-browser$/;
103
+ const HARMLESS_AGENT_BROWSER_INSPECTION_PATTERN = /^\s*(?:command\s+-v|which|type\s+-P)\s+agent-browser\s*$/;
105
104
 
105
+ type ShellQuoteState = 'double' | 'single' | undefined;
106
+
107
+ function isShellAssignmentToken(token: string): boolean {
108
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
109
+ }
110
+
111
+ function stripOuterQuotes(token: string): string {
112
+ if (token.length >= 2 && ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'")))) {
113
+ return token.slice(1, -1);
114
+ }
115
+ return token;
116
+ }
117
+
118
+ function segmentLaunchesAgentBrowser(tokens: string[]): boolean {
119
+ let index = 0;
120
+ while (index < tokens.length && isShellAssignmentToken(tokens[index])) {
121
+ index += 1;
122
+ }
123
+ if (index >= tokens.length) {
124
+ return false;
125
+ }
126
+
127
+ let executableToken = tokens[index];
128
+ if (executableToken === 'env') {
129
+ index += 1;
130
+ while (index < tokens.length && isShellAssignmentToken(tokens[index])) {
131
+ index += 1;
132
+ }
133
+ executableToken = tokens[index] ?? '';
134
+ }
135
+ if (executableToken === 'npx' || executableToken === 'bunx') {
136
+ index += 1;
137
+ while (index < tokens.length && tokens[index].startsWith('-')) {
138
+ index += 1;
139
+ }
140
+ executableToken = tokens[index] ?? '';
141
+ }
142
+ if (executableToken === 'pnpm' || executableToken === 'yarn') {
143
+ index += 1;
144
+ if (tokens[index] !== 'dlx') {
145
+ return false;
146
+ }
147
+ index += 1;
148
+ while (index < tokens.length && tokens[index].startsWith('-')) {
149
+ index += 1;
150
+ }
151
+ executableToken = tokens[index] ?? '';
152
+ }
153
+ return DIRECT_AGENT_BROWSER_EXECUTABLE_PATTERN.test(executableToken);
154
+ }
155
+
156
+ // Best-effort detection for common direct launches only. This is an ergonomics guard,
157
+ // not a general-purpose bash parser or security boundary.
106
158
  function looksLikeDirectAgentBrowserBash(command: string): boolean {
107
- return DIRECT_AGENT_BROWSER_BASH_PATTERN.test(command);
159
+ let currentToken = '';
160
+ let quoteState: ShellQuoteState;
161
+ let awaitingHeredocDelimiter: { stripTabs: boolean } | undefined;
162
+ let pendingHeredoc: { delimiter: string; stripTabs: boolean } | undefined;
163
+ let pendingHeredocLine = '';
164
+ let segmentTokens: string[] = [];
165
+
166
+ const acceptToken = (token: string) => {
167
+ if (token.length === 0) {
168
+ return;
169
+ }
170
+ if (awaitingHeredocDelimiter) {
171
+ pendingHeredoc = {
172
+ delimiter: stripOuterQuotes(token),
173
+ stripTabs: awaitingHeredocDelimiter.stripTabs,
174
+ };
175
+ awaitingHeredocDelimiter = undefined;
176
+ return;
177
+ }
178
+ segmentTokens.push(token);
179
+ };
180
+ const flushToken = () => {
181
+ acceptToken(currentToken);
182
+ currentToken = '';
183
+ };
184
+ const flushSegment = () => {
185
+ const launchesAgentBrowser = segmentLaunchesAgentBrowser(segmentTokens);
186
+ segmentTokens = [];
187
+ return launchesAgentBrowser;
188
+ };
189
+
190
+ for (let index = 0; index < command.length; index += 1) {
191
+ const char = command[index];
192
+ if (pendingHeredoc) {
193
+ if (char === '\n') {
194
+ const candidate = pendingHeredoc.stripTabs ? pendingHeredocLine.replace(/^\t+/, '') : pendingHeredocLine;
195
+ if (candidate === pendingHeredoc.delimiter) {
196
+ pendingHeredoc = undefined;
197
+ }
198
+ pendingHeredocLine = '';
199
+ continue;
200
+ }
201
+ pendingHeredocLine += char;
202
+ continue;
203
+ }
204
+
205
+ if (quoteState === 'single') {
206
+ currentToken += char;
207
+ if (char === "'") {
208
+ quoteState = undefined;
209
+ }
210
+ continue;
211
+ }
212
+ if (quoteState === 'double') {
213
+ currentToken += char;
214
+ if (char === '\\' && index + 1 < command.length) {
215
+ currentToken += command[index + 1];
216
+ index += 1;
217
+ continue;
218
+ }
219
+ if (char === '"') {
220
+ quoteState = undefined;
221
+ }
222
+ continue;
223
+ }
224
+ if (char === "'" || char === '"') {
225
+ currentToken += char;
226
+ quoteState = char === "'" ? 'single' : 'double';
227
+ continue;
228
+ }
229
+ if (char === '\\' && index + 1 < command.length) {
230
+ currentToken += char;
231
+ currentToken += command[index + 1];
232
+ index += 1;
233
+ continue;
234
+ }
235
+ if (char === '\n') {
236
+ flushToken();
237
+ if (flushSegment()) {
238
+ return true;
239
+ }
240
+ continue;
241
+ }
242
+ if (/\s/.test(char)) {
243
+ flushToken();
244
+ continue;
245
+ }
246
+ const threeCharOperator = command.slice(index, index + 3);
247
+ if (threeCharOperator === '<<-') {
248
+ flushToken();
249
+ awaitingHeredocDelimiter = { stripTabs: true };
250
+ index += 2;
251
+ continue;
252
+ }
253
+ const twoCharOperator = command.slice(index, index + 2);
254
+ if (twoCharOperator === '<<') {
255
+ flushToken();
256
+ awaitingHeredocDelimiter = { stripTabs: false };
257
+ index += 1;
258
+ continue;
259
+ }
260
+ if (twoCharOperator === '&&' || twoCharOperator === '||') {
261
+ flushToken();
262
+ if (flushSegment()) {
263
+ return true;
264
+ }
265
+ index += 1;
266
+ continue;
267
+ }
268
+ if (char === '|' || char === ';' || char === '&') {
269
+ flushToken();
270
+ if (flushSegment()) {
271
+ return true;
272
+ }
273
+ continue;
274
+ }
275
+ currentToken += char;
276
+ }
277
+
278
+ flushToken();
279
+ return flushSegment();
108
280
  }
109
281
 
110
282
  function isHarmlessAgentBrowserInspectionCommand(command: string): boolean {
@@ -142,41 +314,53 @@ function extractStringResultField(data: unknown, fieldName: "title" | "url"): st
142
314
  return text.length > 0 ? text : undefined;
143
315
  }
144
316
 
145
- async function collectNavigationSummary(options: {
317
+ async function runSessionCommandData(options: {
318
+ args: string[];
146
319
  cwd: string;
147
320
  sessionName?: string;
148
321
  signal?: AbortSignal;
149
- }): Promise<NavigationSummary | undefined> {
150
- const { cwd, sessionName, signal } = options;
322
+ }): Promise<unknown | undefined> {
323
+ const { args, cwd, sessionName, signal } = options;
151
324
  if (!sessionName) return undefined;
152
325
 
153
- const readField = async (fieldName: "title" | "url"): Promise<string | undefined> => {
154
- const processResult = await runAgentBrowserProcess({
155
- args: ["--json", "--session", sessionName, "get", fieldName],
156
- cwd,
157
- signal,
158
- });
159
- if (processResult.aborted || processResult.spawnError || processResult.exitCode !== 0) {
326
+ const processResult = await runAgentBrowserProcess({
327
+ args: ["--json", "--session", sessionName, ...args],
328
+ cwd,
329
+ signal,
330
+ });
331
+ if (processResult.aborted || processResult.spawnError || processResult.exitCode !== 0) {
332
+ return undefined;
333
+ }
334
+ const parsed = await parseAgentBrowserEnvelope({
335
+ stdout: processResult.stdout,
336
+ stdoutPath: processResult.stdoutSpillPath,
337
+ });
338
+ try {
339
+ if (parsed.parseError || parsed.envelope?.success === false) {
160
340
  return undefined;
161
341
  }
162
- const parsed = await parseAgentBrowserEnvelope({
163
- stdout: processResult.stdout,
164
- stdoutPath: processResult.stdoutSpillPath,
165
- });
166
- try {
167
- if (parsed.parseError || parsed.envelope?.success === false) {
168
- return undefined;
169
- }
170
- return extractStringResultField(parsed.envelope?.data, fieldName);
171
- } finally {
172
- if (processResult.stdoutSpillPath) {
173
- await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
174
- }
342
+ return parsed.envelope?.data;
343
+ } finally {
344
+ if (processResult.stdoutSpillPath) {
345
+ await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
175
346
  }
176
- };
347
+ }
348
+ }
177
349
 
178
- const title = await readField("title");
179
- const url = await readField("url");
350
+ async function collectNavigationSummary(options: {
351
+ cwd: string;
352
+ sessionName?: string;
353
+ signal?: AbortSignal;
354
+ }): Promise<NavigationSummary | undefined> {
355
+ const { cwd, sessionName, signal } = options;
356
+ const title = extractStringResultField(
357
+ await runSessionCommandData({ args: ["get", "title"], cwd, sessionName, signal }),
358
+ "title",
359
+ );
360
+ const url = extractStringResultField(
361
+ await runSessionCommandData({ args: ["get", "url"], cwd, sessionName, signal }),
362
+ "url",
363
+ );
180
364
  if (!title && !url) return undefined;
181
365
  return { title, url };
182
366
  }
@@ -188,6 +372,43 @@ function mergeNavigationSummaryIntoData(data: unknown, navigationSummary: Naviga
188
372
  return { navigationSummary, result: data };
189
373
  }
190
374
 
375
+ async function collectOpenResultTabCorrection(options: {
376
+ cwd: string;
377
+ sessionName?: string;
378
+ signal?: AbortSignal;
379
+ targetTitle?: string;
380
+ targetUrl?: string;
381
+ }): Promise<OpenResultTabCorrection | undefined> {
382
+ const { cwd, sessionName, signal, targetTitle, targetUrl } = options;
383
+ const tabData = await runSessionCommandData({ args: ["tab", "list"], cwd, sessionName, signal });
384
+ if (!isRecord(tabData) || !Array.isArray(tabData.tabs)) {
385
+ return undefined;
386
+ }
387
+ const tabs = tabData.tabs.filter(isRecord).map((tab) => ({
388
+ active: tab.active === true,
389
+ index: typeof tab.index === "number" ? tab.index : undefined,
390
+ title: typeof tab.title === "string" ? tab.title : undefined,
391
+ url: typeof tab.url === "string" ? tab.url : undefined,
392
+ }));
393
+ return chooseOpenResultTabCorrection({ tabs, targetTitle, targetUrl });
394
+ }
395
+
396
+ async function applyOpenResultTabCorrection(options: {
397
+ correction: OpenResultTabCorrection;
398
+ cwd: string;
399
+ sessionName?: string;
400
+ signal?: AbortSignal;
401
+ }): Promise<OpenResultTabCorrection | undefined> {
402
+ const { correction, cwd, sessionName, signal } = options;
403
+ const result = await runSessionCommandData({
404
+ args: ["tab", String(correction.selectedIndex)],
405
+ cwd,
406
+ sessionName,
407
+ signal,
408
+ });
409
+ return result === undefined ? undefined : correction;
410
+ }
411
+
191
412
  function buildSharedBrowserPlaybookGuidelines(hasBraveApiKey: boolean): string[] {
192
413
  return [
193
414
  SHARED_BROWSER_PLAYBOOK_GUIDELINES[0],
@@ -209,6 +430,22 @@ function buildSessionDetailFields(sessionName: string | undefined, usedImplicitS
209
430
  return sessionName ? { sessionName, usedImplicitSession } : {};
210
431
  }
211
432
 
433
+ function getPersistentSessionArtifactStore(ctx: {
434
+ sessionManager: {
435
+ getSessionDir?: () => string;
436
+ getSessionFile?: () => string | undefined;
437
+ getSessionId: () => string | undefined;
438
+ };
439
+ }): PersistentSessionArtifactStore | undefined {
440
+ const sessionFile = typeof ctx.sessionManager.getSessionFile === "function" ? ctx.sessionManager.getSessionFile() : undefined;
441
+ const sessionDir = typeof ctx.sessionManager.getSessionDir === "function" ? ctx.sessionManager.getSessionDir() : undefined;
442
+ const sessionId = ctx.sessionManager.getSessionId();
443
+ if (!sessionFile || !sessionDir || !sessionId) {
444
+ return undefined;
445
+ }
446
+ return { sessionDir, sessionId };
447
+ }
448
+
212
449
  function redactRecoveryHint(recoveryHint: {
213
450
  exampleArgs: string[];
214
451
  exampleParams: { args: string[]; sessionMode: "fresh" };
@@ -267,13 +504,6 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
267
504
  });
268
505
 
269
506
  pi.on("session_shutdown", async () => {
270
- if (managedSessionActive) {
271
- await closeManagedSession({
272
- cwd: managedSessionCwd,
273
- sessionName: managedSessionName,
274
- timeoutMs: implicitSessionCloseTimeoutMs,
275
- });
276
- }
277
507
  managedSessionActive = false;
278
508
  await cleanupSecureTempArtifacts();
279
509
  });
@@ -332,6 +562,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
332
562
  });
333
563
  const redactedEffectiveArgs = redactInvocationArgs(executionPlan.effectiveArgs);
334
564
  const redactedRecoveryHint = redactRecoveryHint(executionPlan.recoveryHint);
565
+ const compatibilityWorkaround: CompatibilityWorkaround | undefined = executionPlan.compatibilityWorkaround;
335
566
  if (executionPlan.managedSessionName === freshSessionName) {
336
567
  freshSessionOrdinal += 1;
337
568
  }
@@ -354,6 +585,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
354
585
  onUpdate?.({
355
586
  content: [{ type: "text", text: `Running agent-browser ${buildInvocationPreview(redactedEffectiveArgs)}` }],
356
587
  details: {
588
+ compatibilityWorkaround,
357
589
  effectiveArgs: redactedEffectiveArgs,
358
590
  sessionMode,
359
591
  ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
@@ -374,6 +606,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
374
606
  content: [{ type: "text", text: errorText }],
375
607
  details: {
376
608
  args: redactedArgs,
609
+ compatibilityWorkaround,
377
610
  effectiveArgs: redactedEffectiveArgs,
378
611
  sessionMode,
379
612
  spawnError: processResult.spawnError.message,
@@ -410,6 +643,34 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
410
643
  }
411
644
  }
412
645
 
646
+ let openResultTabCorrection: OpenResultTabCorrection | undefined;
647
+ if (
648
+ succeeded &&
649
+ executionPlan.sessionName &&
650
+ params.args.some((token) => token === "--profile" || token.startsWith("--profile=")) &&
651
+ (executionPlan.commandInfo.command === "goto" ||
652
+ executionPlan.commandInfo.command === "navigate" ||
653
+ executionPlan.commandInfo.command === "open")
654
+ ) {
655
+ const targetTitle = extractStringResultField(parsed.envelope?.data, "title");
656
+ const targetUrl = extractStringResultField(parsed.envelope?.data, "url");
657
+ const plannedTabCorrection = await collectOpenResultTabCorrection({
658
+ cwd: ctx.cwd,
659
+ sessionName: executionPlan.sessionName,
660
+ signal,
661
+ targetTitle,
662
+ targetUrl,
663
+ });
664
+ if (plannedTabCorrection) {
665
+ openResultTabCorrection = await applyOpenResultTabCorrection({
666
+ correction: plannedTabCorrection,
667
+ cwd: ctx.cwd,
668
+ sessionName: executionPlan.sessionName,
669
+ signal,
670
+ });
671
+ }
672
+ }
673
+
413
674
  const priorManagedSessionCwd = managedSessionCwd;
414
675
  const managedSessionState = resolveManagedSessionState({
415
676
  command: executionPlan.commandInfo.command,
@@ -459,6 +720,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
459
720
  cwd: ctx.cwd,
460
721
  envelope: presentationEnvelope,
461
722
  errorText,
723
+ persistentArtifactStore: getPersistentSessionArtifactStore(ctx),
462
724
  });
463
725
  const redactedContent = presentation.content.map((item) =>
464
726
  item.type === "text" ? { ...item, text: redactSensitiveText(item.text) } : item,
@@ -471,11 +733,13 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
471
733
  batchFailure: redactSensitiveValue(presentation.batchFailure),
472
734
  batchSteps: redactSensitiveValue(presentation.batchSteps),
473
735
  command: executionPlan.commandInfo.command,
736
+ compatibilityWorkaround,
474
737
  subcommand: executionPlan.commandInfo.subcommand,
475
738
  data: redactSensitiveValue(presentation.data),
476
739
  error: plainTextInspection ? undefined : redactSensitiveValue(parsed.envelope?.error),
477
740
  inspection: plainTextInspection || undefined,
478
741
  navigationSummary: redactSensitiveValue(navigationSummary),
742
+ openResultTabCorrection: redactSensitiveValue(openResultTabCorrection),
479
743
  effectiveArgs: redactedEffectiveArgs,
480
744
  exitCode: processResult.exitCode,
481
745
  fullOutputPath: presentation.fullOutputPath,
@@ -10,6 +10,7 @@ import { readFile, stat } from "node:fs/promises";
10
10
  import { resolve } from "node:path";
11
11
 
12
12
  import { parseCommandInfo, type CommandInfo } from "../runtime.js";
13
+ import { type PersistentSessionArtifactStore } from "../temp.js";
13
14
  import { buildSnapshotPresentation, formatRawSnapshotText, formatSnapshotSummary } from "./snapshot.js";
14
15
  import {
15
16
  type AgentBrowserBatchResult,
@@ -106,6 +107,63 @@ function getScreenshotSummary(data: Record<string, unknown>): string | undefined
106
107
  return typeof data.path === "string" ? `Saved image: ${data.path}` : undefined;
107
108
  }
108
109
 
110
+ function getScalarExtractionResult(data: Record<string, unknown>): string | undefined {
111
+ const { result } = data;
112
+ if (typeof result === "string") {
113
+ return result.trim().length > 0 ? result : undefined;
114
+ }
115
+ if (typeof result === "number" || typeof result === "boolean") {
116
+ return String(result);
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function getExtractionOrigin(data: Record<string, unknown>): string | undefined {
122
+ if (typeof data.origin === "string" && data.origin.trim().length > 0) {
123
+ return data.origin.trim();
124
+ }
125
+ if (typeof data.url === "string" && data.url.trim().length > 0) {
126
+ return data.url.trim();
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ function formatGetSummaryLabel(subcommand: string | undefined): string {
132
+ if (!subcommand) {
133
+ return "Get result";
134
+ }
135
+ if (subcommand.toLowerCase() === "url") {
136
+ return "URL";
137
+ }
138
+ return `${subcommand.slice(0, 1).toUpperCase()}${subcommand.slice(1)}`;
139
+ }
140
+
141
+ function formatExtractionSummary(commandInfo: CommandInfo, data: Record<string, unknown>): string | undefined {
142
+ const scalarResult = getScalarExtractionResult(data);
143
+ if (!scalarResult) {
144
+ return undefined;
145
+ }
146
+ if (commandInfo.command === "get") {
147
+ return `${formatGetSummaryLabel(commandInfo.subcommand)}: ${scalarResult.split("\n", 1)[0] ?? scalarResult}`;
148
+ }
149
+ if (commandInfo.command === "eval") {
150
+ return `Eval result: ${scalarResult.split("\n", 1)[0] ?? scalarResult}`;
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ function formatExtractionText(commandInfo: CommandInfo, data: Record<string, unknown>): string | undefined {
156
+ if (commandInfo.command !== "get" && commandInfo.command !== "eval") {
157
+ return undefined;
158
+ }
159
+ const scalarResult = getScalarExtractionResult(data);
160
+ if (!scalarResult) {
161
+ return undefined;
162
+ }
163
+ const origin = getExtractionOrigin(data);
164
+ return origin && origin !== scalarResult ? `${scalarResult}\n\nOrigin: ${origin}` : scalarResult;
165
+ }
166
+
109
167
  function isNavigationObservableCommand(command: string | undefined): boolean {
110
168
  return command !== undefined && NAVIGATION_SUMMARY_COMMANDS.has(command);
111
169
  }
@@ -207,8 +265,9 @@ async function buildBatchStepPresentation(options: {
207
265
  cwd: string;
208
266
  index: number;
209
267
  item: AgentBrowserBatchResult;
268
+ persistentArtifactStore?: PersistentSessionArtifactStore;
210
269
  }): Promise<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> {
211
- const { cwd, index, item } = options;
270
+ const { cwd, index, item, persistentArtifactStore } = options;
212
271
  const command = isStringArray(item.command) ? item.command : undefined;
213
272
  const commandText = formatBatchStepCommand(command, index);
214
273
 
@@ -236,6 +295,7 @@ async function buildBatchStepPresentation(options: {
236
295
  commandInfo: parseCommandInfo(command ?? []),
237
296
  cwd,
238
297
  envelope: { data: item.result, success: true },
298
+ persistentArtifactStore,
239
299
  });
240
300
  const fullOutputPaths = getPresentationPaths({
241
301
  primaryPath: presentation.fullOutputPath,
@@ -268,12 +328,28 @@ async function buildBatchStepPresentation(options: {
268
328
  async function buildBatchPresentation(options: {
269
329
  cwd: string;
270
330
  data: AgentBrowserBatchResult[];
331
+ persistentArtifactStore?: PersistentSessionArtifactStore;
271
332
  summary: string;
272
333
  }): Promise<ToolPresentation> {
273
- const { cwd, data, summary } = options;
334
+ const { cwd, data, persistentArtifactStore, summary } = options;
274
335
  const steps: Array<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> = [];
336
+ const protectedPersistentPaths: string[] = [];
275
337
  for (const [index, item] of data.entries()) {
276
- steps.push(await buildBatchStepPresentation({ cwd, index, item }));
338
+ const step = await buildBatchStepPresentation({
339
+ cwd,
340
+ index,
341
+ item,
342
+ persistentArtifactStore: persistentArtifactStore
343
+ ? { ...persistentArtifactStore, protectedPaths: protectedPersistentPaths }
344
+ : undefined,
345
+ });
346
+ steps.push(step);
347
+ protectedPersistentPaths.push(
348
+ ...getPresentationPaths({
349
+ primaryPath: step.presentation.fullOutputPath,
350
+ secondaryPaths: step.presentation.fullOutputPaths,
351
+ }),
352
+ );
277
353
  }
278
354
 
279
355
  const batchFailure = getBatchFailureDetails(steps);
@@ -354,6 +430,10 @@ function formatSummary(commandInfo: CommandInfo, data: unknown): string {
354
430
  if (commandInfo.command === "screenshot" && typeof data.path === "string") {
355
431
  return `Screenshot saved: ${data.path}`;
356
432
  }
433
+ const extractionSummary = formatExtractionSummary(commandInfo, data);
434
+ if (extractionSummary) {
435
+ return extractionSummary;
436
+ }
357
437
  const pageSummary = getPageSummary(data);
358
438
  if (pageSummary) {
359
439
  return pageSummary.split("\n", 1)[0] ?? "agent-browser result";
@@ -404,6 +484,11 @@ function formatContentText(commandInfo: CommandInfo, data: unknown): string {
404
484
  if (screenshotSummary) return screenshotSummary;
405
485
  }
406
486
 
487
+ const extractionText = formatExtractionText(commandInfo, data);
488
+ if (extractionText) {
489
+ return extractionText;
490
+ }
491
+
407
492
  const pageSummary = getPageSummary(data);
408
493
  if (pageSummary) {
409
494
  return pageSummary;
@@ -459,8 +544,9 @@ export async function buildToolPresentation(options: {
459
544
  cwd: string;
460
545
  envelope?: AgentBrowserEnvelope;
461
546
  errorText?: string;
547
+ persistentArtifactStore?: PersistentSessionArtifactStore;
462
548
  }): Promise<ToolPresentation> {
463
- const { commandInfo, cwd, envelope, errorText } = options;
549
+ const { commandInfo, cwd, envelope, errorText, persistentArtifactStore } = options;
464
550
  if (errorText) {
465
551
  return {
466
552
  content: [{ type: "text", text: errorText }],
@@ -472,9 +558,9 @@ export async function buildToolPresentation(options: {
472
558
  const summary = formatSummary(commandInfo, data);
473
559
  const presentation =
474
560
  commandInfo.command === "batch" && Array.isArray(data)
475
- ? await buildBatchPresentation({ cwd, data: data as AgentBrowserBatchResult[], summary })
561
+ ? await buildBatchPresentation({ cwd, data: data as AgentBrowserBatchResult[], persistentArtifactStore, summary })
476
562
  : commandInfo.command === "snapshot" && isRecord(data)
477
- ? await buildSnapshotPresentation(data)
563
+ ? await buildSnapshotPresentation(data, persistentArtifactStore)
478
564
  : {
479
565
  content: [{ type: "text" as const, text: formatContentText(commandInfo, data) }],
480
566
  data,
@@ -6,7 +6,7 @@
6
6
  * Invariants/Assumptions: Snapshot compaction should stay helpful even if upstream snapshot text formatting shifts, so structured parsing is best-effort and always has a resilient raw-outline fallback.
7
7
  */
8
8
 
9
- import { writeSecureTempFile } from "../temp.js";
9
+ import { type PersistentSessionArtifactStore, writePersistentSessionArtifactFile, writeSecureTempFile } from "../temp.js";
10
10
  import { type ToolPresentation, compareRefIds, countLines, isRecord, normalizeWhitespace, truncateText } from "./shared.js";
11
11
 
12
12
  const SNAPSHOT_INLINE_MAX_CHARS = 6_000;
@@ -463,12 +463,18 @@ function canUseStructuredSnapshotPreview(snapshotLines: SnapshotLine[], refEntri
463
463
  );
464
464
  }
465
465
 
466
- async function writeSnapshotSpillFile(data: Record<string, unknown>): Promise<string> {
467
- return await writeSecureTempFile({
466
+ async function writeSnapshotSpillFile(
467
+ data: Record<string, unknown>,
468
+ persistentArtifactStore: PersistentSessionArtifactStore | undefined,
469
+ ): Promise<string> {
470
+ const options = {
468
471
  content: JSON.stringify(data, null, 2),
469
472
  prefix: SNAPSHOT_SPILL_FILE_PREFIX,
470
473
  suffix: ".json",
471
- });
474
+ };
475
+ return persistentArtifactStore
476
+ ? await writePersistentSessionArtifactFile({ ...options, store: persistentArtifactStore })
477
+ : await writeSecureTempFile(options);
472
478
  }
473
479
 
474
480
  export function formatSnapshotSummary(data: Record<string, unknown>): string {
@@ -487,7 +493,10 @@ export function formatRawSnapshotText(data: Record<string, unknown>): string {
487
493
  return `Origin: ${origin}\nRefs: ${refs}\n\n${snapshot}`;
488
494
  }
489
495
 
490
- export async function buildSnapshotPresentation(data: Record<string, unknown>): Promise<ToolPresentation> {
496
+ export async function buildSnapshotPresentation(
497
+ data: Record<string, unknown>,
498
+ persistentArtifactStore: PersistentSessionArtifactStore | undefined = undefined,
499
+ ): Promise<ToolPresentation> {
491
500
  const summary = formatSnapshotSummary(data);
492
501
  const rawText = formatRawSnapshotText(data);
493
502
  if (!shouldCompactSnapshot(rawText, data)) {
@@ -501,7 +510,7 @@ export async function buildSnapshotPresentation(data: Record<string, unknown>):
501
510
  let fullOutputPath: string | undefined;
502
511
  let spillErrorText: string | undefined;
503
512
  try {
504
- fullOutputPath = await writeSnapshotSpillFile(data);
513
+ fullOutputPath = await writeSnapshotSpillFile(data, persistentArtifactStore);
505
514
  } catch (error) {
506
515
  spillErrorText = error instanceof Error ? error.message : String(error);
507
516
  }
@@ -10,6 +10,8 @@ import { createHash, randomUUID } from "node:crypto";
10
10
  import { basename } from "node:path";
11
11
 
12
12
  const STARTUP_SCOPED_FLAGS = ["--cdp", "--profile", "--session-name"] as const;
13
+ const OPEN_COMMANDS = new Set(["goto", "navigate", "open"]);
14
+ const OPENAI_HEADLESS_COMPAT_HOSTS = new Set(["chat.openai.com", "chatgpt.com"]);
13
15
  const BRAVE_API_KEY_ENV = "BRAVE_API_KEY";
14
16
  const AGENT_BROWSER_IDLE_TIMEOUT_ENV = "AGENT_BROWSER_IDLE_TIMEOUT_MS";
15
17
  const IMPLICIT_SESSION_IDLE_TIMEOUT_ENV = "PI_AGENT_BROWSER_IMPLICIT_SESSION_IDLE_TIMEOUT_MS";
@@ -57,7 +59,21 @@ const GLOBAL_FLAGS_WITH_VALUES = new Set([
57
59
  "--color-scheme",
58
60
  "--device",
59
61
  "--port",
62
+ "--args",
63
+ "--user-agent",
64
+ "--allowed-domains",
65
+ "--action-policy",
66
+ "--confirm-actions",
67
+ "--max-output",
68
+ "--model",
60
69
  ]);
70
+ const DEFAULT_HEADLESS_COMPAT_USER_AGENT_BY_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
71
+ darwin: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
72
+ linux: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
73
+ win32: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
74
+ };
75
+ const FALLBACK_HEADLESS_COMPAT_USER_AGENT =
76
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
61
77
  const SHELL_OPERATOR_TOKENS = new Set(["&&", "||", "|", ";", ">", ">>", "<"]);
62
78
  const MAX_PROJECT_SLUG_LENGTH = 24;
63
79
  const SESSION_NAME_CWD_HASH_LENGTH = 8;
@@ -84,8 +100,20 @@ export interface InvalidValueFlagDetails {
84
100
  receivedToken?: string;
85
101
  }
86
102
 
103
+ export interface CompatibilityWorkaround {
104
+ id: "chatgpt-headless-user-agent";
105
+ reason: string;
106
+ }
107
+
108
+ export interface OpenResultTabCorrection {
109
+ selectedIndex: number;
110
+ targetTitle?: string;
111
+ targetUrl: string;
112
+ }
113
+
87
114
  export interface ExecutionPlan {
88
115
  commandInfo: CommandInfo;
116
+ compatibilityWorkaround?: CompatibilityWorkaround;
89
117
  effectiveArgs: string[];
90
118
  invalidValueFlag?: InvalidValueFlagDetails;
91
119
  managedSessionName?: string;
@@ -467,6 +495,96 @@ function hasFlagToken(args: string[], flag: string): boolean {
467
495
  return args.some((token) => token === flag || token.startsWith(`${flag}=`));
468
496
  }
469
497
 
498
+ function getFlagValue(args: string[], flag: string): string | undefined {
499
+ for (const [index, token] of args.entries()) {
500
+ if (token === flag) {
501
+ return args[index + 1];
502
+ }
503
+ if (token.startsWith(`${flag}=`)) {
504
+ return token.slice(flag.length + 1);
505
+ }
506
+ }
507
+ return undefined;
508
+ }
509
+
510
+ function isBooleanFlagEnabled(args: string[], flag: string): boolean {
511
+ for (const [index, token] of args.entries()) {
512
+ if (token === flag) {
513
+ const nextToken = args[index + 1]?.trim().toLowerCase();
514
+ if (nextToken === "false") {
515
+ return false;
516
+ }
517
+ return true;
518
+ }
519
+ if (token.startsWith(`${flag}=`)) {
520
+ return token.slice(flag.length + 1).trim().toLowerCase() !== "false";
521
+ }
522
+ }
523
+ return false;
524
+ }
525
+
526
+ function normalizeComparableUrl(url: string): string | undefined {
527
+ const normalizedUrl = url.trim();
528
+ if (normalizedUrl.length === 0) {
529
+ return undefined;
530
+ }
531
+ try {
532
+ const parsedUrl = new URL(normalizedUrl);
533
+ parsedUrl.hash = "";
534
+ return parsedUrl.toString();
535
+ } catch {
536
+ return undefined;
537
+ }
538
+ }
539
+
540
+ function parseComparableNavigationUrl(url: string): URL | undefined {
541
+ try {
542
+ return new URL(url);
543
+ } catch {
544
+ try {
545
+ return new URL(`https://${url}`);
546
+ } catch {
547
+ return undefined;
548
+ }
549
+ }
550
+ }
551
+
552
+ function getDefaultHeadlessCompatUserAgent(platform: NodeJS.Platform = process.platform): string {
553
+ return DEFAULT_HEADLESS_COMPAT_USER_AGENT_BY_PLATFORM[platform] ?? FALLBACK_HEADLESS_COMPAT_USER_AGENT;
554
+ }
555
+
556
+ function getCompatibilityWorkaround(args: string[], commandInfo: CommandInfo): CompatibilityWorkaround | undefined {
557
+ if (!commandInfo.command || !OPEN_COMMANDS.has(commandInfo.command) || !commandInfo.subcommand) {
558
+ return undefined;
559
+ }
560
+ if (hasFlagToken(args, "--user-agent")) {
561
+ return undefined;
562
+ }
563
+ if (isBooleanFlagEnabled(args, "--headed")) {
564
+ return undefined;
565
+ }
566
+ if (hasFlagToken(args, "--cdp") || hasFlagToken(args, "--provider") || hasFlagToken(args, "-p") || hasFlagToken(args, "--auto-connect")) {
567
+ return undefined;
568
+ }
569
+ const engine = getFlagValue(args, "--engine");
570
+ if (engine && engine !== "chrome") {
571
+ return undefined;
572
+ }
573
+ const parsedTargetUrl = parseComparableNavigationUrl(commandInfo.subcommand);
574
+ if (!parsedTargetUrl || !["http:", "https:"].includes(parsedTargetUrl.protocol)) {
575
+ return undefined;
576
+ }
577
+ const hostname = parsedTargetUrl.hostname.toLowerCase();
578
+ if (!OPENAI_HEADLESS_COMPAT_HOSTS.has(hostname)) {
579
+ return undefined;
580
+ }
581
+ return {
582
+ id: "chatgpt-headless-user-agent",
583
+ reason:
584
+ "OpenAI web properties currently challenge the default headless Chrome user agent; inject a normal Chrome user agent to preserve the default headless workflow without requiring headed mode or auto-connect.",
585
+ };
586
+ }
587
+
470
588
  export function extractExplicitSessionName(args: string[]): string | undefined {
471
589
  for (const [index, token] of args.entries()) {
472
590
  if (token === "--session") {
@@ -556,6 +674,7 @@ export function buildExecutionPlan(
556
674
  const explicitSessionName = extractExplicitSessionName(args);
557
675
  const shouldCreateFreshManagedSession =
558
676
  !explicitSessionName && options.sessionMode === "fresh" && commandInfo.command !== undefined && commandInfo.command !== "close";
677
+ const compatibilityWorkaround = getCompatibilityWorkaround(args, commandInfo);
559
678
  let managedSessionName: string | undefined;
560
679
  let recoveryHint: SessionRecoveryHint | undefined;
561
680
  let sessionName = explicitSessionName;
@@ -587,10 +706,14 @@ export function buildExecutionPlan(
587
706
  sessionName = options.freshSessionName;
588
707
  }
589
708
 
709
+ if (compatibilityWorkaround) {
710
+ effectiveArgs.push("--user-agent", getDefaultHeadlessCompatUserAgent());
711
+ }
590
712
  effectiveArgs.push(...args);
591
713
 
592
714
  return {
593
715
  commandInfo,
716
+ compatibilityWorkaround,
594
717
  effectiveArgs,
595
718
  managedSessionName,
596
719
  plainTextInspection,
@@ -602,6 +725,48 @@ export function buildExecutionPlan(
602
725
  };
603
726
  }
604
727
 
728
+ export function chooseOpenResultTabCorrection(options: {
729
+ activeTabIndex?: number;
730
+ tabs: Array<{ active?: boolean; index?: number; title?: string; url?: string }>;
731
+ targetTitle?: string;
732
+ targetUrl?: string;
733
+ }): OpenResultTabCorrection | undefined {
734
+ const normalizedTargetUrl =
735
+ typeof options.targetUrl === "string" ? normalizeComparableUrl(options.targetUrl) : undefined;
736
+ if (!normalizedTargetUrl) {
737
+ return undefined;
738
+ }
739
+
740
+ const tabsWithIndices = options.tabs.map((tab, index) => ({
741
+ ...tab,
742
+ index: typeof tab.index === "number" ? tab.index : index,
743
+ }));
744
+ const activeTab =
745
+ tabsWithIndices.find((tab) => tab.active === true) ??
746
+ (typeof options.activeTabIndex === "number" ? tabsWithIndices.find((tab) => tab.index === options.activeTabIndex) : undefined);
747
+ if (activeTab && normalizeComparableUrl(activeTab.url ?? "") === normalizedTargetUrl) {
748
+ return undefined;
749
+ }
750
+
751
+ const matchingTabs = tabsWithIndices.filter((tab) => normalizeComparableUrl(tab.url ?? "") === normalizedTargetUrl);
752
+ if (matchingTabs.length === 0) {
753
+ return undefined;
754
+ }
755
+ const trimmedTargetTitle = typeof options.targetTitle === "string" ? options.targetTitle.trim() : "";
756
+ const titledMatch =
757
+ trimmedTargetTitle.length === 0
758
+ ? undefined
759
+ : matchingTabs.find((tab) => typeof tab.title === "string" && tab.title.trim() === trimmedTargetTitle);
760
+ const selectedTab = titledMatch ?? matchingTabs[0];
761
+ return selectedTab.index === undefined
762
+ ? undefined
763
+ : {
764
+ selectedIndex: selectedTab.index,
765
+ targetTitle: trimmedTargetTitle.length > 0 ? trimmedTargetTitle : undefined,
766
+ targetUrl: normalizedTargetUrl,
767
+ };
768
+ }
769
+
605
770
  export function parseCommandInfo(args: string[]): CommandInfo {
606
771
  const commands: string[] = [];
607
772
 
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Purpose: Create private temporary files for the pi-agent-browser extension without leaking artifacts broadly on disk.
3
- * Responsibilities: Maintain a process-private temp root, stamp explicit ownership markers, enforce an aggregate temp-artifact disk budget, create securely permissioned temp files, prune explicitly owned stale temp roots from prior runs, and best-effort clean all owned roots on process exit.
4
- * Scope: Temporary artifact lifecycle only; callers decide what data to write and when to delete long-lived references.
2
+ * Purpose: Create private temporary and persisted spill files for the pi-agent-browser extension without leaking artifacts broadly on disk.
3
+ * Responsibilities: Maintain a process-private temp root, stamp explicit ownership markers, enforce an aggregate temp-artifact disk budget, create securely permissioned temp files, create session-scoped persisted spill files for resumable sessions, prune explicitly owned stale temp roots from prior runs, and best-effort clean all owned roots on process exit.
4
+ * Scope: Artifact lifecycle helpers only; callers decide what data to write and when to delete or retain long-lived references.
5
5
  * Usage: Imported by result/process helpers when they need secure spill files instead of world-readable shared tmp paths.
6
- * Invariants/Assumptions: Temp artifacts live under the OS temp directory, each active run uses a dedicated 0700 directory, files are created with exclusive 0600 permissions, and stale pruning only touches roots with an explicit pi-agent-browser ownership marker.
6
+ * Invariants/Assumptions: Temp artifacts live under the OS temp directory, each active run uses a dedicated 0700 directory, files are created with exclusive 0600 permissions, session-scoped persisted artifacts stay under the pi session directory, and stale pruning only touches roots with an explicit pi-agent-browser ownership marker.
7
7
  */
8
8
 
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { rmSync } from "node:fs";
11
- import { chmod, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
11
+ import { chmod, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
12
12
  import { tmpdir } from "node:os";
13
13
  import { dirname, join } from "node:path";
14
14
 
@@ -19,6 +19,15 @@ const TEMP_ROOT_MARKER_VERSION = 1;
19
19
  const STALE_TEMP_ROOT_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
20
20
  const TEMP_ROOT_MAX_BYTES_ENV = "PI_AGENT_BROWSER_TEMP_ROOT_MAX_BYTES";
21
21
  const DEFAULT_TEMP_ROOT_MAX_BYTES = 32 * 1_024 * 1_024;
22
+ const SESSION_ARTIFACT_MAX_BYTES_ENV = "PI_AGENT_BROWSER_SESSION_ARTIFACT_MAX_BYTES";
23
+ const DEFAULT_SESSION_ARTIFACT_MAX_BYTES = 32 * 1_024 * 1_024;
24
+ const SESSION_ARTIFACTS_ROOT_DIR_NAME = ".pi-agent-browser-artifacts";
25
+
26
+ export interface PersistentSessionArtifactStore {
27
+ protectedPaths?: readonly string[];
28
+ sessionDir: string;
29
+ sessionId: string;
30
+ }
22
31
 
23
32
  interface TempRootOwnershipRecord {
24
33
  createdAtMs: number;
@@ -69,18 +78,23 @@ function enqueueTempMutation<T>(task: () => Promise<T>): Promise<T> {
69
78
  return nextTask;
70
79
  }
71
80
 
72
- async function getTempRootArtifactBytes(tempRoot: string): Promise<number> {
73
- const entries = await readdir(tempRoot, { withFileTypes: true }).catch(() => []);
74
- let totalBytes = 0;
81
+ async function listArtifactFiles(directory: string, excludedNames: ReadonlySet<string> = new Set()): Promise<Array<{ mtimeMs: number; path: string; size: number }>> {
82
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
83
+ const files: Array<{ mtimeMs: number; path: string; size: number }> = [];
75
84
  for (const entry of entries) {
76
- if (!entry.isFile() || entry.name === TEMP_ROOT_MARKER_FILE_NAME) continue;
77
- const path = join(tempRoot, entry.name);
85
+ if (!entry.isFile() || excludedNames.has(entry.name)) continue;
86
+ const path = join(directory, entry.name);
78
87
  const stats = await stat(path).catch(() => undefined);
79
88
  if (stats?.isFile()) {
80
- totalBytes += stats.size;
89
+ files.push({ mtimeMs: stats.mtimeMs, path, size: stats.size });
81
90
  }
82
91
  }
83
- return totalBytes;
92
+ return files;
93
+ }
94
+
95
+ async function getTempRootArtifactBytes(tempRoot: string): Promise<number> {
96
+ const files = await listArtifactFiles(tempRoot, new Set([TEMP_ROOT_MARKER_FILE_NAME]));
97
+ return files.reduce((totalBytes, file) => totalBytes + file.size, 0);
84
98
  }
85
99
 
86
100
  async function readTempRootOwnershipMarker(tempRoot: string): Promise<TempRootOwnershipRecord | undefined> {
@@ -153,6 +167,10 @@ export function getSecureTempRootMaxBytes(env: NodeJS.ProcessEnv = process.env):
153
167
  return parsePositiveInteger(env[TEMP_ROOT_MAX_BYTES_ENV]) ?? DEFAULT_TEMP_ROOT_MAX_BYTES;
154
168
  }
155
169
 
170
+ export function getPersistentSessionArtifactMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
171
+ return parsePositiveInteger(env[SESSION_ARTIFACT_MAX_BYTES_ENV]) ?? DEFAULT_SESSION_ARTIFACT_MAX_BYTES;
172
+ }
173
+
156
174
  async function assertSecureTempRootBudget(tempRoot: string, additionalBytes: number): Promise<void> {
157
175
  if (additionalBytes <= 0) return;
158
176
  const currentBytes = await getTempRootArtifactBytes(tempRoot);
@@ -173,6 +191,42 @@ export async function cleanupSecureTempArtifacts(): Promise<void> {
173
191
  });
174
192
  }
175
193
 
194
+ async function ensurePersistentSessionArtifactDir(store: PersistentSessionArtifactStore): Promise<string> {
195
+ const rootDir = join(store.sessionDir, SESSION_ARTIFACTS_ROOT_DIR_NAME);
196
+ const sessionDir = join(rootDir, store.sessionId);
197
+ await mkdir(rootDir, { recursive: true, mode: 0o700 });
198
+ await chmod(rootDir, 0o700).catch(() => undefined);
199
+ await mkdir(sessionDir, { recursive: true, mode: 0o700 });
200
+ await chmod(sessionDir, 0o700).catch(() => undefined);
201
+ return sessionDir;
202
+ }
203
+
204
+ async function prunePersistentSessionArtifactsToBudget(
205
+ sessionArtifactDir: string,
206
+ additionalBytes: number,
207
+ protectedPaths: ReadonlySet<string>,
208
+ ): Promise<void> {
209
+ if (additionalBytes <= 0) return;
210
+ const maxBytes = getPersistentSessionArtifactMaxBytes();
211
+ let files = await listArtifactFiles(sessionArtifactDir);
212
+ let totalBytes = files.reduce((total, file) => total + file.size, 0);
213
+ if (totalBytes + additionalBytes <= maxBytes) {
214
+ return;
215
+ }
216
+ files = files.sort((left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path));
217
+ for (const file of files) {
218
+ if (protectedPaths.has(file.path)) {
219
+ continue;
220
+ }
221
+ await rm(file.path, { force: true }).catch(() => undefined);
222
+ totalBytes -= file.size;
223
+ if (totalBytes + additionalBytes <= maxBytes) {
224
+ return;
225
+ }
226
+ }
227
+ throw new Error(`pi-agent-browser persisted spill budget exceeded (${totalBytes + additionalBytes} bytes > ${maxBytes} byte limit).`);
228
+ }
229
+
176
230
  async function getSessionTempRoot(): Promise<string> {
177
231
  if (!sessionTempRootPromise) {
178
232
  sessionTempRootPromise = (async () => {
@@ -228,6 +282,34 @@ export async function writeSecureTempFile(options: {
228
282
  return path;
229
283
  }
230
284
 
285
+ export async function writePersistentSessionArtifactFile(options: {
286
+ content: string | Uint8Array;
287
+ prefix: string;
288
+ store: PersistentSessionArtifactStore;
289
+ suffix: string;
290
+ }): Promise<string> {
291
+ const { content, prefix, store, suffix } = options;
292
+ return await enqueueTempMutation(async () => {
293
+ const artifactDir = await ensurePersistentSessionArtifactDir(store);
294
+ await prunePersistentSessionArtifactsToBudget(
295
+ artifactDir,
296
+ getTempArtifactByteLength(content),
297
+ new Set((store.protectedPaths ?? []).filter((path) => dirname(path) === artifactDir)),
298
+ );
299
+ const path = join(artifactDir, `${prefix}-${randomBytes(8).toString("hex")}${suffix}`);
300
+ const fileHandle = await open(path, "wx", 0o600);
301
+ try {
302
+ await fileHandle.writeFile(content);
303
+ } catch (error) {
304
+ await rm(path, { force: true }).catch(() => undefined);
305
+ throw error;
306
+ } finally {
307
+ await fileHandle.close().catch(() => undefined);
308
+ }
309
+ return path;
310
+ });
311
+ }
312
+
231
313
  export async function getSecureTempDebugState(): Promise<{ currentTempRoot?: string; ownedTempRoots: string[] }> {
232
314
  return {
233
315
  currentTempRoot: await sessionTempRootPromise?.catch(() => undefined),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "pi extension that exposes agent-browser as a native tool for browser automation",
5
5
  "type": "module",
6
6
  "author": "Mitch Fultz (https://github.com/fitchmultz)",