pi-agent-browser-native 0.2.15 → 0.2.17

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
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.17 - 2026-05-03
6
+
7
+ ### Fixed
8
+ - close the active extension-managed `piab-*` browser session when the originating `pi` process quits, while preserving managed browser continuity across `/reload` and resumable session transitions
9
+ - added lifecycle regression coverage for quit-time managed-session cleanup and reload-time preservation
10
+
11
+ ### Changed
12
+ - clarified that the managed-session idle timeout is now an abnormal-exit backstop, not the primary cleanup path for normal `pi` exits
13
+
14
+ ## 0.2.16 - 2026-05-02
15
+
16
+ ### Fixed
17
+ - made screenshot artifact paths reliable for agent workflows by normalizing explicit screenshot output paths, including dot-directory paths such as `.dogfood/...`, to absolute paths before invoking upstream `agent-browser`
18
+ - repaired screenshot outputs from upstream temp files when needed and made the requested path the primary visible artifact path
19
+ - extended the screenshot path contract to annotated batch screenshots, so top-level `--annotate` batch calls preserve and verify per-step requested output paths
20
+ - blocked per-step batch `--annotate` screenshot forms that upstream parses unsafely and now point agents to the safe top-level `--annotate batch` form
21
+ - added wrapper-observed trace/profiler owner guards to prevent known conflicting start/stop sequences from corrupting upstream tracing state
22
+
23
+ ### Changed
24
+ - artifact-producing commands now render direct visible artifact metadata, including artifact type, requested path, absolute path, existence, size, status, cwd, session, and temp path when repaired
25
+ - explicit `--json` calls now render valid JSON in visible tool content; `stream status` JSON is enriched with `wsUrl` and frame format metadata
26
+ - documented the artifact contract, batch annotation guidance, trace/profiler caveat, and package-development bash bypass for upstream debugging
27
+
5
28
  ## 0.2.15 - 2026-05-01
6
29
 
7
30
  ### Changed
package/README.md CHANGED
@@ -242,9 +242,10 @@ Validated workflow examples:
242
242
  - in configured-source lifecycle mode, verify `/reload` and full restart + `/resume` keep following the same implicit managed browser session
243
243
  - run `batch` with JSON via `stdin`
244
244
  - run `eval --stdin`
245
- - take a screenshot with inline attachment support
245
+ - take a screenshot with inline attachment support and visible artifact metadata: artifact type, requested path, absolute path, existence, size, cwd, session, and repair/copy status when applicable
246
246
  - inspect upstream help/version through native tool calls like `{ "args": ["--help"] }` and `{ "args": ["--version"] }` via the tool's stateless plain-text inspection fallback
247
247
  - use `download <selector> <path>` for direct attachment/file-save workflows instead of trying to infer downloads from generic clicks or large eval dumps
248
+ - for `.dogfood/...` or other dot-directory screenshot paths, rely on the wrapper's path normalization/repair contract; the visible result reports the requested path and absolute path rather than only an upstream temp path
248
249
  - use `click` plus `wait --download <path>` for asynchronous export flows, confirm `details.savedFilePath`/`details.savedFile` are present on the wait result or batch wait step, and check `details.artifacts[].exists` before relying on requested-path persistence
249
250
  - confirm oversized outputs show the actual spill file path directly in tool content, not just a details key name
250
251
  - inspect `details.artifactManifest` / `details.artifactRetentionSummary` during artifact-heavy flows to recover recent saved files, spill files, and visible eviction state after reload/resume
@@ -262,7 +263,7 @@ These calls return plain text and stay stateless: the extension does not inject
262
263
  Current cautions:
263
264
  - passing `--profile` is an explicit upstream choice; this extension does not add its own profile-cloning or isolation layer
264
265
  - launch-scoped flags like `--profile`, `--session-name`, `--cdp`, `--state`, and `--auto-connect` 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
265
- - 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` and metadata-only `details.artifactManifest` survive reload/resume without unbounded growth, and still clean up process-private temp spill artifacts on shutdown
266
+ - implicit `piab-*` sessions are extension-managed convenience sessions; they stay alive across `/reload` and resumable session transitions so later default calls can keep following the active managed browser on `/reload` or `/resume`, close when the originating `pi` process quits, rely on the configured idle timeout only as an abnormal-exit backstop, store persisted-session large snapshot spill files under a private session-scoped artifact directory with a bounded per-session budget so `details.fullOutputPath` and metadata-only `details.artifactManifest` survive reload/resume without unbounded growth, and still clean up process-private temp spill artifacts on shutdown
266
267
  - `sessionMode: "fresh"` without an explicit `--session` rotates that extension-managed session to the new browser so later auto calls keep using it
267
268
  - for local Unix launches, the wrapper uses a short private socket directory under `/tmp` so extension-generated session names do not trip upstream Unix socket-path limits in longer cwd/session-name combinations
268
269
  - for direct headless local Chrome launches to `chat.com`, `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`
@@ -274,6 +275,9 @@ Current cautions:
274
275
  - If a known session target unexpectedly reports about:blank, agent_browser preserves the prior intended target, best-effort re-selects it when it still exists, and reports exact recovery guidance when it cannot be re-selected.
275
276
  <!-- agent-browser-playbook:end wrapper-tab-recovery -->
276
277
  - oversized snapshots and oversized generic outputs compact inline content and print the actual spill file path directly in the tool result when a spill file exists; recent spills and explicit saved artifacts are also summarized in `details.artifactManifest`, including `evicted` entries when retention budgets remove older persisted files
278
+ - artifact-producing commands render direct readable artifact metadata in visible content and `details.artifacts`: `kind`/`artifactType`, `path`, `requestedPath`, `absolutePath`, `exists`, `sizeBytes`, `status`, `cwd`, `session`, and `tempPath` when the wrapper repaired an upstream temp fallback
279
+ - if the caller explicitly passes `--json`, the visible text content is valid JSON; for `stream status`, the wrapper enriches data with `wsUrl` and `frameFormat`
280
+ - `trace` and `profiler` share upstream tracing machinery; the wrapper blocks starts/stops that conflict with owner state it observed in the current Pi session, but the message says "wrapper believes" because upstream or external CLI calls can desynchronize that local state
277
281
  - explicit caller-provided `--session` values are treated as user-managed and are not auto-closed by the extension
278
282
  - explicit caller-provided `--user-agent` values win over the ChatGPT/OpenAI compatibility workaround
279
283
  - tool progress/details redact sensitive invocation values such as `--headers`, proxy credentials, and auth-bearing URL parameters before echoing them back into Pi
@@ -87,8 +87,9 @@ V1 ownership rule:
87
87
  - extension-managed sessions should be reusable during an active `pi` session and across `/reload` / `/resume`, while still being cleaned up predictably
88
88
 
89
89
  Practical policy:
90
- - preserve the current extension-managed session across normal `pi` shutdown/reload so persisted sessions can keep following the live browser after `/reload` or `/resume`
91
- - set an idle timeout on extension-managed sessions so abandoned daemons self-clean after inactivity
90
+ - preserve the current extension-managed session across `/reload` and resumable session transitions so persisted sessions can keep following the live browser after `/reload` or `/resume`
91
+ - close the active extension-managed session when the originating `pi` process quits, while leaving explicit caller-provided sessions alone
92
+ - set an idle timeout on extension-managed sessions as a backstop for abnormal exits or cleanup failures
92
93
  - 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
93
94
  - reconstruct the current extension-managed session from persisted tool details on resume/reload so later default calls keep following the active managed browser
94
95
  - if an unnamed fresh launch replaces an active extension-managed session, best-effort close the old managed session after the switch succeeds
@@ -128,11 +128,19 @@ A successful wait-based download renders a readable summary such as `Download co
128
128
  Prefer `download <selector> <path>` when the target element itself is the downloadable link/control. Use `click` plus `wait --download [path]` when a previous action starts the download indirectly.
