pi-agent-browser-native 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +20 -5
  3. package/dist/extensions/agent-browser/index.js +138 -75
  4. package/dist/extensions/agent-browser/lib/command-taxonomy.js +6 -5
  5. package/dist/extensions/agent-browser/lib/electron/cleanup.js +10 -1
  6. package/dist/extensions/agent-browser/lib/input-modes/params.js +20 -7
  7. package/dist/extensions/agent-browser/lib/launch-scoped-flags.js +0 -1
  8. package/dist/extensions/agent-browser/lib/managed-session-restore.js +13 -12
  9. package/dist/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.js +6 -25
  10. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +2 -3
  11. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +10 -3
  12. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +3 -0
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/wait-timeouts.js +1 -1
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +104 -117
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +61 -35
  16. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +100 -135
  17. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +3 -1
  18. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +3 -1
  19. package/dist/extensions/agent-browser/lib/page-target-validation.js +10 -10
  20. package/dist/extensions/agent-browser/lib/parsing.js +7 -0
  21. package/dist/extensions/agent-browser/lib/playbook.js +5 -8
  22. package/dist/extensions/agent-browser/lib/process.js +23 -7
  23. package/dist/extensions/agent-browser/lib/recording-reservations.js +3 -1
  24. package/dist/extensions/agent-browser/lib/results/envelope.js +4 -1
  25. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +45 -43
  26. package/dist/extensions/agent-browser/lib/results/recovery-actions.js +4 -4
  27. package/dist/extensions/agent-browser/lib/runtime.js +18 -2
  28. package/dist/extensions/agent-browser/lib/session-page-state.js +29 -10
  29. package/docs/ARCHITECTURE.md +7 -4
  30. package/docs/COMMAND_REFERENCE.md +22 -15
  31. package/docs/ELECTRON.md +6 -6
  32. package/docs/RELEASE.md +14 -5
  33. package/docs/REQUIREMENTS.md +1 -1
  34. package/docs/SUPPORT_MATRIX.md +13 -3
  35. package/docs/TOOL_CONTRACT.md +33 -22
  36. package/package.json +1 -1
@@ -1,22 +1,43 @@
1
- import { readFile, stat } from "node:fs/promises";
1
+ import { open, readFile, stat } from "node:fs/promises";
2
2
  import { extname, resolve } from "node:path";
3
3
  import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
4
+ import { getExplicitArtifactDestination } from "../../orchestration/browser-run/artifact-paths.js";
4
5
  import { isRecord, parsePositiveInteger } from "../../parsing.js";
6
+ import { extractUpstreamCommandTokens } from "../../runtime.js";
5
7
  import { formatSessionArtifactRetentionSummary, getSessionArtifactManifestEntryKey, isPendingRecordingArtifact, isPendingRecordingCommand, mergeSessionArtifactManifest, } from "../artifact-manifest.js";
6
8
  import { classifyAgentBrowserSuccessCategory } from "../categories.js";
7
- const IMAGE_EXTENSION_TO_MIME_TYPE = {
8
- ".gif": "image/gif",
9
- ".jpeg": "image/jpeg",
10
- ".jpg": "image/jpeg",
11
- ".png": "image/png",
12
- ".webp": "image/webp",
13
- };
9
+ const PNG_HEADER = Buffer.from("89504e470d0a1a0a0000000d49484452", "hex");
14
10
  const INLINE_IMAGE_MAX_BYTES_ENV = "PI_AGENT_BROWSER_INLINE_IMAGE_MAX_BYTES";
15
11
  const DEFAULT_INLINE_IMAGE_MAX_BYTES = 5 * 1_024 * 1_024;
16
12
  const ARTIFACT_MTIME_TOLERANCE_MS = 2_000;
