pi-agent-browser-native 0.6.9 → 0.6.11

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 (54) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +62 -19
  3. package/dist/extensions/agent-browser/index.js +424 -451
  4. package/dist/extensions/agent-browser/lib/argv-descriptor.js +6 -7
  5. package/dist/extensions/agent-browser/lib/argv-grammar.js +7 -1
  6. package/dist/extensions/agent-browser/lib/batch-lifecycle.js +4 -8
  7. package/dist/extensions/agent-browser/lib/command-policy.js +41 -2
  8. package/dist/extensions/agent-browser/lib/command-taxonomy.js +15 -2
  9. package/dist/extensions/agent-browser/lib/input-modes/params.js +1 -1
  10. package/dist/extensions/agent-browser/lib/input-modes/script.js +3 -2
  11. package/dist/extensions/agent-browser/lib/managed-session-restore.js +42 -12
  12. package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +3 -5
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +6 -14
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +14 -25
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +12 -6
  16. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +1 -0
  17. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/wait-timeouts.js +3 -2
  18. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +38 -31
  19. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +60 -20
  20. package/dist/extensions/agent-browser/lib/orchestration/browser-run/recording-recovery.js +161 -0
  21. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +5 -5
  22. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +2 -4
  23. package/dist/extensions/agent-browser/lib/orchestration/native-session-defaults.js +68 -0
  24. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +41 -6
  25. package/dist/extensions/agent-browser/lib/page-target-validation.js +9 -5
  26. package/dist/extensions/agent-browser/lib/playbook.js +13 -12
  27. package/dist/extensions/agent-browser/lib/process-environment.js +26 -8
  28. package/dist/extensions/agent-browser/lib/process.js +8 -5
  29. package/dist/extensions/agent-browser/lib/read-confirmation.js +59 -0
  30. package/dist/extensions/agent-browser/lib/recording-reservations.js +11 -1
  31. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +8 -0
  32. package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +6 -5
  33. package/dist/extensions/agent-browser/lib/results/categories.js +4 -2
  34. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +76 -57
  35. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +19 -8
  36. package/dist/extensions/agent-browser/lib/results/presentation/common.js +5 -25
  37. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +40 -38
  38. package/dist/extensions/agent-browser/lib/results/presentation/errors.js +1 -0
  39. package/dist/extensions/agent-browser/lib/results/presentation/navigation.js +3 -3
  40. package/dist/extensions/agent-browser/lib/results/presentation.js +38 -9
  41. package/dist/extensions/agent-browser/lib/results/recording.js +50 -0
  42. package/dist/extensions/agent-browser/lib/runtime.js +72 -20
  43. package/dist/extensions/agent-browser/lib/session-page-state.js +24 -8
  44. package/dist/extensions/agent-browser/lib/temp.js +4 -0
  45. package/dist/scripts/agent-browser-target.mjs +1 -1
  46. package/docs/ARCHITECTURE.md +29 -12
  47. package/docs/COMMAND_REFERENCE.md +67 -31
  48. package/docs/RELEASE.md +6 -4
  49. package/docs/SUPPORT_MATRIX.md +24 -16
  50. package/docs/TOOL_CONTRACT.md +82 -28
  51. package/package.json +1 -1
  52. package/scripts/agent-browser-capability-baseline.mjs +10 -3
  53. package/scripts/agent-browser-target.mjs +1 -1
  54. package/scripts/prepare.mjs +2 -4
@@ -48,14 +48,13 @@ export function parseWaitCommandTokens(commandTokens) {
48
48
  if (match)
49
49
  return { subcommand: match.token };
50
50
  }