129
129
 
130
130
  Wrapper result rendering is metadata-first for saved files:
131
- - screenshots return a saved-path summary, structured `details.artifacts` metadata, and an inline image attachment when safe
131
+ - screenshots return a saved-path summary, visible artifact metadata, structured `details.artifacts` metadata, and an inline image attachment when safe; the visible block includes artifact type, requested path, absolute path, existence, size, cwd, session, and repair/copy status when applicable
132
132
  - downloads, PDFs, `wait --download` files, traces, CPU profiles, completed WebM recordings from `record stop`, and path-bearing HAR captures return concise saved-path summaries plus structured `details.artifacts` metadata without inlining large files
133
133
  - `record start <path>` reports that recording started and that output will be written on `record stop`; the target file may not exist until recording stops
134
134
  - `batch` keeps each step's artifacts in `details.batchSteps[].artifacts` and aggregates them in top-level `details.artifacts` in step order
135
135
 
136
+ For screenshot paths under dot-directories such as `.dogfood/run/foo.png`, the wrapper normalizes the requested path to an absolute path before invoking upstream `agent-browser`, verifies the requested file exists, and repairs from an upstream temp screenshot when possible. The requested path remains visible as `Requested path`, while `Absolute path` shows the actual on-disk location.
137
+
138
+ For annotated screenshots in `batch`, put `--annotate` in top-level args instead of inside the screenshot step:
139
+
140
+ ```json
141
+ { "args": ["--annotate", "batch"], "stdin": "[[\"screenshot\",\"/tmp/page.png\"]]" }
142
+ ```
143
+
136
144
  #### Artifact retention and dogfood-heavy QA runs
137
145
 
138
146
  The wrapper keeps a bounded, metadata-only `details.artifactManifest` of recent artifacts so long sessions do not grow unbounded. The default recent window is 100 entries and can be raised for screenshot/video-heavy QA sessions with `PI_AGENT_BROWSER_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES=<count>`.