17
- function getImageMimeType(filePath) {
18
- const extension = extname(filePath).toLowerCase();
19
- return IMAGE_EXTENSION_TO_MIME_TYPE[extension];
13
+ function getImageMimeType(bytes) {
14
+ if (bytes.length < 16)
15
+ return undefined;
16
+ if (bytes.subarray(0, 16).equals(PNG_HEADER))
17
+ return "image/png";
18
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff && bytes[3] !== 0xf7)
19
+ return "image/jpeg";
20
+ if (["GIF87a", "GIF89a"].includes(bytes.toString("utf8", 0, 6)))
21
+ return "image/gif";
22
+ if (bytes.toString("utf8", 0, 4) === "RIFF" && bytes.toString("utf8", 8, 12) === "WEBP")
23
+ return "image/webp";
24
+ return undefined;
25
+ }
26
+ async function getFileImageMimeType(path) {
27
+ try {
28
+ const file = await open(path, "r");
29
+ try {
30
+ const bytes = Buffer.alloc(16);
31
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
32
+ return getImageMimeType(bytes.subarray(0, bytesRead));
33
+ }
34
+ finally {
35
+ await file.close();
36
+ }
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
20
41
  }
21
42
  function getInlineImageMaxBytes(env = process.env) {
22
43
  return parsePositiveInteger(env[INLINE_IMAGE_MAX_BYTES_ENV]) ?? DEFAULT_INLINE_IMAGE_MAX_BYTES;
@@ -74,17 +95,6 @@ const PATH_FIELD_CANDIDATES = [
74
95
  "profilePath",
75
96
  "videoPath",
76
97
  ];
77
- const ARTIFACT_EXTENSION_TO_MEDIA_TYPE = {
78
- ".cpuprofile": "application/json",
79
- ".har": "application/json",
80
- ".html": "text/html",
81
- ".json": "application/json",
82
- ".pdf": "application/pdf",
83
- ".txt": "text/plain",
84
- ".webm": "video/webm",
85
- ".zip": "application/zip",
86
- ...IMAGE_EXTENSION_TO_MIME_TYPE,
87
- };
88
98
  function isDownloadWaitSubcommand(subcommand) {
89
99
  return subcommand === "--download" || subcommand === "-d";
90
100
  }
@@ -153,6 +163,7 @@ async function buildFileArtifactMetadata(options) {
153
163
  const pendingRecording = isPendingRecordingCommand(options.commandInfo.command, options.commandInfo.subcommand, kind);
154
164
  let exists;
155
165
  let sizeBytes;
166
+ let mediaType;
156
167
  let stale = false;
157
168
  let updatedAtMs;
158
169
  if (!pendingRecording) {
@@ -161,6 +172,7 @@ async function buildFileArtifactMetadata(options) {
161
172
  exists = true;
162
173
  sizeBytes = fileStats.size;
163
174
  updatedAtMs = fileStats.mtimeMs;
175
+ mediaType = fileStats.isFile() ? await getFileImageMimeType(absolutePath) : undefined;
164
176
  const commandCreatesArtifact = !(options.commandInfo.command === "wait" && isDownloadWaitSubcommand(options.commandInfo.subcommand));
165
177
  stale = commandCreatesArtifact && artifactMtimeIsOutsideCommandWindow(updatedAtMs, options.artifactMinUpdatedAtMs, options.artifactMaxUpdatedAtMs);
166
178
  }
@@ -176,11 +188,11 @@ async function buildFileArtifactMetadata(options) {
176
188
  exists,
177
189
  extension,
178
190
  kind,
179
- mediaType: extension ? ARTIFACT_EXTENSION_TO_MEDIA_TYPE[extension] : undefined,
191
+ mediaType,
180
192
  namespace: options.namespace,
181
193
  path: displayPath,
182
194
  recordingState: pendingRecording ? "openRecording" : undefined,
183
- requestedPath: options.artifactRequest?.path,
195
+ requestedPath: options.artifactRequest?.path ?? getExplicitArtifactDestination(extractUpstreamCommandTokens(options.commandInfo.commandTokens ?? [])),
184
196
  session: options.sessionName,
185
197
  sizeBytes,
186
198
  status: pendingRecording ? "pending" : exists === false ? "missing" : stale ? "stale" : options.artifactRequest?.status ?? "saved",
@@ -212,7 +224,7 @@ async function buildPreviousRestartRecordingArtifact(options) {
212
224
  exists: true,
213
225
  extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
214
226
  kind: "video",
215
- mediaType: previousRecording.mediaType,
227
+ mediaType: fileStats.isFile() ? await getFileImageMimeType(absolutePath) : undefined,
216
228
  namespace: previousRecording.namespace ?? options.namespace,
217
229
  path: previousRecording.path,
218
230
  requestedPath: previousRecording.requestedPath,
@@ -232,7 +244,6 @@ async function buildPreviousRestartRecordingArtifact(options) {
232
244
  exists: false,
233
245
  extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
234
246
  kind: "video",
235
- mediaType: previousRecording.mediaType,
236
247
  namespace: previousRecording.namespace ?? options.namespace,
237
248
  path: previousRecording.path,
238
249
  requestedPath: previousRecording.requestedPath,
@@ -449,13 +460,12 @@ export function formatArtifactMetadataLines(artifacts) {
449
460
  return [
450
461
  `${formatArtifactLabel(artifact)}: ${artifact.path}`,
451
462
  `Artifact type: ${artifact.kind}`,
452
- `Requested path: ${artifact.requestedPath ?? artifact.path}`,
463
+ artifact.requestedPath ? `Requested path: ${artifact.requestedPath}` : undefined,
453
464
  `Absolute path: ${artifact.absolutePath}`,
454
465
  "Exists: pending until record stop",
455
466
  `Status: ${artifact.status ?? "pending"}`,
456
467
  `Recording state: ${artifact.recordingState ?? "openRecording"}`,
457
468
  `Will exist on stop: ${artifact.willExistOnStop !== false}`,
458
- artifact.subcommand === "start" ? "Page state: record start uses a fresh active page for video capture; prior in-page DOM and JavaScript state does not carry over. Take a fresh snapshot before continuing." : undefined,
459
469
  artifact.session ? `Session: ${artifact.session}` : undefined,
460
470
  artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
461
471
  `Machine data: details.artifacts[${index}]`,
@@ -464,14 +474,14 @@ export function formatArtifactMetadataLines(artifacts) {
464
474
  return [
465
475
  `${formatArtifactLabel(artifact)}: ${artifact.path}`,
466
476
  `Artifact type: ${artifact.kind}`,
467
- `Requested path: ${artifact.requestedPath ?? artifact.path}`,
477
+ artifact.requestedPath ? `Requested path: ${artifact.requestedPath}` : undefined,
468
478
  `Absolute path: ${artifact.absolutePath}`,
469
479
  `Exists: ${artifact.exists === true}`,
470
480
  artifact.exists === false ? "not found on disk" : undefined,
471
481
  typeof artifact.sizeBytes === "number" ? `Size: ${formatByteCount(artifact.sizeBytes)}` : undefined,
472
482
  typeof artifact.sizeBytes === "number" ? `Size bytes: ${artifact.sizeBytes}` : undefined,
473
483
  `Status: ${artifact.status ?? (artifact.exists === false ? "missing" : "saved")}`,
474
- artifact.tempPath ? `Temp path: ${artifact.tempPath}` : undefined,
484
+ artifact.tempPath ? `Reported path: ${artifact.tempPath}` : undefined,
475
485
  artifact.mediaType ? `Media type: ${artifact.mediaType}` : undefined,
476
486
  artifact.session ? `Session: ${artifact.session}` : undefined,
477
487
  artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
@@ -519,21 +529,10 @@ export function extractImagePath(commandInfo, cwd, data) {
519
529
  if (!isTrustedScreenshotOutput(commandInfo)) {
520
530
  return undefined;
521
531
  }
522
- if (typeof data === "string") {
523
- const mimeType = getImageMimeType(data);
524
- return mimeType ? resolve(cwd, data) : undefined;
525
- }
526
- if (!isRecord(data) || typeof data.path !== "string") {
527
- return undefined;
528
- }
529
- const mimeType = getImageMimeType(data.path);
530
- return mimeType ? resolve(cwd, data.path) : undefined;
532
+ const path = typeof data === "string" ? data : isRecord(data) && typeof data.path === "string" ? data.path : undefined;
533
+ return path?.trim() && !isNonFileArtifactPathCandidate(path) ? resolve(cwd, path) : undefined;
531
534
  }
532
535
  export async function attachInlineImage(presentation, imagePath) {
533
- const mimeType = getImageMimeType(imagePath);
534
- if (!mimeType) {
535
- return presentation;
536
- }
537
536
  try {
538
537
  const fileStats = await stat(imagePath);
539
538
  const inlineImageMaxBytes = getInlineImageMaxBytes();
@@ -543,6 +542,9 @@ export async function attachInlineImage(presentation, imagePath) {
543
542
  return presentation;
544
543
  }
545
544
  const file = await readFile(imagePath);
545
+ const mimeType = getImageMimeType(file);
546
+ if (!mimeType)
547
+ return presentation;
546
548
  presentation.content.push({ type: "image", data: file.toString("base64"), mimeType });
547
549
  presentation.imagePath = imagePath;
548
550
  return presentation;
@@ -45,11 +45,11 @@ function buildTabSnapshotRecoveryAction(options) {
45
45
  });
46
46
  }
47
47
  return buildNextToolAction({
48
- args: options.sessionArgs(["batch"]),
48
+ args: options.sessionArgs(["batch", "--bail"]),
49
49
  id: options.id,
50
- reason: `${options.reason} The batch selects the stable tab before snapshotting.`,
51
- safety: `${options.safety} The snapshot retry is atomic with tab selection, so it does not assume the intended tab is already active.`,
52
- stdin: JSON.stringify([["tab", options.tabId], ["snapshot", "-i"]]),
50
+ reason: `${options.reason} The batch selects and verifies the stable tab before snapshotting.`,
51
+ safety: `${options.safety} A failed tab selection or URL check stops before the snapshot.`,
52
+ stdin: JSON.stringify([["tab", options.tabId], ["get", "url"], ["snapshot", "-i"]]),
53
53
  });
54
54
  }
55
55
  export function buildRecoveryNextActions(recovery) {
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { basename } from "node:path";
3
3
  import { extractUpstreamCommandTokens, findCommandStartIndex, parseArgvDescriptor, parseCommandInfo, } from "./argv-descriptor.js";
4
4
  import { batchHasSuccessfulCloseAll, getSuccessfulBatchCloseLifecycle } from "./batch-lifecycle.js";
5
- import { canonicalizeAgentBrowserNamespace, extractExplicitNamespace, extractExplicitSessionName, getAgentBrowserSessionIdentityKey, getBooleanFlagValue, GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, GLOBAL_VALUE_FLAGS_ALLOWING_DASH_VALUE, isAgentBrowserSessionIdentityKeyInNamespace, isUpstreamEnvFlagEnabled, PREVALIDATED_VALUE_FLAGS, resolveAgentBrowserNamespace, scanUpstreamGlobalFlagOccurrences, } from "./argv-grammar.js";
5
+ import { canonicalizeAgentBrowserNamespace, extractExplicitNamespace, extractExplicitSessionName, getAgentBrowserSessionIdentityKey, getBooleanFlagValue, GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, GLOBAL_VALUE_FLAGS_ALLOWING_DASH_VALUE, isAgentBrowserSessionIdentityKeyInNamespace, isUpstreamEnvFlagEnabled, PREVALIDATED_VALUE_FLAGS, resolveAgentBrowserNamespace, scanUpstreamGlobalFlagOccurrences, stripUpstreamGlobalFlags, } from "./argv-grammar.js";
6
6
  import { needsManagedSession } from "./command-policy.js";
7
7
  import { isCloseAllCommand, isCloseCommand, isOpenNavigationCommand } from "./command-taxonomy.js";
8
8
  import { hasLaunchScopedFlagToken, LAUNCH_SCOPED_FLAG_DEFINITIONS, LAUNCH_SCOPED_FLAG_LABEL, } from "./launch-scoped-flags.js";
@@ -607,6 +607,18 @@ function getUnsupportedInlineWaitDownloadError(args) {
607
607
  return undefined;
608
608
  return `agent-browser ${TARGET_AGENT_BROWSER_VERSION} does not support \`wait --download=<path>\`. Pass the optional path as a separate argument: \`wait --download <path>\` (or \`wait -d <path>\`).`;
609
609
  }
610
+ function getBareNoSandboxValidationError(args, batchStep) {
611
+ // Native batch rows skip global parsing; --args is effective only on the outer CLI call.
612
+ const tokens = batchStep ? args : stripUpstreamGlobalFlags(args);
613
+ const command = tokens[0];
614
+ const leading = command === "--no-sandbox";
615
+ if (!leading && (!isOpenNavigationCommand(command) || !tokens.slice(1).includes("--no-sandbox")))
616
+ return undefined;
617
+ const explanation = leading
618
+ ? "`--no-sandbox` is not an agent-browser command."
619
+ : `\`--no-sandbox\` is ignored as an option by \`${command}\`.`;
620
+ return `${explanation} It is a Chromium launch argument. Put it in top-level \`--args\` and start a fresh session: { args: ["--args", "--no-sandbox", "open", "https://example.com"], sessionMode: "fresh" }. For batch, put --args before batch, not inside a step.`;
621
+ }
610
622
  export function validateToolArgs(args, options = {}) {
611
623
  if (args.length === 0) {
612
624
  return "`args` must contain at least one agent-browser command token.";
@@ -623,7 +635,8 @@ export function validateToolArgs(args, options = {}) {
623
635
  const invalidValueFlag = inspection ? undefined : getInvalidValueFlagDetails(args, !options.batchStep);
624
636
  if (invalidValueFlag?.reason === "unsupported-assignment")
625
637
  return formatInvalidValueFlagError(invalidValueFlag, options.batchStep);
626
- return getBareMcpValidationError(args) ?? getSingleKeyCommandValidationError(args) ?? getUnsupportedInlineWaitDownloadError(args);
638
+ return (inspection ? undefined : getBareNoSandboxValidationError(args, options.batchStep === true))
639
+ ?? getBareMcpValidationError(args) ?? getSingleKeyCommandValidationError(args) ?? getUnsupportedInlineWaitDownloadError(args);
627
640
  }
628
641
  function getInvalidValueFlagDetails(args, allowRestoreAssignment = true) {
629
642
  for (let index = 0; index < args.length; index += 1) {
@@ -885,6 +898,9 @@ export function buildExecutionPlan(args, options) {
885
898
  managedSessionName = options.freshSessionName;
886
899
  sessionName = options.freshSessionName;
887
900
  }
901
+ if (commandInfo.command !== undefined && !sessionName && !explicitNamespacePresent) {
902
+ namespace = resolveAgentBrowserNamespace(args, getAgentBrowserProcessEnvironment().AGENT_BROWSER_NAMESPACE);
903
+ }
888
904
  const targetsActiveManagedSession = options.managedSessionActive
889
905
  && sessionName
890
906
  && getAgentBrowserSessionIdentityKey(sessionName, namespace) === getAgentBrowserSessionIdentityKey(options.managedSessionName, options.managedSessionNamespace);
@@ -1,7 +1,7 @@
1
1
  import { extractUpstreamCommandTokens } from "./argv-descriptor.js";
2
2
  import { getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "./argv-grammar.js";
3
3
  import { batchHasSuccessfulCloseAll, getSuccessfulBatchCloseLifecycle } from "./batch-lifecycle.js";
4
- import { isCloseAllCommand, isCloseCommand, isReadOnlyDiagnosticSessionTargetCommand, isRecordPageTransitionCommand, isUnverifiedPageTransitionCommand, isWebMcpPageMutationCommand } from "./command-taxonomy.js";
4
+ import { isCloseAllCommand, isCloseCommand, isReadOnlyDiagnosticSessionTargetCommand, isRecordPageTransitionCommand, isUnverifiedPageTransitionCommand, isWebMcpPageMutationCommand, isWindowOrDiffPageTransitionCommand } from "./command-taxonomy.js";
5
5
  import { isRecord } from "./parsing.js";
6
6
  import { getEditableRefEvidence } from "./results/editable-ref-evidence.js";
7
7
  import { enrichSnapshotRefEntries, getSnapshotRefEntries } from "./results/snapshot-refs.js";
@@ -24,8 +24,8 @@ export function normalizeSessionTabTarget(target) {
24
24
  if (!target) {
25
25
  return undefined;
26
26
  }
27
- const url = normalizeComparableUrl(target.url);
28
- if (!url) {
27
+ const url = target.url?.trim();
28
+ if (!url || normalizeComparableUrl(url) === undefined) {
29
29
  return undefined;
30
30
  }
31
31
  const title = target.title?.trim();
@@ -38,7 +38,7 @@ export function isAboutBlankSessionTabTarget(target) {
38
38
  return isAboutBlankUrl(target?.url);
39
39
  }
40
40
  export function commandExplicitlyTargetsAboutBlank(commandTokens) {
41
- return commandTokens.some((token) => isAboutBlankUrl(token));
41
+ return (commandTokens[0] === "window" && commandTokens[1] === "new") || commandTokens.some((token) => isAboutBlankUrl(token));
42
42
  }
43
43
  export function targetsMatch(left, right) {
44
44
  if (!left || !right)
@@ -90,10 +90,15 @@ export function extractSessionTabTargetFromBatchResults(data) {
90
90
  let currentTarget;
91
91
  let pendingTitle;
92
92
  for (const item of data) {
93
- if (!isRecord(item) || item.success === false) {
93
+ if (!isRecord(item))
94
94
  continue;
95
+ const [name, subcommand] = extractUpstreamCommandTokens(extractBatchResultCommand(item));
96
+ if (isWindowOrDiffPageTransitionCommand(name, subcommand)) {
97
+ currentTarget = undefined;
98
+ pendingTitle = undefined;
95
99
  }
96
- const [name, subcommand] = extractBatchResultCommand(item);
100
+ if (item.success === false)
101
+ continue;
97
102
  const result = item.result;
98
103
  if (isCloseCommand(name)) {
99
104
  currentTarget = undefined;
@@ -213,6 +218,9 @@ export function buildPageTransitionRefSnapshotInvalidation(summary) {
213
218
  export function getCommandRefSnapshotInvalidation(commandTokens) {
214
219
  if (isRecordPageTransitionCommand(commandTokens))
215
220
  return buildPageTransitionRefSnapshotInvalidation();
221
+ if (isWindowOrDiffPageTransitionCommand(commandTokens[0], commandTokens[1])) {
222
+ return buildPageTransitionRefSnapshotInvalidation("A window new or diff url command replaced or navigated the active page and invalidated prior refs. Run snapshot -i before using page-scoped refs.");
223
+ }
216
224
  if (isWebMcpPageMutationCommand(commandTokens)) {
217
225
  return buildPageTransitionRefSnapshotInvalidation("A WebMCP invoke, result, or cancel command can mutate, rerender, or navigate the page, so the prior snapshot refs were invalidated. Run snapshot -i before using page-scoped refs.");
218
226
  }
@@ -228,7 +236,7 @@ export function extractLatestRefSnapshotStateFromBatchResults(data) {
228
236
  for (const item of data) {
229
237
  if (!isRecord(item))
230
238
  continue;
231
- const commandTokens = extractBatchResultCommand(item);
239
+ const commandTokens = extractUpstreamCommandTokens(extractBatchResultCommand(item));
232
240
  const [name] = commandTokens;
233
241
  if (item.success !== false && isCloseCommand(name)) {
234
242
  latestState = undefined;
@@ -372,9 +380,10 @@ export class SessionPageState {
372
380
  }
373
381
  const tabTarget = getRestoredSessionTabTarget(details, command, subcommand);
374
382
  const tabTargetUnknown = details.sessionTabTargetUnknown === true;
383
+ const reopenPending = typeof details.sessionTabReopenPending === "boolean" ? details.sessionTabReopenPending : undefined;
375
384
  const refSnapshotInvalidation = getRestoredRefSnapshotInvalidation(details, command);
376
385
  const refSnapshot = refSnapshotInvalidation ? undefined : getRestoredRefSnapshot(details);
377
- if (!tabTarget && !tabTargetUnknown && !refSnapshotInvalidation && !refSnapshot)
386
+ if (!tabTarget && !tabTargetUnknown && !refSnapshotInvalidation && !refSnapshot && reopenPending === undefined)
378
387
  continue;
379
388
  restoredOrder += 1;
380
389
  if (tabTargetUnknown) {
@@ -389,8 +398,11 @@ export class SessionPageState {
389
398
  }
390
399
  if (tabTarget) {
391
400
  state.tabTargetUnknownOrders.delete(sessionKey);
392
- state.tabTargets.set(sessionKey, { order: restoredOrder, target: tabTarget });
401
+ state.tabTargets.set(sessionKey, { order: restoredOrder, reopenPending: state.tabTargets.get(sessionKey)?.reopenPending, target: tabTarget });
393
402
  }
403
+ const currentTarget = state.tabTargets.get(sessionKey);
404
+ if (currentTarget && reopenPending !== undefined)
405
+ currentTarget.reopenPending = reopenPending;
394
406
  if (refSnapshotInvalidation) {
395
407
  state.refSnapshots.delete(sessionKey);
396
408
  state.refSnapshotInvalidations.set(sessionKey, { ...refSnapshotInvalidation, order: restoredOrder });
@@ -421,6 +433,7 @@ export class SessionPageState {
421
433
  return {};
422
434
  return {
423
435
  pinningReason: this.tabPinningReasons.get(sessionName),
436
+ ...(this.tabTargets.get(sessionName)?.reopenPending !== undefined ? { tabReopenPending: this.tabTargets.get(sessionName)?.reopenPending } : {}),
424
437
  refSnapshot: stripRefSnapshotOrder(this.refSnapshots.get(sessionName)),
425
438
  refSnapshotInvalidation: stripRefSnapshotInvalidationOrder(this.refSnapshotInvalidations.get(sessionName)),
426
439
  ...(this.tabTargetUnknownOrders.has(sessionName) ? { tabTargetUnknown: true } : {}),
@@ -433,9 +446,15 @@ export class SessionPageState {
433
446
  return { ...this.get(options.sessionName), applied: false, stale: true };
434
447
  }
435
448
  this.tabTargetUnknownOrders.delete(options.sessionName);
436
- this.tabTargets.set(options.sessionName, { order: options.update, target: options.target });
449
+ this.tabTargets.set(options.sessionName, { order: options.update, reopenPending: current?.reopenPending, target: options.target });
437
450
  return { ...this.get(options.sessionName), applied: true };
438
451
  }
452
+ setTabReopenPending(options) {
453
+ const current = this.tabTargets.get(options.sessionName);
454
+ if (!current || !shouldApplyTabTargetUpdate(current, this.tabTargetUnknownOrders.get(options.sessionName), options.update))
455
+ return;
456
+ this.tabTargets.set(options.sessionName, { ...current, order: options.update, reopenPending: options.pending });
457
+ }
439
458
  applyRefSnapshot(options) {
440
459
  if (!shouldApplyRefStateUpdate({
441
460
  currentInvalidation: this.refSnapshotInvalidations.get(options.sessionName),
@@ -69,6 +69,8 @@ Browser isolation is separate from language isolation. A pre-spawn Pi custom ent
69
69
 
70
70
  ### Agent-first UX
71
71
 
72
+ Artifact directory preparation has one filesystem-error boundary inside the existing cleanup/finally path: direct, stdin and raw-argv failures return structured validation and the attempted directory without launching the requested command. Raw batch strings and argv-over-stdin precedence stay native; absolute artifact paths avoid differing daemon/Pi working directories. Artifact metadata retains known requested and reported/resolved paths without a new canonicalization pass. A bounded 16-byte regular-file header read recognizes the existing PNG/JPEG/GIF/WebP formats; inline screenshots use the same classifier under their existing byte limit, and unknown MIME types are omitted. Recording page warnings use the shared transition predicate and confirmed CLI/reached-row evidence through the existing prose/JSON warning path, independently of conservative ref-state invalidation after an uncertain batch.
73
+
72
74
  The primary UX is the agent calling the tool directly.
73
75
 
74
76
  That means:
@@ -157,6 +159,7 @@ V1 ownership rule:
157
159
  Practical policy:
158
160
  - preserve the current branch-visible extension-managed session across `/reload`, exact-session relaunch, `/resume`, and Pi 0.84.0+ `session_tree` branch transitions so persisted sessions can keep following the live browser after lifecycle changes
159
161
  - close the active extension-managed session when the originating `pi` process quits, while leaving explicit caller-provided sessions alone
162
+ - after branch restore, use the existing locked daemon inspection to distinguish a confirmed inactive wrapper-owned daemon from a live, unknown, or unavailable one. For compatible automatic managed restore only, keep the pending reopen in ordered session page state and persist it as `sessionTabReopenPending`. Non-page calls such as `tab list` and explicit HTTP reads can start a daemon without fulfilling it, including across branch/reload replay. Before the first current-page operation (`get url`, history commands and relative `pushstate` included), reopen the complete recorded URL with native `open`, invalidate old refs with the existing `page-transition` state, and verify the actual tab. Native `open` resets frame scope. Consume the obligation on that attempt or an executed explicit context/navigation/close command, not an unreached batch row; a failed open does not permit repeated navigation of a now-live browser. After the reopen CLI starts, cancellation returns a structured `aborted` result through the ordinary result path with the exact namespace/session, consumed marker and ref invalidation; it does not throw away replay state or run later browser helpers. Cancellation before the CLI starts does not consume the pending reopen. Internal URLs retain their fragments while tab/ref comparisons remain fragment-insensitive and presentation keeps normal redaction. Older transcripts cannot recover a fragment they did not record. Caller-owned/attached browsers and restore-disabled sessions do not take this path; live wrong-tab recovery still only selects an existing target. Reopening reloads the URL, not unsaved forms, JavaScript memory, or history. There is no second restore store or lifecycle lock.
160
163
  - set an idle timeout on extension-managed sessions as a backstop for abnormal exits or cleanup failures, and apply that same `AGENT_BROWSER_IDLE_TIMEOUT_MS` value to every upstream subprocess (including wrapper helper snapshots, tab lists, and navigation-summary reads) because changing the launch environment between calls can make upstream restart the background browser, discard the active tab, and invalidate fresh refs
161
164
  - for wrapper-owned implicit sessions only, set a transcript- and checkout-scoped `AGENT_BROWSER_RESTORE` key on compatible calls so cookies and web storage can survive idle shutdown, reload, and resume. Explicit caller sessions, restore/state choices, profiles, upstream config, file access, launch arguments, environment variables, local pages, output paths, and close arguments remain upstream-owned and pass through unchanged. `piab-*` names are not reserved; session/state lists and restore identifiers are not filtered or redacted. The wrapper validates only its automatic restore checkout/storage identity and coordinates same-daemon reuse so its own restore pools cannot mix. Ambiguous page-target transitions still require live `get url` verification before content calls. The current v3 ticket-claim lock is the only managed-daemon coordination protocol; no earlier lock bridge or compatibility path remains.
162
165
  - redact snapshot spill payloads before writing them, clean up process-private temp spill artifacts on shutdown, and keep persisted-session 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
@@ -172,16 +175,16 @@ Practical policy:
172
175
  - expose `details.browserWindow` and one visible login handoff only when a successful first/fresh local wrapper-managed headed result, including `batch`, is not an attachment and has upstream `lifecycle.effectiveLaunch.browserLaunched: true` and a `created`/`replaced` managed-session outcome. Keep `visibility: "unverified"`: this is launch evidence, never a claim about the user's OS desktop
173
176
  - leave explicit caller-provided `--session` choices alone unless the caller closes them explicitly, but before any content-bearing read or interaction against a caller-owned explicit session, live-probe that session with `get url` instead of trusting missing or stale transcript page state; hold the effective canonical namespace/session queue from that probe through semantic snapshot resolution and the main command so another same-instance call cannot change tabs in between. Non-bail batch analysis retains every possible page left by a failed transition up to a fixed bound and blocks later content only when the target is unverified; exceeding the bound also fails closed to exact `batch --bail` guidance. Nested `batch` steps remain unsupported, and raw batch command strings mirror upstream's ASCII-space tokenizer, including quote/backslash handling, rather than splitting on other Unicode whitespace.
174
177
  - 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
175
- - once the wrapper observes tab-drift risk for a session (profile restore correction, overlapping stale opens, or restored session state), later active-tab commands may synthesize a tiny upstream `batch` that re-selects that tab and then runs the requested command in the same upstream invocation; routine same-session commands avoid `tab list` preflights to reduce probes that can perturb upstream click behavior
178
+ - once the wrapper observes tab-drift risk for a session (profile restore correction, overlapping stale opens, or restored session state), later active-tab commands verify the intended tab under the existing session queue before semantic/ref helpers and user commands. Native selection runs only when the intended tab is not already active, because upstream selection clears refs and frame scope even on same-tab reselection. Missing targets, failed selection, and post-selection target mismatches fail before user commands. Caller argv/stdin and native `--pin-tab` / `--no-pin-tab` preferences remain unchanged. Local commands, live `get url`, explicit HTTP `read <url>` (including its flags), URL `a11y`/`vitals`/`web-vitals`, `diff url`, `window new`, URL-bearing recording commands, and explicit tab/navigation/`connect`/`state load` recovery do not require the old target; history back/forward/reload, `pushstate`, and page-content operations still do. The same classifier scans effective batch rows past non-page prefixes until a page dependency or explicit context change, without rewriting user rows or changing bail behavior. For `window new` and `diff url`, observe the resulting URL instead of retaining the old target or treating the requested second URL as redirect evidence. Fold only reached native batch rows when available, discard observations and refs from before those transitions, and let later successful snapshots rebuild refs even when another batch row fails. Retain an observed blank destination after either command instead of recovering the old page; if no final target is observed, use the existing unknown-target state. Caller batch arguments, stdin and bail behavior stay unchanged. Routine same-session commands avoid `tab list` preflights
176
179
  - for sessions with observed tab-drift risk, after a successful command on a known tab target, the wrapper may best-effort restore that same target again if restored/background tabs steal focus after the command returns; routine same-session commands skip this post-command `tab list` probe
177
180
  - after successful standalone tab selection or `tab close`, read the now-active URL and fresh non-blank title—even when two tabs share a URL—before updating per-session page state because upstream selection/close payloads are not sufficient page-target evidence; retain an explicitly selected existing `about:blank` tab or a blank tab revealed by close instead of treating either as accidental drift
178
- - keep a per-session `refSnapshot` aligned with the last successful `snapshot` (including refs merged from a successful `batch` by taking the last successful `snapshot` step in batch result order): restore it from persisted tool `details` when reloading, resuming, or moving to a different Pi session-tree branch, store bounded ref role/name metadata from the same snapshot for wrapper-side current-ref diagnostics, drop it on successful close commands (`close`, `quit`, or `exit`), replace it with a persisted `page-transition` invalidation after any upstream-executed `record start` attempt (direct or batch; upstream swaps the session to a fresh active page before its already-active check, so failed starts count), a `record restart` with a URL operand, or WebMCP `invoke` / `result` / `cancel` (these page-provided tools can mutate, rerender, or navigate; when a spawned `batch` yields no parseable result rows, for example after a wrapper timeout, planned transition steps still record the invalidation), or after a failed non-batch transition command (`eval`, `back`, `forward`, `reload`, `connect`, `state load`, `tab` selection) whose live URL re-verification probe observed the page (a failed transition can still have mutated or replaced the document before throwing, so keeping the verified URL must not keep the prior refs; transcript replay preserves the persisted invalidation summary), and refuse page-scoped `@e…` argv before spawn when the active tab URL no longer matches the snapshot URL, when a ref id was never in that snapshot, when the snapshot state is invalidated, or when a `batch` step would reuse `@e…` on a guarded getter or mutation step after an earlier invalidating step (including `record start`, URL-bearing `record restart`, and WebMCP `invoke` / `result` / `cancel`) without a later `snapshot` step in the same plan; batch steps come from the source upstream actually executes (raw batch argument strings exclusively when any exist — filtering only the exact `--bail` token like upstream — stdin only otherwise, via `getUpstreamEffectiveBatchSteps` in `extensions/agent-browser/lib/orchestration/batch-stdin.ts`); the tab-pinned batch rewrite (which re-emits the caller's exact `--bail` token so fail-fast semantics survive the rewrite), artifact/recording preflight, batch screenshot path preparation (parent directories are created for effective raw rows too, without rewriting raw strings), and stale-ref echo args use that same selection so pinning and preflights cannot act on upstream-ignored stdin, while the pre-spawn state-policy validator deliberately keeps scanning parseable stdin alongside argv as a fail-closed content superset and treats stdin parse failures as fatal only when upstream would actually read stdin (its raw-token filter also uses the exact `--bail` token only). Same-snapshot `fill @e…` rows are guarded but do not themselves set that invalidation latch, so ordinary form fills can precede a click/submit row in one batch—see [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md#details) for the agent-visible contract and failure text; typed per-session tab/ref/pinning state lives in `extensions/agent-browser/lib/session-page-state.ts` and is updated from `extensions/agent-browser/index.ts` after each tool result
181
+ - keep a per-session `refSnapshot` aligned with the last successful `snapshot` (including refs merged from a successful `batch` by taking the last successful `snapshot` step in batch result order): restore it from persisted tool `details` when reloading, resuming, or moving to a different Pi session-tree branch, store bounded ref role/name metadata from the same snapshot for wrapper-side current-ref diagnostics, drop it on successful close commands (`close`, `quit`, or `exit`), replace it with a persisted `page-transition` invalidation after any upstream-executed `record start` attempt (direct or batch; upstream swaps the session to a fresh active page before its already-active check, so failed starts count), a `record restart` with a URL operand, `window new`, `diff url`, or WebMCP `invoke` / `result` / `cancel` (these page-provided tools can mutate, rerender, or navigate; when a spawned `batch` yields no parseable result rows, for example after a wrapper timeout, planned transition steps still record the invalidation), or after a failed non-batch transition command (`eval`, `back`, `forward`, `reload`, `connect`, `state load`, `tab` selection) whose live URL re-verification probe observed the page (a failed transition can still have mutated or replaced the document before throwing, so keeping the verified URL must not keep the prior refs; transcript replay preserves the persisted invalidation summary), and refuse page-scoped `@e…` argv before spawn when the active tab URL no longer matches the snapshot URL, when a ref id was never in that snapshot, when the snapshot state is invalidated, or when a `batch` step would reuse `@e…` on a guarded getter or mutation step after an earlier invalidating step (including `record start`, URL-bearing `record restart`, and WebMCP `invoke` / `result` / `cancel`) without a later `snapshot` step in the same plan; batch steps come from the source upstream actually executes (raw batch argument strings exclusively when any exist — filtering only the exact `--bail` token like upstream — stdin only otherwise, via `getUpstreamEffectiveBatchSteps` in `extensions/agent-browser/lib/orchestration/batch-stdin.ts`); tab recovery (which leaves user argv/stdin and continue-on-error control flow unchanged), artifact/recording preflight, batch screenshot path preparation (parent directories are created for effective raw rows too, without rewriting raw strings), and stale-ref echo args use that same selection so pinning and preflights cannot act on upstream-ignored stdin, while the pre-spawn state-policy validator deliberately keeps scanning parseable stdin alongside argv as a fail-closed content superset and treats stdin parse failures as fatal only when upstream would actually read stdin (its raw-token filter also uses the exact `--bail` token only). Same-snapshot `fill @e…` rows are guarded but do not themselves set that invalidation latch, so ordinary form fills can precede a click/submit row in one batch—see [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md#details) for the agent-visible contract and failure text; typed per-session tab/ref/pinning state lives in `extensions/agent-browser/lib/session-page-state.ts` and is updated from `extensions/agent-browser/index.ts` after each tool result
179
182
  - when a direct or batched WebMCP call returns `status: "pending"`, or `result` / `cancel` fails while that target is unknown, keep its tab target unknown and discard same-call snapshot evidence instead of treating an immediate post-dispatch URL probe as stable; `webmcp result` / `cancel`, `get url`, and explicit navigation remain available while unknown; replace the generic blocked snapshot action with `verify-page-target-after-pending-webmcp` (`get url`), and let a completed `batch --bail` use that verification before `snapshot -i` to re-establish both target and refs
180
- - for top-level non-Electron direct `click` commands with an eligible target, install a bounded in-page target-specific event probe before upstream runs; if upstream reports success but no trusted pointer/mouse/click event reached the resolved target, fail the tool and report `details.clickDispatch` with explicit retry/inspect next actions (the wrapper does not replay clicks in-page). The probe covers `xpath=` targets and current `@e…` / `ref=` refs whose latest stored `refSnapshot.refs` role is `button`, `checkbox`, `menuitem`, `radio`, `switch`, or `tab`; it uses that role/name metadata, including snapshot-order `duplicateIndex` for duplicate-name refs, instead of taking a fresh pre-click snapshot that could recycle upstream refs. The probe is intentionally skipped for CSS selector clicks, unresolved `find … click` locators, and `batch`/`job`/`qa` click steps
183
+ - for top-level non-Electron direct `click` commands with an eligible target, install a bounded in-page target-specific event probe before upstream runs; if upstream reports success but no trusted pointer/mouse/click event reached the resolved target, fail the tool and report `details.clickDispatch` with explicit retry/inspect next actions (the wrapper does not replay clicks in-page). The probe covers `xpath=` targets and current `@e…` / `ref=` refs whose latest stored `refSnapshot.refs` role is `button`, `checkbox`, `menuitem`, `radio`, `switch`, or `tab`; it requires a unique role/name in the saved snapshot and the live candidates instead of taking a fresh pre-click snapshot that could recycle upstream refs. Duplicate-name refs pass through without a probe: their old ordinal is not target identity. The probe is intentionally skipped for CSS selector clicks, unresolved `find … click` locators, and `batch`/`job`/`qa` click steps
181
184
  - derive narrow prompt guards only for concrete evidence invariants: explicitly requested screenshot/recording output paths block browser close until the artifact manifest verifies those paths, while bare inbound attachment paths remain inputs. The wrapper intentionally does not infer broad business/user intent from prompt text such as order/payment/post boundaries; agents must follow those instructions themselves. The artifact guard is bounded preflight policy (`details.promptGuard`, `failureCategory: "policy-blocked"`), not a reusable browser recipe layer
182
185
  - reject direct and effective batch `scrollintoview text=...` / `scrollinto text=...` before dispatch because current upstream can falsely report success without movement, while leaving help forms untouched; return only native recovery (`find text ... hover` or fresh snapshot/ref), leaving CSS, XPath, and current-ref behavior upstream-owned
183
186
  - after successful `get text` on a qualifying non-ref CSS selector, optionally issue one read-only `eval --stdin` probe per selector when multiple DOM matches or a hidden first match with visible peers could misread tabbed or off-screen content; simple id selectors and sensitive-looking literals skip this probe. Merge `details.selectorTextVisibility` / `selectorTextVisibilityAll`, visible warning lines, and `inspect-visible-text-candidates*` next actions as documented in [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md#details) and `RQ-0074` in [`SUPPORT_MATRIX.md`](SUPPORT_MATRIX.md)
184
- - for local Unix launches, set a short private socket directory so extension-generated session names do not fail on the upstream Unix socket-path length limit; require the selected path to be absolute, owned by the current uid, mode `0700`, under trusted non-replaceable ancestry, and free of symlink, foreign-owner, or special planted entries; reject pre-existing unsafe modes instead of repairing them, then recheck before spawn. Android/Termux uses `/data/data/<package>/piab`, treats the owner-only app-data directory as the trust anchor, permits the app's matching private uid/gid ancestry, compacts generated managed identities to one 80-bit digest so ordinary namespace plus fresh-session paths remain within the limit, and places policy-lock coordination under `os.tmpdir()` because Android `/tmp` is shell-owned and inaccessible
187
+ - for local Unix launches, set a short private socket directory so extension-generated session names do not fail on the upstream Unix socket-path length limit; require the selected path to be absolute, owned by the current uid, mode `0700`, under checked ancestry, and free of symlink, foreign-owner, or special planted entries; reject pre-existing unsafe modes instead of repairing them, then recheck before spawn. The actual filesystem root `/` is supplied by the trusted operating environment: a directory without group/other write bits is accepted regardless of its reported owner, which can be unmapped in a Linux user namespace. Existing root-owned sticky-directory acceptance is unchanged. This boundary does not protect against whoever controls the root filesystem. Every non-root ancestor still needs trusted ownership and permissions, including the destination ancestry of root-owned aliases; a matching overflow UID is not trusted. Android/Termux uses `/data/data/<package>/piab`, treats the owner-only app-data directory as the trust anchor, permits the app's matching private uid/gid ancestry, compacts generated managed identities to one 80-bit digest so ordinary namespace plus fresh-session paths remain within the limit, and places policy-lock coordination under `os.tmpdir()` because Android `/tmp` is shell-owned and inaccessible
185
188
  - keep wrapper-spawned upstream CLI calls bounded by clamping `AGENT_BROWSER_DEFAULT_TIMEOUT` to the upstream documented 25-second default while deriving a longer subprocess watchdog for explicit long `wait <ms>` / `wait --timeout <ms>`, read, and WebMCP calls from the effective direct or raw-argument-else-stdin batch steps; dialog commands, likely dialog-trigger clicks/taps/finds, and `eval --stdin` snippets that look like alert/confirm/prompt/dialog triggers use shorter wrapper subprocess budgets so blocking JavaScript prompts surface recovery actions before the full default watchdog. Timeout recovery removes standalone snapshots when the target is unknown and emits one executable session-scoped `batch --bail` (`get url`, then `snapshot -i`); blocking-dialog status/accept/dismiss remains allowed under the same unknown-target guard
186
189
 
187
190
  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, or branch-tree semantics. If the caller explicitly chose the upstream session model, the extension should stay out of the way.