51
- const download = considered.find((entry) => entry.token === "--download" || entry.token === "-d");
52
- if (download) {
53
- const downloadPathIndex = download.index + 1;
54
- const candidate = commandTokens[downloadPathIndex];
51
+ const downloadIndex = considered.findIndex((entry) => entry.token === "--download" || entry.token === "-d");
52
+ if (downloadIndex >= 0) {
53
+ const candidate = considered[downloadIndex + 1];
55
54
  return {
56
- downloadPath: candidate && !candidate.startsWith("--") ? candidate : undefined,
57
- downloadPathIndex: candidate && !candidate.startsWith("--") ? downloadPathIndex : undefined,
58
- subcommand: download.token,
55
+ downloadPath: candidate && !candidate.token.startsWith("--") ? candidate.token : undefined,
56
+ downloadPathIndex: candidate && !candidate.token.startsWith("--") ? candidate.index : undefined,
57
+ subcommand: considered[downloadIndex].token,
59
58
  };
60
59
  }
61
60
  return { subcommand: considered[0]?.token };
@@ -81,7 +81,7 @@ export const COMMAND_VALUE_FLAGS = [
81
81
  export const OPTIONAL_GLOBAL_VALUE_FLAGS = new Set(["--restore"]);
82
82
  export const VALUE_FLAGS = new Set([...GLOBAL_VALUE_FLAGS, ...COMMAND_VALUE_FLAGS]);
83
83
  export const PREVALIDATED_VALUE_FLAGS = new Set(GLOBAL_VALUE_FLAGS);
84
- export const GLOBAL_VALUE_FLAGS_ALLOWING_DASH_VALUE = new Set(["--args"]);
84
+ export const GLOBAL_VALUE_FLAGS_ALLOWING_DASH_VALUE = new Set(["--args", "--session"]);
85
85
  export const GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES = new Set([
86
86
  "--allow-file-access",
87
87
  "--annotate",
@@ -175,6 +175,12 @@ export function isAgentBrowserSessionIdentityKeyInNamespace(identityKey, namespa
175
175
  const prefix = getAgentBrowserSessionIdentityKey("", namespace);
176
176
  return prefix ? identityKey.startsWith(prefix) : !identityKey.includes("\0");
177
177
  }
178
+ export function deleteIdentityKeysInNamespace(entries, namespace) {
179
+ for (const key of entries.keys()) {
180
+ if (isAgentBrowserSessionIdentityKeyInNamespace(key, namespace))
181
+ entries.delete(key);
182
+ }
183
+ }
178
184
  /** Mirror upstream global parsing: full argv, no `--` sentinel, and only global value payloads are skipped. */
179
185
  export function scanUpstreamGlobalFlagOccurrences(args, targetFlag) {
180
186
  const occurrences = [];
@@ -1,12 +1,6 @@
1
1
  import { extractUpstreamCommandTokens } from "./argv-descriptor.js";
2
2
  import { isCloseAllCommand, isCloseCommand } from "./command-taxonomy.js";
3
3
  import { isRecord } from "./parsing.js";
4
- function getRowBrowserLaunched(row) {
5
- const result = isRecord(row.result) ? row.result : isRecord(row.data) ? row.data : undefined;
6
- const lifecycle = isRecord(row.lifecycle) ? row.lifecycle : isRecord(result?.lifecycle) ? result.lifecycle : undefined;
7
- const effectiveLaunch = isRecord(lifecycle?.effectiveLaunch) ? lifecycle.effectiveLaunch : undefined;
8
- return typeof effectiveLaunch?.browserLaunched === "boolean" ? effectiveLaunch.browserLaunched : undefined;
9
- }
10
4
  export function batchHasSuccessfulCloseAll(data, fallbackCommands = []) {
11
5
  if (!Array.isArray(data))
12
6
  return false;
@@ -34,7 +28,10 @@ export function getSuccessfulBatchCloseLifecycle(rows, fallbackCommands = []) {
34
28
  const rowCommand = Array.isArray(row.command) && row.command.every((token) => typeof token === "string")
35
29
  ? row.command
36
30
  : fallbackCommands[index];
37
- const browserLaunched = getRowBrowserLaunched(row);
31
+ const result = isRecord(row.result) ? row.result : isRecord(row.data) ? row.data : undefined;
32
+ const lifecycle = isRecord(row.lifecycle) ? row.lifecycle : isRecord(result?.lifecycle) ? result.lifecycle : undefined;
33
+ const effectiveLaunch = isRecord(lifecycle?.effectiveLaunch) ? lifecycle.effectiveLaunch : undefined;
34
+ const browserLaunched = typeof effectiveLaunch?.browserLaunched === "boolean" ? effectiveLaunch.browserLaunched : undefined;
38
35
  if (!rowCommand) {
39
36
  if (sawClose && browserLaunched !== false) {
40
37
  endsClosed = false;
@@ -49,7 +46,6 @@ export function getSuccessfulBatchCloseLifecycle(rows, fallbackCommands = []) {
49
46
  endsClosed = true;
50
47
  browserActiveAfterClose = false;
51
48
  recordingClosedAfterBatch = true;
52
- const result = isRecord(row.result) ? row.result : isRecord(row.data) ? row.data : undefined;
53
49
  statePath = typeof result?.statePath === "string" ? result.statePath : undefined;
54
50
  }
55
51
  else if (sawClose && command === "record") {
@@ -1,4 +1,5 @@
1
1
  import { hasOnlyBooleanFlags, hasOnlyOptionFlags, isNonFlagToken, stripSessionlessShapeGlobalFlags } from "./argv-grammar.js";
2
+ import { getUpstreamEffectiveBatchSteps } from "./orchestration/batch-stdin.js";
2
3
  const SESSIONLESS_AUTH_SUBCOMMANDS = new Set(["save", "list", "show", "delete", "remove"]);
3
4
  const PLUGIN_SESSIONLESS_SUBCOMMANDS = new Set(["list", "show", "add", "run"]);
4
5
  const EMPTY_BOOLEAN_FLAGS = new Set();
@@ -92,6 +93,44 @@ function isSessionlessCommand(commandTokens) {
92
93
  return isSessionlessStateCommand(normalizedTokens);
93
94
  return false;
94
95
  }
95
- export function needsManagedSession(descriptor) {
96
- return !isSessionlessCommand(descriptor.upstreamCommandTokens);
96
+ // undefined is a valid DOM read; null is invalid native syntax, which must not trigger page helpers.
97
+ export function getExplicitReadUrl(commandTokens) {
98
+ if (commandTokens[0] !== "read")
99
+ return undefined;
100
+ let url;
101
+ let llms = false;
102
+ let outline = false;
103
+ for (let index = 1; index < commandTokens.length; index += 1) {
104
+ const token = commandTokens[index];
105
+ if (["--filter", "--llms", "--timeout"].includes(token)) {
106
+ const value = commandTokens[++index];
107
+ if (value === undefined)
108
+ return null;
109
+ if (token === "--llms") {
110
+ if (!["index", "full"].includes(value))
111
+ return null;
112
+ llms = true;
113
+ }
114
+ if (token === "--timeout" && (!/^\+?\d+$/.test(value) || BigInt(value) === 0n || BigInt(value) > 18446744073709551615n))
115
+ return null;
116
+ }
117
+ else if (["--raw", "--require-md", "--outline", "--json"].includes(token)) {
118
+ if (token === "--outline")
119
+ outline = true;
120
+ }
121
+ else if (token.startsWith("--") || url !== undefined)
122
+ return null;
123
+ else
124
+ url = token;
125
+ }
126
+ return llms && outline ? null : url;
127
+ }
128
+ export function isBrowserIndependentRead(commandTokens, stdin) {
129
+ if (commandTokens[0] !== "batch")
130
+ return getExplicitReadUrl(commandTokens) !== undefined;
131
+ const steps = getUpstreamEffectiveBatchSteps(commandTokens, stdin);
132
+ return steps.length > 0 && steps.every((step) => getExplicitReadUrl(step) !== undefined);
133
+ }
134
+ export function needsManagedSession(descriptor, stdin) {
135
+ return !isSessionlessCommand(descriptor.upstreamCommandTokens) && !isBrowserIndependentRead(descriptor.upstreamCommandTokens, stdin);
97
136
  }
@@ -316,13 +316,26 @@ export function isSessionTabPinningExcludedCommand(command) {
316
316
  export function isSessionTabPostCommandCorrectionExcludedCommand(command) {
317
317
  return hasCommandCapability(command, "excludedFromPostCommandCorrection");
318
318
  }
319
- /** Upstream 0.33.2 record start swaps to a fresh active page before its already-active check, so even a failed start can replace the page; record restart navigates the current page only when a URL operand (any fourth token, mirroring upstream's positional slot) is present. */
319
+ export function getRecordCommandOperands(tokens) {
320
+ if (tokens[0] !== "record" || !["start", "restart"].includes(tokens[1] ?? ""))
321
+ return {};
322
+ const operands = [];
323
+ for (let index = 2; index < tokens.length && operands.length < 2; index += 1) {
324
+ // Native validates the range; bare/non-numeric --fps keeps its old literal meaning.
325
+ if (tokens[index] === "--fps" && /^\+?\d+$/.test(tokens[index + 1] ?? ""))
326
+ index += 1;
327
+ else
328
+ operands.push(tokens[index]);
329
+ }
330
+ return operands.length > 0 ? { path: operands[0], url: operands[1] } : { path: tokens[2], url: tokens[3] };
331
+ }
332
+ /** Starts conservatively invalidate refs because older supported natives replace the page, even on failure. Restarts invalidate only when they have a URL. */
320
333
  export function isRecordPageTransitionCommand(tokens) {
321
334
  if (tokens[0] !== "record")
322
335
  return false;
323
336
  if (tokens[1] === "start")
324
337
  return true;
325
- return tokens[1] === "restart" && tokens.length >= 4;
338
+ return tokens[1] === "restart" && getRecordCommandOperands(tokens).url !== undefined;
326
339
  }
327
340
  export function isWebMcpPageMutationCommand(tokens) {
328
341
  return isWebMcpPageMutation(tokens[0], tokens[1]);
@@ -138,7 +138,7 @@ export function createAgentBrowserParamsSchema(Type = JsonSchema, StringEnum = l
138
138
  outputPath: Type.Optional(Type.String({ description: "Workspace-relative or absolute result-data path; keep it distinct from screenshot, download, recording, and other browser artifact destinations.", minLength: 1 })),
139
139
  timeoutMs: Type.Optional(Type.Integer({ description: "Wrapper timeout in ms; exceed explicit waits. electron.list has no configurable timeout; other Electron actions use electron.timeoutMs.", minimum: 1 })),
140
140
  sessionMode: Type.Optional(StringEnum(["auto", "fresh"], {
141
- description: "auto reuses the managed session; fresh starts one for launch-only flags, then makes it the managed session.",
141
+ description: "Native configured sessions win; otherwise auto reuses the managed session and fresh starts a new managed browser for launch-only flags.",
142
142
  default: DEFAULT_SESSION_MODE,
143
143
  })),
144
144
  }, {
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgvDescriptor } from "../argv-descriptor.js";
7
7
  import { getFlagName } from "../argv-grammar.js";
8
- import { needsManagedSession } from "../command-policy.js";
8
+ import { isBrowserIndependentRead, needsManagedSession } from "../command-policy.js";
9
9
  import { isCloseCommand } from "../command-taxonomy.js";
10
10
  import { LAUNCH_SCOPED_FLAGS, MANAGED_RESTORE_INCOMPATIBLE_FLAGS } from "../launch-scoped-flags.js";
11
11
  import { isRecord } from "../parsing.js";
@@ -44,6 +44,7 @@ const SCRIPT_FORBIDDEN_FLAGS = new Set([
44
44
  ...MANAGED_RESTORE_INCOMPATIBLE_FLAGS.filter((flag) => flag !== SCRIPT_ALLOWED_LAUNCH_FLAG),
45
45
  "--namespace",
46
46
  "--session",
47
+ "--config",
47
48
  ]);
48
49
  export function compileAgentBrowserScript(input) {
49
50
  if (typeof input !== "string")
@@ -71,7 +72,7 @@ function getScriptCallPolicyError(args) {
71
72
  return "script browser calls cannot close, quit, or exit their isolated session.";
72
73
  if (SCRIPT_FORBIDDEN_COMMANDS.has(command))
73
74
  return `script browser calls cannot use ${command}.`;
74
- if (!needsManagedSession(descriptor))
75
+ if (!needsManagedSession(descriptor) && !isBrowserIndependentRead(descriptor.upstreamCommandTokens))
75
76
  return `script browser calls cannot use sessionless/local command ${command}.`;
76
77
  for (const token of args) {
77
78
  const flag = getFlagName(token);
@@ -104,7 +104,7 @@ export function agentBrowserExplicitConfigIsPresent(parentEnv = getAgentBrowserP
104
104
  return hasExplicitConfigArg(args) || hasUpstreamEnvValue(parentEnv, AGENT_BROWSER_CONFIG_ENV);
105
105
  }
106
106
  /** Caller-selected upstream config disables the wrapper's automatic restore injection without blocking that config. */
107
- export function agentBrowserConfigBlocksManagedRestore(_cwd, parentEnv = getAgentBrowserProcessEnvironment(), args = [], platform = process.platform) {
107
+ export function agentBrowserConfigBlocksManagedRestore(parentEnv = getAgentBrowserProcessEnvironment(), args = [], platform = process.platform) {
108
108
  return !resolveManagedSessionRestoreHome(parentEnv, platform) || agentBrowserExplicitConfigIsPresent(parentEnv, args);
109
109
  }
110
110
  function omitWrapperInjectedUserAgent(args, enabled) {
@@ -151,7 +151,7 @@ function isManagedSessionRestoreIncompatible(options, namespace = extractExplici
151
151
  const args = omitWrapperInjectedUserAgent(options.args, options.wrapperInjectedUserAgent);
152
152
  if (options.cwd && !hasManagedSessionRestoreProjectIdentity(options.cwd))
153
153
  return true;
154
- if (options.cwd && agentBrowserConfigBlocksManagedRestore(options.cwd, effectiveEnv, args))
154
+ if (options.cwd && agentBrowserConfigBlocksManagedRestore(effectiveEnv, args))
155
155
  return true;
156
156
  return !ensureManagedSessionRestoreStorageIsSecure(effectiveEnv, process.platform, namespace);
157
157
  }
@@ -192,6 +192,12 @@ export function getOwnedManagedSessionCompatibilityEnv(options) {
192
192
  const explicitRawInterval = Object.hasOwn(callEnv, "AGENT_BROWSER_AUTOSAVE_INTERVAL_MS")
193
193
  ? callEnv.AGENT_BROWSER_AUTOSAVE_INTERVAL_MS
194
194
  : parentEnv.AGENT_BROWSER_AUTOSAVE_INTERVAL_MS;
195
+ if (ownedContext.reuseOnly) {
196
+ return explicitRawInterval === undefined && ownedContext.headedManagedAutosaveInterval !== undefined
197
+ && !agentBrowserExplicitConfigIsPresent({ ...parentEnv, ...callEnv }, options.args)
198
+ ? { AGENT_BROWSER_AUTOSAVE_INTERVAL_MS: ownedContext.headedManagedAutosaveInterval }
199
+ : {};
200
+ }
195
201
  const explicitIntervalMatches = resolveExplicitAutosaveInterval(explicitRawInterval) === ownedContext.headedManagedAutosaveInterval;
196
202
  return {
197
203
  ...(ownedContext.compatibilityUserAgent ? { AGENT_BROWSER_USER_AGENT: ownedContext.compatibilityUserAgent } : {}),
@@ -209,26 +215,33 @@ export function getManagedSessionRestoreProtectedEnv(options, restoreEnv) {
209
215
  }
210
216
  export function validateManagedSessionRestoreContextForSpawn(options) {
211
217
  const { namespace, ownedContext, parentEnv } = resolveManagedSessionRestorePolicy(options);
212
- if (closesBrowserSession(options.args) || ownedContext?.restoreDecision !== "enabled")
218
+ if (closesBrowserSession(options.args) || ownedContext?.restoreDecision !== "enabled" || (ownedContext.reuseOnly && ownedContext.restoreSuppressed))
213
219
  return true;
214
220
  const ownedCwd = ownedContext.cwd ?? options.cwd;
215
221
  if (!ownedContext.restoreKey || !ownedContext.restoreScope || createManagedSessionRestoreKey(ownedCwd, ownedContext.restoreScope) !== ownedContext.restoreKey || !hasManagedSessionRestoreProjectIdentity(ownedCwd))
216
222
  return false;
217
223
  const effectiveEnv = { ...parentEnv, ...options.env };
218
- if (isDisabledEnvFlag(effectiveEnv[MANAGED_SESSION_RESTORE_ENV]))
219
- return false;
220
- if (MANAGED_RESTORE_INCOMPATIBLE_ENVS.some((name) => !MANAGED_SESSION_RESTORE_SPAWN_PINNED_ENVS.has(name) && hasUpstreamEnvValue(effectiveEnv, name)))
221
- return false;
222
- if (MANAGED_RESTORE_INCOMPATIBLE_BOOLEAN_ENVS.some((name) => isUpstreamEnvFlagEnabled(effectiveEnv[name])))
223
- return false;
224
- if (options.env?.[AGENT_BROWSER_RESTORE_ENV] !== undefined && options.env[AGENT_BROWSER_RESTORE_ENV] !== ownedContext.restoreKey)
225
- return false;
224
+ if (!ownedContext.reuseOnly) {
225
+ if (isDisabledEnvFlag(effectiveEnv[MANAGED_SESSION_RESTORE_ENV]))
226
+ return false;
227
+ if (MANAGED_RESTORE_INCOMPATIBLE_ENVS.some((name) => !MANAGED_SESSION_RESTORE_SPAWN_PINNED_ENVS.has(name) && hasUpstreamEnvValue(effectiveEnv, name)))
228
+ return false;
229
+ if (MANAGED_RESTORE_INCOMPATIBLE_BOOLEAN_ENVS.some((name) => isUpstreamEnvFlagEnabled(effectiveEnv[name])))
230
+ return false;
231
+ if (options.env?.[AGENT_BROWSER_RESTORE_ENV] !== undefined && options.env[AGENT_BROWSER_RESTORE_ENV] !== ownedContext.restoreKey)
232
+ return false;
233
+ }
226
234
  return ensureManagedSessionRestoreStorageIsSecure({ ...effectiveEnv, ...ownedContext.protectedStorageEnv }, process.platform, namespace);
227
235
  }
228
236
  export function getManagedSessionRestoreEnv(options) {
229
237
  const { namespace, owned, ownedContext, parentEnv, restoreState, sessionName } = resolveManagedSessionRestorePolicy(options);
230
238
  if (!owned || !restoreState || closesBrowserSession(options.args))
231
239
  return {};
240
+ if (ownedContext?.reuseOnly) {
241
+ return ownedContext.restoreKey && !ownedContext.restoreSuppressed && validateManagedSessionRestoreContextForSpawn(options)
242
+ ? { [AGENT_BROWSER_RESTORE_ENV]: ownedContext.restoreKey }
243
+ : {};
244
+ }
232
245
  if (ownedContext?.restoreDecision) {
233
246
  if (ownedContext.restoreDecision !== "enabled" || restoreState.isDisabled(sessionName, namespace) || !sessionName || !ownedContext.restoreKey || !validateManagedSessionRestoreContextForSpawn(options))
234
247
  return {};
@@ -244,7 +257,7 @@ export function getManagedSessionRestoreEnv(options) {
244
257
  /** Commit sticky suppression only after an owned-context subprocess has actually started. */
245
258
  export function commitManagedSessionRestoreSuppression(options) {
246
259
  const { namespace, owned, ownedContext, parentEnv, restoreState, sessionName } = resolveManagedSessionRestorePolicy(options);
247
- if (!owned || !restoreState || closesBrowserSession(options.args))
260
+ if (!owned || !restoreState || ownedContext?.reuseOnly || closesBrowserSession(options.args))
248
261
  return;
249
262
  if (ownedContext?.restoreDecision) {
250
263
  const alreadyDisabled = restoreState.isDisabled(sessionName, namespace);
@@ -272,6 +285,23 @@ export function buildOwnedManagedSessionRestoreContext(options) {
272
285
  if (!owned)
273
286
  return undefined;
274
287
  const ownedCwd = owned.cwd ?? options.cwd;
288
+ if (options.reuseOnly) {
289
+ const env = { ...(options.parentEnv ?? getAgentBrowserProcessEnvironment()), ...options.env };
290
+ const enabled = !options.restoreState.isDisabled(owned.sessionName, owned.namespace);
291
+ const restoreScope = getManagedSessionRestoreScope(owned.sessionName);
292
+ const knownKey = options.restoreState.getDaemonRestoreKey(owned.sessionName, owned.namespace);
293
+ const restoreKey = knownKey === undefined && enabled && hasManagedSessionRestoreProjectIdentity(ownedCwd)
294
+ ? createManagedSessionRestoreKey(ownedCwd, restoreScope)
295
+ : knownKey;
296
+ return { ...owned, reuseOnly: true, restoreKey: restoreKey ?? undefined, restoreScope,
297
+ protectedStorageEnv: enabled ? getManagedSessionRestoreProtectedStorageEnv(true, env) : undefined,
298
+ restoreDecision: enabled ? "enabled" : undefined,
299
+ restoreSuppressed: managedSessionRestoreOptedOut(options) || agentBrowserExplicitConfigIsPresent(env, options.args)
300
+ || ["--restore", "--session-name"].some(flag => hasLaunchScopedFlagToken(options.args, flag))
301
+ || env.AGENT_BROWSER_RESTORE !== undefined || env.AGENT_BROWSER_SESSION_NAME !== undefined,
302
+ headedManagedAutosaveDisabled: options.headedManagedAutosaveDisabled,
303
+ headedManagedAutosaveInterval: options.headedManagedAutosaveInterval };
304
+ }
275
305
  const policyOptions = {
276
306
  args: options.args,
277
307
  cwd: ownedCwd,
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, isAbsolute, join } from "node:path";
4
- import { createManagedSessionRestoreKey, directoryContainsSymlink, ensureManagedSessionRestoreStorageIsSecure, hasManagedSessionRestoreProjectIdentity, ensureOwnerOnlyDirectory, getManagedRestoreSessionsDirectory, isManagedSessionRestoreKey, resolveManagedSessionRestoreCheckoutRoot, resolveManagedSessionRestoreHome, } from "./managed-session-storage.js";
4
+ import { directoryContainsSymlink, ensureManagedSessionRestoreStorageIsSecure, ensureOwnerOnlyDirectory, getManagedRestoreSessionsDirectory, isManagedSessionRestoreKey, resolveManagedSessionRestoreCheckoutRoot, resolveManagedSessionRestoreHome, } from "./managed-session-storage.js";
5
5
  const OWNED_RESTORE_SNAPSHOT_FAMILIES_TO_KEEP = 2;
6
6
  const OWNED_RESTORE_SNAPSHOT_MAX_RECORDS = 256;
7
7
  const OWNED_RESTORE_SNAPSHOT_RECORD_MAX_BYTES = 16 * 1_024;
@@ -286,10 +286,8 @@ function scanOwnedSnapshots(options) {
286
286
  export function pruneOwnedManagedSessionRestoreSnapshots(options) {
287
287
  const parentEnv = options.parentEnv ?? process.env;
288
288
  const platform = options.platform ?? process.platform;
289
- const restoreKey = options.restoreKey === undefined
290
- ? hasManagedSessionRestoreProjectIdentity(options.cwd) ? createManagedSessionRestoreKey(options.cwd) : undefined
291
- : isManagedSessionRestoreKey(options.restoreKey) ? options.restoreKey : undefined;
292
- if (!restoreKey)
289
+ const restoreKey = options.restoreKey;
290
+ if (!isManagedSessionRestoreKey(restoreKey))
293
291
  return 0;
294
292
  const home = resolveManagedSessionRestoreHome(parentEnv, platform);
295
293
  if (!home)
@@ -2,8 +2,7 @@ import { lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { foldAgentBrowserFilesystemIdentity } from "../../argv-grammar.js";
4
4
  import { parseWaitCommandTokens } from "../../argv-descriptor.js";
5
- const SCREENSHOT_BOOLEAN_FLAGS = new Set(["--annotate", "--full", "-f"]);
6
- const SCREENSHOT_VALUE_FLAGS = new Set(["--screenshot-dir", "--screenshot-format", "--screenshot-quality"]);
5
+ import { getRecordCommandOperands } from "../../command-taxonomy.js";
7
6
  const SCREENSHOT_IMAGE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".webp"];
8
7
  function isSingleScreenshotPathToken(token) {
9
8
  const explicitlyRelative = token.startsWith("./") || token.startsWith("../");
@@ -17,11 +16,7 @@ function getScreenshotPositionalIndices(commandTokens) {
17
16
  const positionalIndices = [];
18
17
  for (let index = 1; index < commandTokens.length; index += 1) {
19
18
  const token = commandTokens[index];
20
- if (SCREENSHOT_VALUE_FLAGS.has(token)) {
21
- index += 1;
22
- continue;
23
- }
24
- if (SCREENSHOT_BOOLEAN_FLAGS.has(token))
19
+ if (token === "--full" || token === "-f")
25
20
  continue;
26
21
  positionalIndices.push(index);
27
22
  }
@@ -54,9 +49,6 @@ function getDiffScreenshotOutputPath(commandTokens) {
54
49
  }
55
50
  return outputPath;
56
51
  }
57
- function foldArtifactPath(path, platform) {
58
- return foldAgentBrowserFilesystemIdentity(path, platform);
59
- }
60
52
  function canonicalizeArtifactPath(absolutePath, platform, seenSymlinks) {
61
53
  let cursor = absolutePath;
62
54
  const suffix = [];
@@ -71,7 +63,7 @@ function canonicalizeArtifactPath(absolutePath, platform, seenSymlinks) {
71
63
  catch {
72
64
  // The destination does not exist yet; canonical ancestry still catches aliases.
73
65
  }
74
- return foldArtifactPath(canonicalPath, platform);
66
+ return foldAgentBrowserFilesystemIdentity(canonicalPath, platform);
75
67
  }
76
68
  catch {
77
69
  let symlinkTarget;
@@ -90,7 +82,7 @@ function canonicalizeArtifactPath(absolutePath, platform, seenSymlinks) {
90
82
  }
91
83
  const parent = dirname(cursor);
92
84
  if (parent === cursor)
93
- return foldArtifactPath(absolutePath, platform);
85
+ return foldAgentBrowserFilesystemIdentity(absolutePath, platform);
94
86
  suffix.unshift(basename(cursor));
95
87
  cursor = parent;
96
88
  }
@@ -120,7 +112,7 @@ export function getExplicitArtifactDestination(commandTokens) {
120
112
  return commandTokens[3];
121
113
  if ((command === "trace" || command === "profiler") && subcommand === "stop")
122
114
  return commandTokens[2];
123
- if (command === "record" && (subcommand === "start" || subcommand === "restart"))
124
- return commandTokens[2];
115
+ if (command === "record")
116
+ return getRecordCommandOperands(commandTokens).path;
125
117
  return undefined;
126
118
  }
@@ -1,17 +1,18 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import { isCloseCommand, isOpenNavigationCommand } from "../../command-taxonomy.js";
4
+ import { isBrowserIndependentRead } from "../../command-policy.js";
4
5
  import { boundElectronProbeString } from "../../electron/cdp.js";
5
6
  import { executableExistsOnPath } from "../../executable-path.js";
6
7
  import { formatSessionArtifactRetentionSummary } from "../../results/artifact-manifest.js";
7
8
  import { buildInspectOverlayStateAction, buildNextToolAction, withOptionalSessionArgs } from "../../results/next-actions.js";
8
9
  import { buildVisibleRefFallbackDiagnosticFromSnapshot, getVisibleRefFallbackTarget } from "../../results/selector-recovery.js";
9
10
  import { extractRefSnapshotFromData, isAboutBlankUrl, normalizeComparableUrl } from "../../session-page-state.js";
10
- import { extractUpstreamCommandTokens, parseWaitCommandTokens, redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
11
+ import { redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
11
12
  import { isRecord } from "../../parsing.js";
12
13
  import { extractBatchResultCommand, extractNavigationSummaryFromData, extractStringResultField, findElectronLaunchRecordForSession, runSessionCommandData, } from "./session-state.js";
13
- import { parseValidBatchStepEntries } from "../batch-stdin.js";
14
- import { getScreenshotPathTokenIndex } from "./artifact-paths.js";
14
+ import { getUpstreamEffectiveBatchSteps } from "../batch-stdin.js";
15
+ import { getExplicitArtifactDestination } from "./artifact-paths.js";
15
16
  const ELECTRON_FILL_VERIFICATION_TIMEOUT_MS = 2_000;
16
17
  export function sleepMs(ms) {
17
18
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -227,7 +228,7 @@ export async function collectRecordingDependencyWarning(options) {
227
228
  return undefined;
228
229
  if (await executableExistsOnPath("ffmpeg"))
229
230
  return undefined;
230
- return { command: recordCommand, dependency: "ffmpeg", message: `${recordCommand} can begin recording, but record stop needs ffmpeg on PATH to encode the WebM output.`, reason: "ffmpeg-missing-for-recording", recommendations: ["Install ffmpeg before relying on this recording workflow; on macOS with Homebrew, brew install ffmpeg or brew install ffmpeg-full.", "If ffmpeg was just installed, restart pi or ensure the PATH visible to pi includes the ffmpeg binary before running record stop."] };
231
+ return { command: recordCommand, dependency: "ffmpeg", message: `${recordCommand} reported a pending recording, but ffmpeg is not on PATH. Its output is unverified; install ffmpeg before starting a new recording.`, reason: "ffmpeg-missing-for-recording", recommendations: ["Install ffmpeg before recording; on macOS with Homebrew, brew install ffmpeg or brew install ffmpeg-full.", "Stop this recording, check the result, and start a new recording after ensuring Pi can find ffmpeg on PATH."] };
231
232
  }
232
233
  export function formatRecordingDependencyWarningText(warning) {
233
234
  if (!warning)
@@ -711,12 +712,10 @@ export async function collectElectronHandoff(options) {
711
712
  }
712
713
  return { handoff: "snapshot", refSnapshot, snapshot, ...(snapshotRetryCount > 0 ? { snapshotRetryCount } : {}), tabs };
713
714
  }
714
- function getTimeoutProgressSteps(compiledJob, command, stdin) {
715
+ function getTimeoutProgressSteps(compiledJob, commandTokens, stdin) {
715
716
  if (compiledJob)
716
717
  return compiledJob.steps.map((step, index) => ({ args: step.args, generatedFrom: step.generatedFrom, index: index + 1 }));
717
- if (command !== "batch" || !stdin)
718
- return [];
719
- return parseValidBatchStepEntries(stdin).map(({ index, step }) => ({ args: step, index: index + 1 }));
718
+ return getUpstreamEffectiveBatchSteps(commandTokens, stdin).map((args, index) => ({ args, index: index + 1 }));
720
719
  }
721
720
  function getLastPositionalToken(args, startIndex = 1) {
722
721
  for (let index = args.length - 1; index >= startIndex; index -= 1) {
@@ -726,20 +725,8 @@ function getLastPositionalToken(args, startIndex = 1) {
726
725
  }
727
726
  return undefined;
728
727
  }
729
- function getTimeoutStepArtifactPath(args) {
730
- const commandArgs = extractUpstreamCommandTokens(args);
731
- const [command] = commandArgs;
732
- if (command === "screenshot") {
733
- const index = getScreenshotPathTokenIndex(commandArgs);
734
- return index === undefined ? undefined : commandArgs[index];
735
- }
736
- if (command === "pdf")
737
- return getLastPositionalToken(commandArgs);
738
- if (command === "download")
739
- return getLastPositionalToken(commandArgs, 2);
740
- if (command === "wait")
741
- return parseWaitCommandTokens(commandArgs).downloadPath;
742
- return undefined;
728
+ function getTimeoutStepArtifactPath(commandTokens) {
729
+ return ["screenshot", "pdf", "download", "wait"].includes(commandTokens[0]) ? getExplicitArtifactDestination(commandTokens) : undefined;
743
730
  }
744
731
  async function statTimeoutArtifactPath(absolutePath) {
745
732
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -795,7 +782,7 @@ const TIMEOUT_RETRYABLE_COMMANDS = new Set([
795
782
  ]);
796
783
  function getTimeoutStepRetry(step) {
797
784
  const command = step.args[0];
798
- return command && TIMEOUT_RETRYABLE_COMMANDS.has(command) ? { args: step.args } : undefined;
785
+ return command && TIMEOUT_RETRYABLE_COMMANDS.has(command) ? { args: ["batch"], stdin: JSON.stringify([step.args]) } : undefined;
799
786
  }
800
787
  function normalizeUrlForTimeoutComparison(url) {
801
788
  if (!url)
@@ -873,7 +860,9 @@ function buildTimeoutProgressSteps(options) {
873
860
  };
874
861
  }
875
862
  export async function collectTimeoutPartialProgress(options) {
876
- const rawSteps = getTimeoutProgressSteps(options.compiledJob, options.command, options.stdin);
863
+ if ((options.commandTokens[0] === "session" && options.commandTokens[1] === "info") || isBrowserIndependentRead(options.commandTokens, options.stdin))
864
+ return undefined;
865
+ const rawSteps = getTimeoutProgressSteps(options.compiledJob, options.commandTokens, options.stdin);
877
866
  const artifacts = await collectTimeoutArtifactEvidence(options.cwd, rawSteps);
878
867
  const urlData = await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName });
879
868
  const recoveredUrl = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
@@ -930,7 +919,7 @@ export function formatTimeoutPartialProgressText(progress, pageTargetUnknown = f
930
919
  lines.push(`- ... ${progress.steps.length - shownSteps.length} more step${progress.steps.length - shownSteps.length === 1 ? "" : "s"} omitted`);
931
920
  }
932
921
  if (progress.retryStep?.retry?.args) {
933
- const payload = JSON.stringify({ args: redactInvocationArgs(progress.retryStep.retry.args) });
922
+ const payload = JSON.stringify({ ...progress.retryStep.retry, stdin: JSON.stringify([redactInvocationArgs(progress.retryStep.args)]) });
934
923
  lines.push(pageTargetUnknown
935
924
  ? `Retry candidate for step ${progress.retryStep.index}: ${payload}. Verify the current URL before running it.`
936
925
  : `Retry failed step: ${payload}`);
@@ -86,7 +86,7 @@ export function redactRecoveryHint(recoveryHint) {
86
86
  }
87
87
  export function buildJsonVisibleContent(options) {
88
88
  const { error, presentation, succeeded, warnings } = options;
89
- const payload = redactSensitiveValue({ artifacts: presentation.artifacts, data: presentation.data, error, success: succeeded, warnings: warnings && warnings.length > 0 ? warnings : undefined });
89
+ const payload = redactSensitiveValue({ artifacts: presentation.artifacts, data: presentation.data, error, recordingRecovery: presentation.recordingRecovery, readConfirmation: presentation.readConfirmation, success: succeeded, warnings: warnings && warnings.length > 0 ? warnings : undefined });
90
90
  if (isRecord(payload) && isRecord(payload.data) && isRecord(presentation.data) && typeof presentation.data.wsUrl === "string")
91
91
  payload.data.wsUrl = presentation.data.wsUrl;
92
92
  const images = presentation.content.filter((item) => item.type === "image");
@@ -205,7 +205,9 @@ export async function prepareFinalResultRecoveryState(options) {
205
205
  return { categoryDetails, currentRefSnapshot, currentRefSnapshotInvalidation, noActivePageSnapshotFailure, richInputRecoveryDiagnostic, visibleRefFallbackDiagnostic, visibleRefFallbackSessionName };
206
206
  }
207
207
  function buildTimeoutPartialProgressNextActions(options) {
208
- const retryArgs = options.timeoutPartialProgress?.retryStep?.retry?.args;
208
+ if (options.executionPlan.commandInfo.command === "session" && options.executionPlan.commandInfo.subcommand === "info")
209
+ return [];
210
+ const retry = options.timeoutPartialProgress?.retryStep?.retry;
209
211
  const stepIndex = options.timeoutPartialProgress?.retryStep?.index;
210
212
  const freshSessionAbandoned = options.sessionMode === "fresh" && options.timeoutPartialProgress?.liveUrlRecovered !== true;
211
213
  if (options.currentSessionTabTargetUnknown && !freshSessionAbandoned && options.executionPlan.sessionName) {
@@ -220,12 +222,12 @@ function buildTimeoutPartialProgressNextActions(options) {
220
222
  tool: "agent_browser",
221
223
  }];
222
224
  }
223
- if (retryArgs) {
225
+ if (retry) {
224
226
  return [{
225
227
  id: "retry-timeout-step",
226
228
  params: freshSessionAbandoned
227
- ? { args: retryArgs, sessionMode: "fresh" }
228
- : { args: withOptionalSessionArgs(options.executionPlan.sessionName, retryArgs) },
229
+ ? { ...retry, sessionMode: "fresh" }
230
+ : { ...retry, args: withOptionalSessionArgs(options.executionPlan.sessionName, retry.args) },
229
231
  reason: freshSessionAbandoned
230
232
  ? `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} in a fresh browser session because the timed-out fresh session was not proven live.`
231
233
  : `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} against the current browser session.`,
@@ -271,6 +273,8 @@ function buildDialogTimeoutNextActions(options) {
271
273
  ];
272
274
  }
273
275
  function buildResultNextActions(options) {
276
+ if (options.presentation.recordingRecovery || options.presentation.readConfirmation)
277
+ return options.presentation.nextActions;
274
278
  let nextActions = options.presentation.nextActions ? [...options.presentation.nextActions] : [];
275
279
  const append = (actions) => {
276
280
  if (actions && actions.length > 0)
@@ -371,7 +375,7 @@ function formatReadExecutionText(options, lifecycle) {
371
375
  const source = getReadSource(options);
372
376
  if (!source)
373
377
  return undefined;
374
- return `Read execution: source ${source}; CLI started: ${options.processResult.agentBrowserStarted ? "yes" : "no"}; managed browser lifecycle active: ${lifecycle?.effectiveLaunch.browserLaunched === true ? "yes" : "no"}; managed session outcome: ${options.managedSessionOutcome?.status ?? "not managed"}.`;
378
+ return `Read execution: source ${source}; CLI started: ${options.processResult.agentBrowserStarted ? "yes" : "no"}; reported browserLaunched: ${lifecycle ? String(lifecycle.effectiveLaunch.browserLaunched) : "unknown"}; managed session outcome: ${options.managedSessionOutcome?.status ?? "not managed"}. An HTTP read does not establish shared-browser liveness; use session info for that.`;
375
379
  }
376
380
  function buildBrowserWindowStatus(options, lifecycle) {
377
381
  if (!options.headedLaunch || options.preserveAttachedBrowserSession || options.providerLaunch || !options.succeeded || lifecycle?.effectiveLaunch.browserLaunched !== true || !options.executionPlan.managedSessionName || !options.managedSessionOutcome || !["created", "replaced"].includes(options.managedSessionOutcome.status))
@@ -417,6 +421,8 @@ function buildAgentBrowserResultDetails(options, nextActions) {
417
421
  browserWindow,
418
422
  lifecycle,
419
423
  readSource: getReadSource(options),
424
+ recordingRecovery: options.presentation.recordingRecovery,
425
+ readConfirmation: options.presentation.readConfirmation,
420
426
  aboutBlankSessionMismatch: options.aboutBlankSessionMismatch,
421
427
  electronPostCommandHealth: options.electronPostCommandHealth,
422
428
  electronRefFreshness: options.electronRefFreshnessDiagnostic,
@@ -31,6 +31,7 @@ async function runAgentBrowserToolInContext(options) {
31
31
  const artifactRunStartedAtMs = Date.now();
32
32
  const processResult = await runAgentBrowserProcess({
33
33
  args: prepared.processArgs,
34
+ browserIndependentReadConfirmation: prepared.readConfirmation !== undefined,
34
35
  cwd: options.cwd,
35
36
  env: ownedManagedSession
36
37
  ? { AGENT_BROWSER_IDLE_TIMEOUT_MS: options.implicitSessionIdleTimeoutMs }
@@ -1,5 +1,6 @@
1
1
  import { GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, VALUE_FLAGS } from "../../../argv-grammar.js";
2
2
  import { isOpenNavigationCommand } from "../../../command-taxonomy.js";
3
+ import { getExplicitReadUrl, isBrowserIndependentRead } from "../../../command-policy.js";
3
4
  import { getAgentBrowserProcessTimeoutMs } from "../../../process.js";
4
5
  import { getUpstreamEffectiveBatchSteps } from "../../batch-stdin.js";
5
6
  const POSITIONAL_VALUE_FLAGS = new Set([...VALUE_FLAGS, "--llms"]);
@@ -44,10 +45,10 @@ export function findFirstPositionalArgument(commandTokens) {
44
45
  return undefined;
45
46
  }
46
47
  function readUsesActivePageUrl(commandTokens) {
47
- return findFirstPositionalArgument(commandTokens) === undefined && commandTokens.some((token) => token === "--require-md" || token === "--llms" || token.startsWith("--llms="));
48
+ return !isBrowserIndependentRead(commandTokens) && commandTokens.some((token) => token === "--require-md" || token === "--llms" || token.startsWith("--llms="));
48
49
  }
49
50
  function readRequestBudget(commandTokens, activePageUrl) {
50
- const target = findFirstPositionalArgument(commandTokens) ?? activePageUrl;
51
+ const target = getExplicitReadUrl(commandTokens) ?? activePageUrl;
51
52
  if (target === undefined)
52
53
  return 1;
53
54
  let url;