@@ -325,6 +333,8 @@ Stable tab ids look like `t1`, `t2`, and `t3`. Optional user labels such as `doc
325
333
 
326
334
  When these diagnostic commands are invoked through the native `agent_browser` tool, structured console and page-error outputs render as compact summaries with counts and key fields. Large outputs are previewed with a `Full output path:` spill file instead of dumping the entire payload into context.
327
335
 
336
+ `trace` and `profiler` share upstream Chrome tracing machinery. Do not run them at the same time. The wrapper tracks owner state it observes in the current Pi session and blocks conflicting starts/stops with "wrapper believes ..." wording because direct upstream CLI use or browser restarts can desynchronize wrapper-local state.
337
+
328
338
  ### Batch, auth, confirmations, sessions, chat, dashboard, and setup
329
339
 
330
340
  | Command | Purpose |
@@ -171,7 +171,7 @@ Additional structured fields can appear when relevant:
171
171
  - `batchFailure` and `batchSteps` for `batch` rendering, including mixed-success runs
172
172
  - `navigationSummary` for navigation-style commands like `click`, `back`, `forward`, and `reload`
173
173
  - `imagePath` / `imagePaths` for screenshots and batched image outputs
174
- - `artifacts` for upstream saved files such as screenshots, PDFs, downloads, `wait --download` files, traces, CPU profiles, completed WebM recordings, path-bearing HAR captures, and future recording output paths reported by `record start`. Each artifact includes the original saved or requested `path`, resolved `absolutePath`, `kind`, optional `mediaType`, optional `extension`, and best-effort disk metadata such as `exists` and `sizeBytes`.
174
+ - `artifacts` for upstream saved files such as screenshots, PDFs, downloads, `wait --download` files, traces, CPU profiles, completed WebM recordings, path-bearing HAR captures, and future recording output paths reported by `record start`. Each artifact includes the original saved or requested `path`, resolved `absolutePath`, `kind`/`artifactType`, optional `mediaType`, optional `extension`, best-effort disk metadata such as `exists` and `sizeBytes`, plus `requestedPath`, `status`, `cwd`, `session`, and `tempPath` when applicable.
175
175
  - `savedFilePath` / `savedFile` for direct `download`, `pdf`, and `wait --download` saved-file workflows; batch results preserve the same fields on the relevant `batchSteps` entry.
176
176
  - `batchSteps[].artifacts` for per-step artifacts in `batch` output; top-level `artifacts` aggregates all step artifacts in order
177
177
  - `fullOutputPath` / `fullOutputPaths` when large snapshot output or other oversized tool 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
@@ -189,15 +189,16 @@ For oversized snapshots and other oversized tool outputs, details should switch
189
189
  "Rendering" here means how results appear inside `pi`, not embedding a browser UI.
190
190
 
191
191
  Worth doing in v1:
192
- - screenshots → saved-path summary, `details.artifacts` metadata, and inline image attachment when safe
192
+ - screenshots → saved-path summary, visible artifact metadata, `details.artifacts` metadata, and inline image attachment when safe; screenshot paths that upstream would treat ambiguously, such as `.dogfood/run/foo.png`, are normalized to absolute paths before launch and repaired from upstream temp output when possible
193
193
  - file artifacts such as PDFs, downloads, `wait --download` files, traces, CPU profiles, completed WebM recordings, and path-bearing HAR captures → concise saved-path summaries plus metadata in `details.artifacts` and bounded recent metadata in `details.artifactManifest`; `record start` reports recording lifecycle state and the future output path without adding a missing manifest entry; direct saved-file workflows also expose `details.savedFilePath` / `details.savedFile`; large or binary artifacts are not inlined into model context; the recent manifest cap can age out explicit-file metadata but does not remove explicit saved files from disk
194
194
  - snapshots → origin + ref count + main-content-first compact preview, with the raw snapshot spill path printed directly in content and kept in `details.fullOutputPath` plus `details.artifactManifest` when the inline result would otherwise be too large
195
195
  - oversized generic outputs such as large `eval --stdin` payloads → compact preview plus the actual spill file path instead of dumping the whole payload into model context
196
196
  - extraction-style commands like `eval --stdin` and `get title` → scalar-first text with lightweight origin context when available
197
197
  - navigation actions like `click`, `back`, `forward`, and `reload` → lightweight post-action title/url summary when available
198
198
  - tab lists → compact summary/table
199
- - stream status → enabled/connected/port summary
199
+ - stream status → enabled/connected/port summary plus WebSocket URL and frame format when a port is known; if the caller explicitly passed `--json`, visible text is valid JSON instead of a prose summary
200
200
  - diagnostic/status families (`session`, `session list`, `profiles`, `doctor`, `auth list`/`show`, `network requests`, `console`, `errors`, and dashboard start/stop/status outputs) → compact readable summaries with counts and stable fields; large log/request/error outputs use previews plus `fullOutputPath` spill files; sensitive nested auth/header/token fields are not expanded in the model-facing text
201
+ - trace/profiler owner conflicts → when the wrapper has observed one owner active for a session, block conflicting starts/stops with "wrapper believes ..." wording because upstream or external CLI use can desynchronize wrapper-local state
201
202
 
202
203
  ## Missing binary behavior
203
204
 
@@ -212,8 +213,9 @@ If `agent-browser` is not on `PATH`, fail with a message that:
212
213
  - derive the base implicit session name from the official `pi` session id plus a cwd hash so same-named checkouts do not collide
213
214
  - respect explicit upstream `--session` with minimal interference
214
215
  - treat the extension-managed session as convenience state owned by the wrapper
215
- - preserve the current extension-managed session across normal `pi` shutdown/reload so persisted sessions can keep following the live browser on `/reload` or `/resume`
216
- - set an idle timeout on extension-managed sessions so abandoned daemons eventually self-clean
216
+ - preserve the current extension-managed session across `/reload` and resumable session transitions so persisted sessions can keep following the live browser on `/reload` or `/resume`
217
+ - close the active extension-managed session when the originating `pi` process quits, while leaving explicit caller-provided sessions alone
218
+ - set an idle timeout on extension-managed sessions as a backstop for abnormal exits or cleanup failures
217
219
  - 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
218
220
  - reconstruct the current extension-managed session and latest `artifactManifest` from persisted tool details on resume/reload so later default calls keep following the active managed browser and can continue reporting artifact retention state
219
221
  - when an unnamed `sessionMode: "fresh"` launch succeeds, make it the new extension-managed session so later default calls keep using it
@@ -6,7 +6,8 @@
6
6
  * Invariants/Assumptions: agent-browser is installed separately on PATH, the wrapper targets the current locally installed upstream version only, and no backward-compatibility shims are provided.
7
7
  */
8
8
 
9
- import { readFile, rm } from "node:fs/promises";
9
+ import { copyFile, mkdir, readFile, rm, stat } from "node:fs/promises";
10
+ import { dirname, extname, isAbsolute, join, resolve } from "node:path";
10
11
 
11
12
  import { StringEnum } from "@mariozechner/pi-ai";
12
13
  import { isToolCallEventType, type AgentToolResult, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -64,6 +65,8 @@ import {
64
65
  } from "./lib/results/shared.js";
65
66
 
66
67
  const DEFAULT_SESSION_MODE = "auto" as const;
68
+ const DIRECT_AGENT_BROWSER_BASH_BYPASS_ENV = "PI_AGENT_BROWSER_ALLOW_DIRECT_BASH";
69
+ const PACKAGE_NAME = "pi-agent-browser-native";
67
70
 
68
71
  const AGENT_BROWSER_PARAMS = Type.Object({
69
72
  args: Type.Array(Type.String({ description: "Exact agent-browser CLI arguments, excluding the binary name." }), {
@@ -292,6 +295,23 @@ function isHarmlessAgentBrowserInspectionCommand(command: string): boolean {
292
295
  return HARMLESS_AGENT_BROWSER_INSPECTION_PATTERN.test(command);
293
296
  }
294
297
 
298
+ function isTruthyEnvValue(value: string | undefined): boolean {
299
+ return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";
300
+ }
301
+
302
+ async function isPackageDevelopmentCwd(cwd: string): Promise<boolean> {
303
+ try {
304
+ const packageJson = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")) as { name?: unknown };
305
+ return packageJson.name === PACKAGE_NAME;
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+
311
+ async function isDirectAgentBrowserBashAllowed(cwd: string): Promise<boolean> {
312
+ return isTruthyEnvValue(process.env[DIRECT_AGENT_BROWSER_BASH_BYPASS_ENV]) || await isPackageDevelopmentCwd(cwd);
313
+ }
314
+
295
315
  const NAVIGATION_SUMMARY_COMMANDS = new Set(["back", "click", "dblclick", "forward", "reload"]);
296
316
 
297
317
  interface NavigationSummary {
@@ -303,6 +323,330 @@ function isRecord(value: unknown): value is Record<string, unknown> {
303
323
  return typeof value === "object" && value !== null;
304
324
  }
305
325
 
326
+ const SCREENSHOT_VALUE_FLAGS = new Set(["--screenshot-dir", "--screenshot-format", "--screenshot-quality"]);
327
+ const SCREENSHOT_IMAGE_EXTENSIONS = new Set([".jpeg", ".jpg", ".png", ".webp"]);
328
+
329
+ interface ScreenshotPathRequest {
330
+ absolutePath: string;
331
+ path: string;
332
+ }
333
+
334
+ interface PreparedAgentBrowserArgs {
335
+ args: string[];
336
+ batchScreenshotPathRequests?: Array<ScreenshotPathRequest | undefined>;
337
+ screenshotPathRequest?: ScreenshotPathRequest;
338
+ stdin?: string;
339
+ }
340
+
341
+ interface ScreenshotArtifactRequest extends ScreenshotPathRequest {
342
+ status?: "missing" | "repaired-from-temp" | "saved" | "upstream-temp-only";
343
+ tempPath?: string;
344
+ }
345
+
346
+ type TraceOwner = "profiler" | "trace";
347
+
348
+ function isImagePathToken(token: string): boolean {
349
+ const extension = extname(token).toLowerCase();
350
+ return SCREENSHOT_IMAGE_EXTENSIONS.has(extension);
351
+ }
352
+
353
+ function getScreenshotPathTokenIndex(commandTokens: string[]): number | undefined {
354
+ if (commandTokens[0] !== "screenshot") {
355
+ return undefined;
356
+ }
357
+
358
+ const positionalIndices: number[] = [];
359
+ for (let index = 1; index < commandTokens.length; index += 1) {
360
+ const token = commandTokens[index];
361
+ if (token === "--") {
362
+ for (let positionalIndex = index + 1; positionalIndex < commandTokens.length; positionalIndex += 1) {
363
+ positionalIndices.push(positionalIndex);
364
+ }
365
+ break;
366
+ }
367
+ if (token.startsWith("-")) {
368
+ const normalizedToken = token.split("=", 1)[0] ?? token;
369
+ if (SCREENSHOT_VALUE_FLAGS.has(normalizedToken) && !token.includes("=")) {
370
+ index += 1;
371
+ }
372
+ continue;
373
+ }
374
+ positionalIndices.push(index);
375
+ }
376
+
377
+ if (positionalIndices.length === 0) {
378
+ return undefined;
379
+ }
380
+ const candidateIndex = positionalIndices[positionalIndices.length - 1];
381
+ const candidate = commandTokens[candidateIndex];
382
+ if (positionalIndices.length >= 2 || isImagePathToken(candidate) || isAbsolute(candidate) || candidate.startsWith("./") || candidate.startsWith("../")) {
383
+ return candidateIndex;
384
+ }
385
+ return undefined;
386
+ }
387
+
388
+ async function normalizeScreenshotPathInTokens(commandTokens: string[], cwd: string): Promise<{
389
+ request?: ScreenshotPathRequest;
390
+ tokens: string[];
391
+ }> {
392
+ const screenshotPathTokenIndex = getScreenshotPathTokenIndex(commandTokens);
393
+ if (screenshotPathTokenIndex === undefined) {
394
+ return { tokens: commandTokens };
395
+ }
396
+
397
+ const requestedPath = commandTokens[screenshotPathTokenIndex];
398
+ const absolutePath = resolve(cwd, requestedPath);
399
+ await mkdir(dirname(absolutePath), { recursive: true });
400
+
401
+ const tokens = [...commandTokens];
402
+ tokens[screenshotPathTokenIndex] = absolutePath;
403
+ const terminatorIndex = tokens.indexOf("--");
404
+ if (terminatorIndex >= 0) {
405
+ tokens.splice(terminatorIndex, 1);
406
+ }
407
+
408
+ return {
409
+ request: {
410
+ absolutePath,
411
+ path: requestedPath,
412
+ },
413
+ tokens,
414
+ };
415
+ }
416
+
417
+ async function prepareBatchScreenshotPaths(args: string[], stdin: string | undefined, cwd: string): Promise<PreparedAgentBrowserArgs | undefined> {
418
+ const commandTokens = extractCommandTokens(args);
419
+ if (commandTokens[0] !== "batch" || stdin === undefined) {
420
+ return undefined;
421
+ }
422
+ let steps: unknown;
423
+ try {
424
+ steps = JSON.parse(stdin);
425
+ } catch {
426
+ return undefined;
427
+ }
428
+ if (!Array.isArray(steps)) {
429
+ return undefined;
430
+ }
431
+
432
+ let changed = false;
433
+ const batchScreenshotPathRequests: Array<ScreenshotPathRequest | undefined> = [];
434
+ const preparedSteps = await Promise.all(steps.map(async (step, index) => {
435
+ if (!Array.isArray(step) || !step.every((item) => typeof item === "string") || step[0] !== "screenshot") {
436
+ return step;
437
+ }
438
+ const normalized = await normalizeScreenshotPathInTokens(step, cwd);
439
+ batchScreenshotPathRequests[index] = normalized.request;
440
+ if (normalized.request) {
441
+ changed = true;
442
+ }
443
+ return normalized.tokens;
444
+ }));
445
+
446
+ return changed
447
+ ? {
448
+ args,
449
+ batchScreenshotPathRequests,
450
+ stdin: JSON.stringify(preparedSteps),
451
+ }
452
+ : undefined;
453
+ }
454
+
455
+ async function prepareAgentBrowserArgs(args: string[], stdin: string | undefined, cwd: string): Promise<PreparedAgentBrowserArgs> {
456
+ const preparedBatch = await prepareBatchScreenshotPaths(args, stdin, cwd);
457
+ if (preparedBatch) {
458
+ return preparedBatch;
459
+ }
460
+
461
+ const commandTokens = extractCommandTokens(args);
462
+ const normalized = await normalizeScreenshotPathInTokens(commandTokens, cwd);
463
+ if (!normalized.request) {
464
+ return { args };
465
+ }
466
+
467
+ const commandStartIndex = args.length - commandTokens.length;
468
+ return {
469
+ args: [...args.slice(0, commandStartIndex), ...normalized.tokens],
470
+ screenshotPathRequest: normalized.request,
471
+ };
472
+ }
473
+
474
+ async function pathExists(path: string): Promise<boolean> {
475
+ try {
476
+ await stat(path);
477
+ return true;
478
+ } catch {
479
+ return false;
480
+ }
481
+ }
482
+
483
+ async function repairScreenshotData(options: {
484
+ cwd: string;
485
+ data: Record<string, unknown>;
486
+ request: ScreenshotPathRequest;
487
+ }): Promise<{ data: Record<string, unknown>; request: ScreenshotArtifactRequest }> {
488
+ const { cwd, data, request } = options;
489
+ const reportedPath = typeof data.path === "string" ? data.path : undefined;
490
+ const reportedAbsolutePath = reportedPath ? resolve(cwd, reportedPath) : undefined;
491
+ let status: ScreenshotArtifactRequest["status"] = await pathExists(request.absolutePath) ? "saved" : "missing";
492
+ let tempPath: string | undefined;
493
+
494
+ if (reportedAbsolutePath && reportedAbsolutePath !== request.absolutePath) {
495
+ tempPath = reportedAbsolutePath;
496
+ if (status === "missing" && await pathExists(reportedAbsolutePath)) {
497
+ await mkdir(dirname(request.absolutePath), { recursive: true });
498
+ await copyFile(reportedAbsolutePath, request.absolutePath);
499
+ status = "repaired-from-temp";
500
+ }
501
+ }
502
+
503
+ return {
504
+ data: {
505
+ ...data,
506
+ path: request.absolutePath,
507
+ },
508
+ request: {
509
+ ...request,
510
+ status,
511
+ tempPath,
512
+ },
513
+ };
514
+ }
515
+
516
+ async function repairScreenshotArtifact(options: {
517
+ cwd: string;
518
+ envelope?: AgentBrowserEnvelope;
519
+ request?: ScreenshotPathRequest;
520
+ }): Promise<{ envelope?: AgentBrowserEnvelope; request?: ScreenshotArtifactRequest }> {
521
+ const { cwd, envelope, request } = options;
522
+ if (!request || !envelope || !isRecord(envelope.data)) {
523
+ return { envelope, request };
524
+ }
525
+
526
+ const repaired = await repairScreenshotData({ cwd, data: envelope.data, request });
527
+ return {
528
+ envelope: { ...envelope, data: repaired.data },
529
+ request: repaired.request,
530
+ };
531
+ }
532
+
533
+ async function repairBatchScreenshotArtifacts(options: {
534
+ cwd: string;
535
+ envelope?: AgentBrowserEnvelope;
536
+ requests?: Array<ScreenshotPathRequest | undefined>;
537
+ }): Promise<{ envelope?: AgentBrowserEnvelope; requests?: Array<ScreenshotArtifactRequest | undefined> }> {
538
+ const { cwd, envelope, requests } = options;
539
+ if (!envelope || !Array.isArray(envelope.data) || !requests?.some((request) => request !== undefined)) {
540
+ return { envelope, requests };
541
+ }
542
+
543
+ const repairedRequests: Array<ScreenshotArtifactRequest | undefined> = [];
544
+ const repairedData = await Promise.all(envelope.data.map(async (item, index) => {
545
+ const request = requests[index];
546
+ if (!request || !isRecord(item) || !isRecord(item.result)) {
547
+ return item;
548
+ }
549
+ const repaired = await repairScreenshotData({ cwd, data: item.result, request });
550
+ repairedRequests[index] = repaired.request;
551
+ return {
552
+ ...item,
553
+ result: repaired.data,
554
+ };
555
+ }));
556
+
557
+ return {
558
+ envelope: { ...envelope, data: repairedData },
559
+ requests: repairedRequests,
560
+ };
561
+ }
562
+
563
+ function buildJsonVisibleContent(options: {
564
+ error: unknown;
565
+ presentation: Awaited<ReturnType<typeof buildToolPresentation>>;
566
+ succeeded: boolean;
567
+ warnings?: string[];
568
+ }): Array<{ text: string; type: "text" } | { data: string; mimeType: string; type: "image" }> {
569
+ const { error, presentation, succeeded, warnings } = options;
570
+ const payload = redactSensitiveValue({
571
+ artifacts: presentation.artifacts,
572
+ data: presentation.data,
573
+ error,
574
+ success: succeeded,
575
+ warnings: warnings && warnings.length > 0 ? warnings : undefined,
576
+ });
577
+ if (isRecord(payload) && isRecord(payload.data) && isRecord(presentation.data) && typeof presentation.data.wsUrl === "string") {
578
+ payload.data.wsUrl = presentation.data.wsUrl;
579
+ }
580
+ const images = presentation.content.filter((item): item is { data: string; mimeType: string; type: "image" } => item.type === "image");
581
+ return [{ type: "text", text: JSON.stringify(payload, null, 2) }, ...images];
582
+ }
583
+
584
+ function getBatchAnnotateValidationError(args: string[], stdin: string | undefined): string | undefined {
585
+ const commandTokens = extractCommandTokens(args);
586
+ if (commandTokens[0] !== "batch" || stdin === undefined) {
587
+ return undefined;
588
+ }
589
+ let steps: unknown;
590
+ try {
591
+ steps = JSON.parse(stdin);
592
+ } catch {
593
+ return undefined;
594
+ }
595
+ if (!Array.isArray(steps)) {
596
+ return undefined;
597
+ }
598
+ const badStepIndex = steps.findIndex((step) => Array.isArray(step) && step[0] === "screenshot" && step.includes("--annotate"));
599
+ if (badStepIndex < 0) {
600
+ return undefined;
601
+ }
602
+ return [
603
+ `Unsupported batch screenshot annotation in step ${badStepIndex + 1}: put --annotate in top-level args, not inside the batch step.`,
604
+ `Use: { "args": ["--annotate", "batch"], "stdin": "[[\\"screenshot\\",\\"/path/to/image.png\\"]]" }`,
605
+ ].join("\n");
606
+ }
607
+
608
+ function getTraceOwner(command: string | undefined): TraceOwner | undefined {
609
+ return command === "trace" || command === "profiler" ? command : undefined;
610
+ }
611
+
612
+ function getTraceOwnerGuardMessage(options: {
613
+ command: string | undefined;
614
+ sessionName: string | undefined;
615
+ subcommand: string | undefined;
616
+ traceOwners: Map<string, TraceOwner>;
617
+ }): string | undefined {
618
+ const owner = getTraceOwner(options.command);
619
+ if (!owner || !options.sessionName || (options.subcommand !== "start" && options.subcommand !== "stop")) {
620
+ return undefined;
621
+ }
622
+ const activeOwner = options.traceOwners.get(options.sessionName);
623
+ if (!activeOwner || activeOwner === owner) {
624
+ return undefined;
625
+ }
626
+ return options.subcommand === "start"
627
+ ? `Wrapper believes ${activeOwner} tracing is active for session ${options.sessionName}; stop ${activeOwner} before starting ${owner}.`
628
+ : `Wrapper believes tracing for session ${options.sessionName} is owned by ${activeOwner}; run ${activeOwner} stop instead of ${owner} stop.`;
629
+ }
630
+
631
+ function updateTraceOwnerState(options: {
632
+ command: string | undefined;
633
+ sessionName: string | undefined;
634
+ subcommand: string | undefined;
635
+ succeeded: boolean;
636
+ traceOwners: Map<string, TraceOwner>;
637
+ }): void {
638
+ const owner = getTraceOwner(options.command);
639
+ if (!owner || !options.sessionName || !options.succeeded) {
640
+ return;
641
+ }
642
+ if (options.subcommand === "start") {
643
+ options.traceOwners.set(options.sessionName, owner);
644
+ }
645
+ if (options.subcommand === "stop" && options.traceOwners.get(options.sessionName) === owner) {
646
+ options.traceOwners.delete(options.sessionName);
647
+ }
648
+ }
649
+
306
650
  function shouldCaptureNavigationSummary(command: string | undefined, data: unknown): boolean {
307
651
  return (
308
652
  command !== undefined &&
@@ -1025,6 +1369,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1025
1369
  let freshSessionOrdinal = 0;
1026
1370
  let sessionTabTargets = new Map<string, OrderedSessionTabTarget>();
1027
1371
  let sessionTabTargetUpdateOrder = 0;
1372
+ let traceOwners = new Map<string, TraceOwner>();
1028
1373
  let artifactManifest: SessionArtifactManifest | undefined;
1029
1374
  const managedSessionExecutionQueue = new AsyncExecutionQueue();
1030
1375
 
@@ -1040,10 +1385,21 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1040
1385
  artifactManifest = restoreArtifactManifestFromBranch(ctx.sessionManager.getBranch());
1041
1386
  });
1042
1387
 
1043
- pi.on("session_shutdown", async () => {
1388
+ pi.on("session_shutdown", async (event) => {
1389
+ if (event?.reason === "quit") {
1390
+ await managedSessionExecutionQueue.run(async () => {
1391
+ if (!managedSessionActive) return;
1392
+ await closeManagedSession({
1393
+ cwd: managedSessionCwd,
1394
+ sessionName: managedSessionName,
1395
+ timeoutMs: implicitSessionCloseTimeoutMs,
1396
+ });
1397
+ });
1398
+ }
1044
1399
  managedSessionActive = false;
1045
1400
  sessionTabTargets = new Map<string, OrderedSessionTabTarget>();
1046
1401
  sessionTabTargetUpdateOrder = 0;
1402
+ traceOwners = new Map<string, TraceOwner>();
1047
1403
  artifactManifest = undefined;
1048
1404
  await cleanupSecureTempArtifacts();
1049
1405
  });
@@ -1063,7 +1419,8 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1063
1419
  isToolCallEventType("bash", event) &&
1064
1420
  !promptPolicy.allowLegacyAgentBrowserBash &&
1065
1421
  looksLikeDirectAgentBrowserBash(event.input.command) &&
1066
- !isHarmlessAgentBrowserInspectionCommand(event.input.command)
1422
+ !isHarmlessAgentBrowserInspectionCommand(event.input.command) &&
1423
+ !(await isDirectAgentBrowserBashAllowed(ctx.cwd))
1067
1424
  ) {
1068
1425
  return {
1069
1426
  block: true,
@@ -1083,7 +1440,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1083
1440
  parameters: AGENT_BROWSER_PARAMS,
1084
1441
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1085
1442
  const redactedArgs = redactInvocationArgs(params.args);
1086
- const validationError = validateToolArgs(params.args);
1443
+ const validationError = validateToolArgs(params.args) ?? getBatchAnnotateValidationError(params.args, params.stdin);
1087
1444
  if (validationError) {
1088
1445
  return {
1089
1446
  content: [{ type: "text", text: validationError }],
@@ -1091,12 +1448,14 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1091
1448
  isError: true,
1092
1449
  };
1093
1450
  }
1451
+ const preparedArgs = await prepareAgentBrowserArgs(params.args, params.stdin, ctx.cwd);
1452
+ const userRequestedJson = params.args.includes("--json");
1094
1453
 
1095
1454
  const tabTargetUpdateOrder = ++sessionTabTargetUpdateOrder;
1096
1455
  const runTool = async (): Promise<AgentBrowserToolResult> => {
1097
1456
  const sessionMode = params.sessionMode ?? DEFAULT_SESSION_MODE;
1098
1457
  const freshSessionName = createFreshSessionName(managedSessionBaseName, ephemeralSessionSeed, freshSessionOrdinal + 1);
1099
- const executionPlan = buildExecutionPlan(params.args, {
1458
+ const executionPlan = buildExecutionPlan(preparedArgs.args, {
1100
1459
  freshSessionName,
1101
1460
  managedSessionActive,
1102
1461
  managedSessionName,
@@ -1124,7 +1483,28 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1124
1483
  };
1125
1484
  }
1126
1485
 
1127
- const commandTokens = extractCommandTokens(params.args);
1486
+ const commandTokens = extractCommandTokens(preparedArgs.args);
1487
+ const traceOwnerGuardMessage = getTraceOwnerGuardMessage({
1488
+ command: executionPlan.commandInfo.command,
1489
+ sessionName: executionPlan.sessionName,
1490
+ subcommand: executionPlan.commandInfo.subcommand,
1491
+ traceOwners,
1492
+ });
1493
+ if (traceOwnerGuardMessage) {
1494
+ return {
1495
+ content: [{ type: "text", text: traceOwnerGuardMessage }],
1496
+ details: {
1497
+ args: redactedArgs,
1498
+ command: executionPlan.commandInfo.command,
1499
+ compatibilityWorkaround,
1500
+ effectiveArgs: redactedEffectiveArgs,
1501
+ sessionMode,
1502
+ validationError: traceOwnerGuardMessage,
1503
+ ...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
1504
+ },
1505
+ isError: true,
1506
+ };
1507
+ }
1128
1508
  const stdinValidationError = validateStdinCommandContract({
1129
1509
  command: executionPlan.commandInfo.command,
1130
1510
  commandTokens,
@@ -1152,7 +1532,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1152
1532
  let includePinnedNavigationSummary = false;
1153
1533
  let sessionTabCorrection: OpenResultTabCorrection | undefined;
1154
1534
  let processArgs = executionPlan.effectiveArgs;
1155
- let processStdin = params.stdin;
1535
+ let processStdin = preparedArgs.stdin ?? params.stdin;
1156
1536
  if (
1157
1537
  priorSessionTabTarget &&
1158
1538
  shouldPinSessionTabForCommand({
@@ -1283,6 +1663,20 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1283
1663
  presentationEnvelope = pinnedBatchResult.envelope ?? presentationEnvelope;
1284
1664
  navigationSummary = pinnedBatchResult.navigationSummary;
1285
1665
  }
1666
+ const repairedScreenshot = await repairScreenshotArtifact({
1667
+ cwd: ctx.cwd,
1668
+ envelope: presentationEnvelope,
1669
+ request: preparedArgs.screenshotPathRequest,
1670
+ });
1671
+ presentationEnvelope = repairedScreenshot.envelope;
1672
+ const repairedBatchScreenshots = await repairBatchScreenshotArtifacts({
1673
+ cwd: ctx.cwd,
1674
+ envelope: presentationEnvelope,
1675
+ requests: preparedArgs.batchScreenshotPathRequests,
1676
+ });
1677
+ presentationEnvelope = repairedBatchScreenshots.envelope;
1678
+ const screenshotArtifactRequest = repairedScreenshot.request;
1679
+ const batchScreenshotArtifactRequests = repairedBatchScreenshots.requests;
1286
1680
  const parseFailureOutput = parseError
1287
1681
  ? await preserveParseFailureOutput({
1288
1682
  artifactManifest,
@@ -1296,6 +1690,13 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1296
1690
  const envelopeSuccess = plainTextInspection ? true : presentationEnvelope?.success !== false;
1297
1691
  const succeeded = processSucceeded && parseSucceeded && envelopeSuccess;
1298
1692
  const inspectionText = plainTextInspection ? processResult.stdout.trim() : undefined;
1693
+ updateTraceOwnerState({
1694
+ command: executionPlan.commandInfo.command,
1695
+ sessionName: executionPlan.sessionName,
1696
+ subcommand: executionPlan.commandInfo.subcommand,
1697
+ succeeded,
1698
+ traceOwners,
1699
+ });
1299
1700
 
1300
1701
  if (succeeded && !navigationSummary && shouldCaptureNavigationSummary(executionPlan.commandInfo.command, presentationEnvelope?.data)) {
1301
1702
  navigationSummary = await collectNavigationSummary({
@@ -1477,11 +1878,14 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1477
1878
  }
1478
1879
  : await buildToolPresentation({
1479
1880
  artifactManifest,
1881
+ artifactRequest: screenshotArtifactRequest,
1882
+ batchArtifactRequests: batchScreenshotArtifactRequests,
1480
1883
  commandInfo: executionPlan.commandInfo,
1481
1884
  cwd: ctx.cwd,
1482
1885
  envelope: presentationEnvelope,
1483
1886
  errorText,
1484
1887
  persistentArtifactStore,
1888
+ sessionName: executionPlan.sessionName,
1485
1889
  });
1486
1890
  if (parseFailureOutput.artifactManifest) {
1487
1891
  presentation.artifactManifest = parseFailureOutput.artifactManifest;
@@ -1504,20 +1908,29 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
1504
1908
  if (presentation.artifactManifest) {
1505
1909
  artifactManifest = presentation.artifactManifest;
1506
1910
  }
1507
- const contentWithSessionWarnings = aboutBlankSessionMismatch ? [...presentation.content] : presentation.content;
1508
- if (aboutBlankSessionMismatch) {
1509
- const warning = buildAboutBlankWarning(aboutBlankSessionMismatch);
1911
+ const warningText = aboutBlankSessionMismatch ? buildAboutBlankWarning(aboutBlankSessionMismatch) : undefined;
1912
+ const contentWithSessionWarnings = userRequestedJson && !plainTextInspection
1913
+ ? buildJsonVisibleContent({
1914
+ error: presentationEnvelope?.error,
1915
+ presentation,
1916
+ succeeded,
1917
+ warnings: warningText ? [warningText] : undefined,
1918
+ })
1919
+ : warningText
1920
+ ? [...presentation.content]
1921
+ : presentation.content;
1922
+ if (warningText && !userRequestedJson) {
1510
1923
  if (contentWithSessionWarnings[0]?.type === "text") {
1511
1924
  contentWithSessionWarnings[0] = {
1512
1925
  ...contentWithSessionWarnings[0],
1513
- text: `${warning}\n\n${contentWithSessionWarnings[0].text}`,
1926
+ text: `${warningText}\n\n${contentWithSessionWarnings[0].text}`,
1514
1927
  };
1515
1928
  } else {
1516
- contentWithSessionWarnings.unshift({ type: "text", text: warning });
1929
+ contentWithSessionWarnings.unshift({ type: "text", text: warningText });
1517
1930
  }
1518
1931
  }
1519
1932
  const redactedContent = contentWithSessionWarnings.map((item) =>
1520
- item.type === "text" ? { ...item, text: redactSensitiveText(item.text) } : item,
1933
+ item.type === "text" && !(userRequestedJson && !plainTextInspection) ? { ...item, text: redactSensitiveText(item.text) } : item,
1521
1934
  );
1522
1935
 
1523
1936
  return {
@@ -18,6 +18,7 @@ export const QUICK_START_GUIDELINES = [
18
18
  "Common first calls: { args: [\"open\", \"https://example.com\"] } then { args: [\"snapshot\", \"-i\"] }; after navigation, use { args: [\"click\", \"@e2\"] } then { args: [\"snapshot\", \"-i\"] }.",
19
19
  "Common advanced calls: { args: [\"batch\"], stdin: \"[[\\\"open\\\",\\\"https://example.com\\\"],[\\\"snapshot\\\",\\\"-i\\\"]]\" }, { args: [\"eval\", \"--stdin\"], stdin: \"document.title\" }, and { args: [\"--profile\", \"Default\", \"open\", \"https://example.com/account\"], sessionMode: \"fresh\" }.",
20
20
  "High-value command reference: download <selector> <path> saves a file triggered by a click; get title/url/text/html/value/attr/count reads page state; screenshot [path] captures an image; pdf <path> saves a PDF; tab list and tab <tab-id-or-label> inspect or recover the active tab.",
21
+ "For artifact-producing commands, read the visible artifact block for requested path, absolute path, existence, size, type, cwd, and session; details.artifacts contains the same machine-readable metadata. For annotated screenshots inside batch, put --annotate in top-level args (for example { args: [\"--annotate\", \"batch\"], stdin: \"[[\\\"screenshot\\\",\\\"/tmp/page.png\\\"]]\" }) rather than inside the screenshot step.",
21
22
  ] as const;
22
23
 
23
24
  export const BRAVE_SEARCH_PROMPT_GUIDELINE =
@@ -173,10 +173,27 @@ function getStreamSummary(data: Record<string, unknown>): string | undefined {
173
173
  ];
174
174
  if (typeof data.port === "number") {
175
175
  lines.push(`Port: ${data.port}`);
176
+ lines.push(`WebSocket URL: ${getStreamWebSocketUrl(data.port)}`);
177
+ lines.push(`Frame format: JSON messages with base64 JPEG frame data`);
176
178
  }
177
179
  return lines.join("\n");
178
180
  }
179
181
 
182
+ function getStreamWebSocketUrl(port: number): string {
183
+ return `ws://127.0.0.1:${port}`;
184
+ }
185
+
186
+ function enrichStreamStatusData(commandInfo: CommandInfo, data: unknown): unknown {
187
+ if (commandInfo.command !== "stream" || commandInfo.subcommand !== "status" || !isRecord(data) || typeof data.port !== "number") {
188
+ return data;
189
+ }
190
+ return {
191
+ ...data,
192
+ frameFormat: "JSON messages with base64 JPEG frame data",
193
+ wsUrl: getStreamWebSocketUrl(data.port),
194
+ };
195
+ }
196
+
180
197
  function getArrayField(data: Record<string, unknown>, key: string): unknown[] | undefined {
181
198
  return Array.isArray(data[key]) ? data[key] : undefined;
182
199
  }
@@ -662,18 +679,28 @@ function extractPathStrings(data: unknown): string[] {
662
679
  return [...new Set(paths)];
663
680
  }
664
681
 
682
+ interface ArtifactRequestContext {
683
+ absolutePath: string;
684
+ path: string;
685
+ status?: FileArtifactMetadata["status"];
686
+ tempPath?: string;
687
+ }
688
+
665
689
  async function buildFileArtifactMetadata(options: {
690
+ artifactRequest?: ArtifactRequestContext;
666
691
  commandInfo: CommandInfo;
667
692
  cwd: string;
668
693
  path: string;
694
+ sessionName?: string;
669
695
  }): Promise<FileArtifactMetadata | undefined> {
670
696
  const kind = getArtifactKind(options.commandInfo);
671
697
  if (!kind) {
672
698
  return undefined;
673
699
  }
674
700
 
675
- const absolutePath = resolve(options.cwd, options.path);
676
- const extension = extname(options.path).toLowerCase() || undefined;
701
+ const absolutePath = options.artifactRequest?.absolutePath ?? resolve(options.cwd, options.path);
702
+ const displayPath = options.artifactRequest?.path ?? options.path;
703
+ const extension = extname(absolutePath || options.path).toLowerCase() || undefined;
677
704
  let exists: boolean | undefined;
678
705
  let sizeBytes: number | undefined;
679
706
  try {
@@ -686,20 +713,32 @@ async function buildFileArtifactMetadata(options: {
686
713
 
687
714
  return {
688
715
  absolutePath,
716
+ artifactType: kind,
689
717
  command: options.commandInfo.command,
718
+ cwd: options.cwd,
690
719
  exists,
691
720
  extension,
692
721
  kind,
693
722
  mediaType: extension ? ARTIFACT_EXTENSION_TO_MEDIA_TYPE[extension] : undefined,
694
- path: options.path,
723
+ path: displayPath,
724
+ requestedPath: options.artifactRequest?.path,
725
+ session: options.sessionName,
695
726
  sizeBytes,
727
+ status: options.artifactRequest?.status ?? (exists === false ? "missing" : "saved"),
696
728
  subcommand: options.commandInfo.subcommand,
729
+ tempPath: options.artifactRequest?.tempPath,
697
730
  };
698
731
  }
699
732
 
700
- async function extractFileArtifacts(commandInfo: CommandInfo, cwd: string, data: unknown): Promise<FileArtifactMetadata[]> {
701
- const candidates = extractPathStrings(data);
702
- const artifacts = await Promise.all(candidates.map((path) => buildFileArtifactMetadata({ commandInfo, cwd, path })));
733
+ async function extractFileArtifacts(options: {
734
+ artifactRequest?: ArtifactRequestContext;
735
+ commandInfo: CommandInfo;
736
+ cwd: string;
737
+ data: unknown;
738
+ sessionName?: string;
739
+ }): Promise<FileArtifactMetadata[]> {
740
+ const candidates = extractPathStrings(options.data);
741
+ const artifacts = await Promise.all(candidates.map((path) => buildFileArtifactMetadata({ ...options, path })));
703
742
  return artifacts.filter((artifact): artifact is FileArtifactMetadata => artifact !== undefined);
704
743
  }
705
744
 
@@ -708,12 +747,15 @@ function buildManifestEntriesForFileArtifacts(artifacts: FileArtifactMetadata[],
708
747
  absolutePath: artifact.absolutePath,
709
748
  command: artifact.command,
710
749
  createdAtMs: nowMs,
750
+ cwd: artifact.cwd,
711
751
  exists: artifact.exists,
712
752
  extension: artifact.extension,
713
753
  kind: artifact.kind,
714
754
  mediaType: artifact.mediaType,
715
755
  path: artifact.path,
756
+ requestedPath: artifact.requestedPath,
716
757
  retentionState: artifact.exists === false ? "missing" : "live",
758
+ session: artifact.session,
717
759
  sizeBytes: artifact.sizeBytes,
718
760
  storageScope: "explicit-path",
719
761
  subcommand: artifact.subcommand,
@@ -761,17 +803,37 @@ function formatArtifactSummary(artifacts: FileArtifactMetadata[]): string | unde
761
803
  }
762
804
 
763
805
  function formatArtifactMetadataLines(artifacts: FileArtifactMetadata[]): string[] {
764
- return artifacts.map((artifact) => {
806
+ return artifacts.map((artifact, index) => {
765
807
  if (isRecordingStartArtifact(artifact)) {
766
- return `${formatArtifactLabel(artifact)}: ${artifact.path}`;
808
+ return [
809
+ `${formatArtifactLabel(artifact)}: ${artifact.path}`,
810
+ `Artifact type: ${artifact.kind}`,
811
+ `Requested path: ${artifact.requestedPath ?? artifact.path}`,
812
+ `Absolute path: ${artifact.absolutePath}`,
813
+ `Exists: ${artifact.exists === true}`,
814
+ `Status: ${artifact.status ?? (artifact.exists === false ? "missing" : "saved")}`,
815
+ artifact.session ? `Session: ${artifact.session}` : undefined,
816
+ artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
817
+ `Machine data: details.artifacts[${index}]`,
818
+ ].filter((item): item is string => item !== undefined).join("\n");
767
819
  }
768
820
 
769
- const suffix = [
770
- artifact.mediaType,
771
- typeof artifact.sizeBytes === "number" ? formatByteCount(artifact.sizeBytes) : undefined,
821
+ return [
822
+ `${formatArtifactLabel(artifact)}: ${artifact.path}`,
823
+ `Artifact type: ${artifact.kind}`,
824
+ `Requested path: ${artifact.requestedPath ?? artifact.path}`,
825
+ `Absolute path: ${artifact.absolutePath}`,
826
+ `Exists: ${artifact.exists === true}`,
772
827
  artifact.exists === false ? "not found on disk" : undefined,
773
- ].filter((item): item is string => item !== undefined).join(", ");
774
- return suffix ? `${formatArtifactLabel(artifact)}: ${artifact.path} (${suffix})` : `${formatArtifactLabel(artifact)}: ${artifact.path}`;
828
+ typeof artifact.sizeBytes === "number" ? `Size: ${formatByteCount(artifact.sizeBytes)}` : undefined,
829
+ typeof artifact.sizeBytes === "number" ? `Size bytes: ${artifact.sizeBytes}` : undefined,
830
+ `Status: ${artifact.status ?? (artifact.exists === false ? "missing" : "saved")}`,
831
+ artifact.tempPath ? `Temp path: ${artifact.tempPath}` : undefined,
832
+ artifact.mediaType ? `Media type: ${artifact.mediaType}` : undefined,
833
+ artifact.session ? `Session: ${artifact.session}` : undefined,
834
+ artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
835
+ `Machine data: details.artifacts[${index}]`,
836
+ ].filter((item): item is string => item !== undefined).join("\n");
775
837
  });
776
838
  }
777
839
 
@@ -1020,12 +1082,14 @@ function getBatchFailureDetails(steps: Array<{ details: BatchStepPresentationDet
1020
1082
 
1021
1083
  async function buildBatchStepPresentation(options: {
1022
1084
  artifactManifest?: SessionArtifactManifest;
1085
+ artifactRequest?: ArtifactRequestContext;
1023
1086
  cwd: string;
1024
1087
  index: number;
1025
1088
  item: AgentBrowserBatchResult;
1026
1089
  persistentArtifactStore?: PersistentSessionArtifactStore;
1090
+ sessionName?: string;
1027
1091
  }): Promise<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> {
1028
- const { artifactManifest, cwd, index, item, persistentArtifactStore } = options;
1092
+ const { artifactManifest, artifactRequest, cwd, index, item, persistentArtifactStore, sessionName } = options;
1029
1093
  const command = isStringArray(item.command) ? item.command : undefined;
1030
1094
  const commandText = formatBatchStepCommand(command, index);
1031
1095
 
@@ -1052,10 +1116,12 @@ async function buildBatchStepPresentation(options: {
1052
1116
 
1053
1117
  const presentation = await buildToolPresentation({
1054
1118
  artifactManifest,
1119
+ artifactRequest,
1055
1120
  commandInfo: parseCommandInfo(command ?? []),
1056
1121
  cwd,
1057
1122
  envelope: { data: item.result, success: true },
1058
1123
  persistentArtifactStore,
1124
+ sessionName,
1059
1125
  });
1060
1126
  const fullOutputPaths = getPresentationPaths({
1061
1127
  primaryPath: presentation.fullOutputPath,
@@ -1090,24 +1156,28 @@ async function buildBatchStepPresentation(options: {
1090
1156
 
1091
1157
  async function buildBatchPresentation(options: {
1092
1158
  artifactManifest?: SessionArtifactManifest;
1159
+ artifactRequests?: Array<ArtifactRequestContext | undefined>;
1093
1160
  cwd: string;
1094
1161
  data: AgentBrowserBatchResult[];
1095
1162
  persistentArtifactStore?: PersistentSessionArtifactStore;
1163
+ sessionName?: string;
1096
1164
  summary: string;
1097
1165
  }): Promise<ToolPresentation> {
1098
- const { cwd, data, persistentArtifactStore, summary } = options;
1166
+ const { artifactRequests, cwd, data, persistentArtifactStore, sessionName, summary } = options;
1099
1167
  const steps: Array<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> = [];
1100
1168
  const protectedPersistentPaths: string[] = [];
1101
1169
  let currentArtifactManifest = options.artifactManifest;
1102
1170
  for (const [index, item] of data.entries()) {
1103
1171
  const step = await buildBatchStepPresentation({
1104
1172
  artifactManifest: currentArtifactManifest,
1173
+ artifactRequest: artifactRequests?.[index],
1105
1174
  cwd,
1106
1175
  index,
1107
1176
  item,
1108
1177
  persistentArtifactStore: persistentArtifactStore
1109
1178
  ? { ...persistentArtifactStore, protectedPaths: protectedPersistentPaths }
1110
1179
  : undefined,
1180
+ sessionName,
1111
1181
  });
1112
1182
  steps.push(step);
1113
1183
  currentArtifactManifest = step.presentation.artifactManifest ?? currentArtifactManifest;
@@ -1522,13 +1592,16 @@ async function compactLargePresentationOutput(options: {
1522
1592
 
1523
1593
  export async function buildToolPresentation(options: {
1524
1594
  artifactManifest?: SessionArtifactManifest;
1595
+ artifactRequest?: ArtifactRequestContext;
1596
+ batchArtifactRequests?: Array<ArtifactRequestContext | undefined>;
1525
1597
  commandInfo: CommandInfo;
1526
1598
  cwd: string;
1527
1599
  envelope?: AgentBrowserEnvelope;
1528
1600
  errorText?: string;
1529
1601
  persistentArtifactStore?: PersistentSessionArtifactStore;
1602
+ sessionName?: string;
1530
1603
  }): Promise<ToolPresentation> {
1531
- const { artifactManifest, commandInfo, cwd, envelope, errorText, persistentArtifactStore } = options;
1604
+ const { artifactManifest, artifactRequest, commandInfo, cwd, envelope, errorText, persistentArtifactStore, sessionName } = options;
1532
1605
  if (errorText) {
1533
1606
  const hintedErrorText = appendSelectorRecoveryHint(redactModelFacingText(errorText));
1534
1607
  return {
@@ -1537,14 +1610,14 @@ export async function buildToolPresentation(options: {
1537
1610
  };
1538
1611
  }
1539
1612
 
1540
- const data = envelope?.data;
1541
- const artifacts = await extractFileArtifacts(commandInfo, cwd, data);
1613
+ const data = enrichStreamStatusData(commandInfo, envelope?.data);
1614
+ const artifacts = await extractFileArtifacts({ artifactRequest, commandInfo, cwd, data, sessionName });
1542
1615
  const artifactSummary = formatArtifactSummary(artifacts);
1543
1616
  const summary = artifactSummary ?? formatSummary(commandInfo, data);
1544
1617
  const artifactText = artifacts.length > 0 ? formatArtifactMetadataLines(artifacts).join("\n") : undefined;
1545
1618
  const presentation =
1546
1619
  commandInfo.command === "batch" && Array.isArray(data)
1547
- ? await buildBatchPresentation({ artifactManifest, cwd, data: data as AgentBrowserBatchResult[], persistentArtifactStore, summary })
1620
+ ? await buildBatchPresentation({ artifactManifest, artifactRequests: options.batchArtifactRequests, cwd, data: data as AgentBrowserBatchResult[], persistentArtifactStore, sessionName, summary })
1548
1621
  : commandInfo.command === "snapshot" && isRecord(data)
1549
1622
  ? await buildSnapshotPresentation(data, persistentArtifactStore, artifactManifest)
1550
1623
  : {
@@ -1564,7 +1637,7 @@ export async function buildToolPresentation(options: {
1564
1637
  }
1565
1638
  }
1566
1639
 
1567
- const imagePath = extractImagePath(commandInfo, cwd, data);
1640
+ const imagePath = artifactRequest?.absolutePath ?? extractImagePath(commandInfo, cwd, data);
1568
1641
  const presentationWithImage = imagePath ? await attachInlineImage(presentation, imagePath) : presentation;
1569
1642
  const compactedPresentation = await compactLargePresentationOutput({
1570
1643
  artifactManifest,
@@ -21,16 +21,24 @@ export interface AgentBrowserBatchResult {
21
21
 
22
22
  export type FileArtifactKind = "download" | "file" | "har" | "image" | "pdf" | "profile" | "trace" | "video";
23
23
 
24
+ export type FileArtifactStatus = "missing" | "repaired-from-temp" | "saved" | "upstream-temp-only";
25
+
24
26
  export interface FileArtifactMetadata {
25
27
  absolutePath: string;
28
+ artifactType?: FileArtifactKind;
26
29
  command?: string;
30
+ cwd?: string;
27
31
  exists?: boolean;
28
32
  extension?: string;
29
33
  kind: FileArtifactKind;
30
34
  mediaType?: string;
31
35
  path: string;
36
+ requestedPath?: string;
37
+ session?: string;
32
38
  sizeBytes?: number;
39
+ status?: FileArtifactStatus;
33
40
  subcommand?: string;
41
+ tempPath?: string;
34
42
  }
35
43
 
36
44
  export interface SavedFilePresentationDetails {
@@ -49,13 +57,16 @@ export interface SessionArtifactManifestEntry {
49
57
  absolutePath?: string;
50
58
  command?: string;
51
59
  createdAtMs: number;
60
+ cwd?: string;
52
61
  evictedAtMs?: number;
53
62
  exists?: boolean;
54
63
  extension?: string;
55
64
  kind: FileArtifactKind | "spill";
56
65
  mediaType?: string;
57
66
  path: string;
67
+ requestedPath?: string;
58
68
  retentionState: ArtifactRetentionState;
69
+ session?: string;
59
70
  sizeBytes?: number;
60
71
  storageScope: ArtifactStorageScope;
61
72
  subcommand?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
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)",