pi-agent-browser-native 0.6.16 → 0.7.0

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 (52) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +59 -165
  3. package/dist/extensions/agent-browser/index.js +634 -501
  4. package/dist/extensions/agent-browser/lib/browser-transcript.js +33 -0
  5. package/dist/extensions/agent-browser/lib/command-taxonomy.js +8 -4
  6. package/dist/extensions/agent-browser/lib/execution-cwd.js +66 -0
  7. package/dist/extensions/agent-browser/lib/input-modes/job.js +2 -213
  8. package/dist/extensions/agent-browser/lib/input-modes/lookups.js +1 -1
  9. package/dist/extensions/agent-browser/lib/input-modes/params.js +146 -142
  10. package/dist/extensions/agent-browser/lib/input-modes/script.js +64 -92
  11. package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +170 -24
  12. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +27 -20
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +14 -2
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +26 -13
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +21 -1
  16. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/snapshot-filter.js +4 -2
  17. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +1 -3
  18. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +41 -24
  19. package/dist/extensions/agent-browser/lib/orchestration/browser-run/recording-recovery.js +3 -1
  20. package/dist/extensions/agent-browser/lib/orchestration/browser-run/screenshot-observation.js +79 -0
  21. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +3 -4
  22. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +19 -52
  23. package/dist/extensions/agent-browser/lib/orchestration/native-session-defaults.js +3 -3
  24. package/dist/extensions/agent-browser/lib/orchestration/operation-paths.js +76 -0
  25. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +3 -3
  26. package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +91 -278
  27. package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +9 -9
  28. package/dist/extensions/agent-browser/lib/playbook.js +46 -25
  29. package/dist/extensions/agent-browser/lib/process-identity.js +22 -6
  30. package/dist/extensions/agent-browser/lib/process.js +16 -13
  31. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +2 -2
  32. package/dist/extensions/agent-browser/lib/results/next-actions.js +1 -2
  33. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +25 -4
  34. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +6 -2
  35. package/dist/extensions/agent-browser/lib/results/presentation/common.js +2 -0
  36. package/dist/extensions/agent-browser/lib/results/presentation/content.js +39 -0
  37. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +3 -3
  38. package/dist/extensions/agent-browser/lib/results/presentation/large-output.js +41 -1
  39. package/dist/extensions/agent-browser/lib/results/presentation.js +16 -11
  40. package/dist/extensions/agent-browser/lib/runtime.js +18 -6
  41. package/dist/extensions/agent-browser/lib/session-page-state.js +3 -4
  42. package/dist/extensions/agent-browser/lib/tool-surface.js +131 -0
  43. package/dist/extensions/agent-browser/script-worker.js +4 -1
  44. package/docs/ARCHITECTURE.md +47 -61
  45. package/docs/COMMAND_REFERENCE.md +78 -137
  46. package/docs/ELECTRON.md +51 -49
  47. package/docs/RELEASE.md +31 -15
  48. package/docs/REQUIREMENTS.md +18 -14
  49. package/docs/SUPPORT_MATRIX.md +33 -15
  50. package/docs/TOOL_CONTRACT.md +218 -401
  51. package/package.json +4 -4
  52. package/scripts/doctor.mjs +2 -2
@@ -1,12 +1,14 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import { basename, dirname, join } from "node:path";
5
- import { canonicalizeAgentBrowserNamespace, getAgentBrowserSessionIdentityKey } from "./argv-grammar.js";
6
+ import { canonicalizeAgentBrowserNamespace, foldAgentBrowserFilesystemIdentity } from "./argv-grammar.js";
7
+ import { getAgentBrowserSocketDirValidationError, resolveAgentBrowserSocketDir } from "./process.js";
6
8
  import { processStartIdentitiesMatch, readProcessStartIdentity } from "./process-identity.js";
7
9
  const POLICY_LOCK_WAIT_MS = 1_000;
8
10
  const POLICY_LOCK_RETRY_MS = 10;
9
- const POLICY_LOCK_MAX_BYTES = 4_096;
11
+ const POLICY_LOCK_MAX_BYTES = 1_048_576; // One claim may cover an entire owned-browser cleanup set.
10
12
  const LOCK_OWNER_FILE = "owner.json";
11
13
  const LOCK_TICKET_FILE = "ticket.json";
12
14
  function getCoordinationDirectory(platform = process.platform) {
@@ -43,19 +45,133 @@ async function ensureCoordinationDirectory(path, platform) {
43
45
  return false;
44
46
  }
45
47
  }
46
- function getPolicyLockDigest(sessionName, namespace) {
47
- const identity = getAgentBrowserSessionIdentityKey(sessionName, canonicalizeAgentBrowserNamespace(namespace));
48
- return createHash("sha256").update(identity).digest("hex");
48
+ export async function resolveBrowserExecutionIdentity(options) {
49
+ const socketDir = resolveAgentBrowserSocketDir(options);
50
+ const namespace = canonicalizeAgentBrowserNamespace(options.namespace);
51
+ let socketContext;
52
+ if (process.platform === "win32") {
53
+ // Native Windows can reach the same namespace/session TCP daemon from
54
+ // different storage roots. Do not split those callers into separate locks.
55
+ socketContext = `win32-native:${namespace ?? ""}`;
56
+ }
57
+ else {
58
+ if (socketDir === undefined)
59
+ throw new Error("Browser execution coordination requires the native socket directory.");
60
+ const error = await getAgentBrowserSocketDirValidationError(socketDir);
61
+ if (error)
62
+ throw new Error(`Browser execution coordination cannot use socket storage: ${error}.`);
63
+ const root = await realpath(socketDir);
64
+ socketContext = namespace ? join(root, "namespaces", namespace, "run") : root;
65
+ }
66
+ return {
67
+ socketContext: foldAgentBrowserFilesystemIdentity(socketContext, process.platform),
68
+ ...(options.sessionName !== undefined ? { sessionName: foldAgentBrowserFilesystemIdentity(options.sessionName, process.platform) } : {}),
69
+ };
70
+ }
71
+ export function getBrowserExecutionLockPath(identity) {
72
+ const digest = createHash("sha256").update(identity.socketContext).digest("hex");
73
+ return join(getCoordinationDirectory(), `.pi-agent-browser-policy-${digest}.lock-v4`);
74
+ }
75
+ function groupExecutionIdentities(identities) {
76
+ if (identities.length === 0)
77
+ throw new Error("Browser execution coordination requires at least one identity.");
78
+ const groups = new Map();
79
+ for (const { socketContext, sessionName } of identities) {
80
+ if (sessionName === undefined)
81
+ groups.set(socketContext, null);
82
+ else if (groups.get(socketContext) !== null) {
83
+ const sessions = groups.get(socketContext) ?? new Set();
84
+ sessions.add(sessionName);
85
+ groups.set(socketContext, sessions);
86
+ }
87
+ }
88
+ return [...groups.keys()].sort().map(socketContext => {
89
+ const sessions = groups.get(socketContext);
90
+ return { socketContext, sessionNames: sessions ? [...sessions].sort() : null };
91
+ });
92
+ }
93
+ const executionScope = new AsyncLocalStorage();
94
+ function scopeCovers(outer, inner) {
95
+ return inner.every(identity => outer.some(held => held.socketContext === identity.socketContext
96
+ && (held.sessionNames === null || identity.sessionNames !== null && identity.sessionNames.every(session => held.sessionNames.includes(session)))));
97
+ }
98
+ /**
99
+ * Hold across every helper and action (or a complete code cell), not each CLI call.
100
+ * Nested siblings are serialized; recursive inner calls and managed policy checks borrow
101
+ * the claims. Declare every old/new identity up front for replacement or multi-browser cleanup;
102
+ * omit sessionName for close --all. Upgrading mid-operation would allow a lock-order cycle.
103
+ * Sessions sharing a socket context use ONE set-valued claim: acquiring them separately
104
+ * could deadlock against a namespace-wide claimant between the first and second session.
105
+ * Cancellation never releases a still-running callback. The callback must pass its signal
106
+ * to subprocesses and await their termination; finally drains started children before release.
107
+ */
108
+ export async function withBrowserExecutionLocks(options, run) {
109
+ if (!Number.isFinite(options.deadline))
110
+ throw new Error("Browser execution coordination requires a finite deadline.");
111
+ const identities = groupExecutionIdentities(options.identities);
112
+ const parent = executionScope.getStore();
113
+ if (parent && !parent.active)
114
+ throw new Error("Browser execution scope has already finished.");
115
+ if (parent && !scopeCovers(parent.identities, identities))
116
+ throw new Error("Cannot change or upgrade browser identity inside an execution lock; acquire every required identity before starting.");
117
+ const controller = new AbortController();
118
+ const signals = [controller.signal, options.signal, parent?.signal].filter((signal) => signal !== undefined);
119
+ const signal = AbortSignal.any(signals);
120
+ const expire = () => controller.abort(new DOMException("Browser execution deadline exceeded.", "TimeoutError"));
121
+ const remaining = options.deadline - Date.now();
122
+ const timer = setTimeout(expire, Math.max(0, Math.min(remaining, 2_147_483_647)));
123
+ const checkBudget = () => {
124
+ if (Date.now() >= options.deadline)
125
+ expire();
126
+ signal.throwIfAborted();
127
+ };
128
+ const locks = [];
129
+ try {
130
+ checkBudget();
131
+ if (!parent) {
132
+ for (const identity of identities) {
133
+ const lock = await acquireExecutionClaim({ identity, deadline: options.deadline, signal });
134
+ if (lock)
135
+ locks.push(lock);
136
+ checkBudget();
137
+ if (!lock)
138
+ throw new Error("Browser execution coordination is unavailable or busy; no browser command was run.");
139
+ }
140
+ }
141
+ const execute = async () => {
142
+ checkBudget();
143
+ if (options.waitOnly)
144
+ clearTimeout(timer);
145
+ const scope = { identities: parent?.identities ?? identities, active: true, signal, children: Promise.resolve() };
146
+ try {
147
+ return await executionScope.run(scope, () => run(signal));
148
+ }
149
+ finally {
150
+ scope.active = false;
151
+ await scope.children;
152
+ }
153
+ };
154
+ if (!parent)
155
+ return await execute();
156
+ const result = parent.children.then(execute);
157
+ parent.children = result.then(() => undefined, () => undefined);
158
+ return await result;
159
+ }
160
+ finally {
161
+ clearTimeout(timer);
162
+ await Promise.all(locks.reverse().map(lock => lock.release()));
163
+ }
49
164
  }
50
- export function getManagedSessionPolicyLockPath(sessionName, namespace) {
51
- return join(getCoordinationDirectory(), `.pi-agent-browser-policy-${getPolicyLockDigest(sessionName, namespace)}.lock-v3`);
165
+ export function withBrowserExecutionLock(options, run) {
166
+ return withBrowserExecutionLocks({ identities: [options.identity], signal: options.signal, deadline: options.deadline, waitOnly: options.waitOnly }, run);
52
167
  }
53
168
  function parseOwner(content) {
54
169
  if (Buffer.byteLength(content) > POLICY_LOCK_MAX_BYTES)
55
170
  return undefined;
56
171
  try {
57
172
  const parsed = JSON.parse(content);
58
- return parsed.version === 3
173
+ return parsed.version === 4
174
+ && (parsed.sessionNames === null || Array.isArray(parsed.sessionNames) && parsed.sessionNames.length > 0 && parsed.sessionNames.every(session => typeof session === "string"))
59
175
  && Number.isSafeInteger(parsed.pid) && (parsed.pid ?? 0) > 0
60
176
  && typeof parsed.startIdentity === "string" && parsed.startIdentity.length > 0
61
177
  && typeof parsed.token === "string" && parsed.token.length > 0
@@ -71,7 +187,7 @@ function parseTicket(content, token) {
71
187
  return undefined;
72
188
  try {
73
189
  const parsed = JSON.parse(content);
74
- return parsed.version === 3
190
+ return parsed.version === 4
75
191
  && parsed.token === token
76
192
  && Number.isSafeInteger(parsed.ticket)
77
193
  && (parsed.ticket ?? 0) > 0
@@ -150,7 +266,7 @@ async function readClaims(basePath) {
150
266
  }
151
267
  return claims;
152
268
  }
153
- async function ownerAlive(owner) {
269
+ async function ownerAlive(owner, budget) {
154
270
  try {
155
271
  process.kill(owner.pid, 0);
156
272
  }
@@ -161,7 +277,7 @@ async function ownerAlive(owner) {
161
277
  if (code !== "EPERM")
162
278
  return undefined;
163
279
  }
164
- const current = await readProcessStartIdentity(owner.pid);
280
+ const current = await readProcessStartIdentity(owner.pid, process.platform, budget);
165
281
  return current === undefined ? undefined : processStartIdentitiesMatch(owner.startIdentity, current);
166
282
  }
167
283
  async function removeClaimOwnedBy(path, token, deadline = 0) {
@@ -199,7 +315,7 @@ async function removeClaimOwnedBy(path, token, deadline = 0) {
199
315
  await rm(movedPath, { force: true, recursive: true });
200
316
  return true;
201
317
  }
202
- async function cleanDeadPolicyArtifacts(directory) {
318
+ async function cleanDeadPolicyArtifacts(directory, budget) {
203
319
  let names;
204
320
  try {
205
321
  names = await readdir(directory);
@@ -208,10 +324,12 @@ async function cleanDeadPolicyArtifacts(directory) {
208
324
  return;
209
325
  }
210
326
  for (const name of names.filter((candidate) => candidate.startsWith(".pi-agent-browser-policy-remove-")
211
- || candidate.includes(".lock-v3.candidate-"))) {
327
+ || candidate.includes(".lock-v4.candidate-"))) {
328
+ if (budget.signal?.aborted || Date.now() >= (budget.deadline ?? Infinity))
329
+ return;
212
330
  const path = join(directory, name);
213
331
  const claim = await readClaim(path);
214
- if (claim && await ownerAlive(claim.owner) === false)
332
+ if (claim && await ownerAlive(claim.owner, budget) === false)
215
333
  await rm(path, { force: true, recursive: true }).catch(() => undefined);
216
334
  }
217
335
  }
@@ -244,23 +362,48 @@ function waitForRetry(signal) {
244
362
  export async function acquireManagedSessionPolicyLock(options) {
245
363
  if (options.signal?.aborted)
246
364
  return undefined;
365
+ let identity;
366
+ try {
367
+ identity = await resolveBrowserExecutionIdentity({ ...options, ownedManagedSession: true });
368
+ }
369
+ catch {
370
+ return undefined;
371
+ }
372
+ const scope = executionScope.getStore();
373
+ if (scope) {
374
+ if (!scope.active || !scopeCovers(scope.identities, groupExecutionIdentities([identity])))
375
+ return undefined;
376
+ return { release: async () => undefined };
377
+ }
378
+ return acquireExecutionClaim({ identity: groupExecutionIdentities([identity])[0], signal: options.signal, timeoutMs: options.timeoutMs });
379
+ }
380
+ async function acquireExecutionClaim(options) {
381
+ if (options.signal?.aborted)
382
+ return undefined;
383
+ const deadline = options.deadline ?? Date.now() + (options.timeoutMs ?? POLICY_LOCK_WAIT_MS);
384
+ // timeoutMs: 0 retains the existing single-attempt policy probe contract.
385
+ const budget = { signal: options.signal, deadline: options.timeoutMs === 0 ? undefined : deadline };
247
386
  const platform = process.platform;
248
387
  const directory = getCoordinationDirectory(platform);
249
388
  if (!await ensureCoordinationDirectory(directory, platform))
250
389
  return undefined;
251
- const basePath = getManagedSessionPolicyLockPath(options.sessionName, options.namespace);
390
+ const basePath = getBrowserExecutionLockPath(options.identity);
252
391
  const token = randomUUID();
253
- const startIdentity = await readProcessStartIdentity(process.pid);
392
+ const startIdentity = await readProcessStartIdentity(process.pid, process.platform, budget);
254
393
  if (!startIdentity)
255
394
  return undefined;
256
- const owner = { pid: process.pid, startIdentity, token, version: 3 };
395
+ const owner = { pid: process.pid, startIdentity, token, sessionNames: options.identity.sessionNames, version: 4 };
396
+ const ownerContent = JSON.stringify(owner);
397
+ // Never publish metadata our reader cannot validate and ownership-safely remove.
398
+ if (Buffer.byteLength(ownerContent) > POLICY_LOCK_MAX_BYTES)
399
+ return undefined;
257
400
  const candidatePath = `${basePath}.candidate-${token}`;
258
401
  const claimPath = `${basePath}.claim-${token}`;
259
402
  let claimPublished = false;
260
403
  let lockAcquired = false;
261
404
  try {
262
405
  await mkdir(candidatePath, { mode: 0o700 });
263
- await writeFile(join(candidatePath, LOCK_OWNER_FILE), JSON.stringify(owner), { encoding: "utf8", flag: "wx", mode: 0o600 });
406
+ await writeFile(join(candidatePath, LOCK_OWNER_FILE), ownerContent, { encoding: "utf8", flag: "wx", mode: 0o600 });
264
407
  await rename(candidatePath, claimPath);
265
408
  claimPublished = true;
266
409
  const initialClaims = await readClaims(basePath);
@@ -269,11 +412,10 @@ export async function acquireManagedSessionPolicyLock(options) {
269
412
  const maxTicket = initialClaims.reduce((max, claim) => claim.ticket === null ? max : Math.max(max, claim.ticket), 0);
270
413
  if (!Number.isSafeInteger(maxTicket + 1))
271
414
  return undefined;
272
- const ticket = { ticket: maxTicket + 1, token, version: 3 };
415
+ const ticket = { ticket: maxTicket + 1, token, version: 4 };
273
416
  const ticketCandidatePath = join(claimPath, `.ticket-${token}.tmp`);
274
417
  await writeFile(ticketCandidatePath, JSON.stringify(ticket), { encoding: "utf8", flag: "wx", mode: 0o600 });
275
418
  await rename(ticketCandidatePath, join(claimPath, LOCK_TICKET_FILE));
276
- const deadline = Date.now() + (options.timeoutMs ?? POLICY_LOCK_WAIT_MS);
277
419
  while (!options.signal?.aborted) {
278
420
  const claims = await readClaims(basePath);
279
421
  if (!claims)
@@ -283,13 +425,15 @@ export async function acquireManagedSessionPolicyLock(options) {
283
425
  return undefined;
284
426
  let blocked = false;
285
427
  for (const claim of claims) {
286
- if (claim.owner.token === token || !claimPrecedes(claim, ownClaim))
428
+ const disjoint = owner.sessionNames !== null && claim.owner.sessionNames !== null
429
+ && !owner.sessionNames.some(session => claim.owner.sessionNames.includes(session));
430
+ if (claim.owner.token === token || disjoint || !claimPrecedes(claim, ownClaim))
287
431
  continue;
288
432
  // Choosing is transient: a later published ticket must not make
289
433
  // its predecessor wait on it using an earlier null snapshot.
290
434
  if (await hasPublishedLaterTicket(claim, ownClaim))
291
435
  continue;
292
- const alive = await ownerAlive(claim.owner);
436
+ const alive = await ownerAlive(claim.owner, budget);
293
437
  if (alive === false) {
294
438
  await removeClaimOwnedBy(claim.path, claim.owner.token);
295
439
  continue;
@@ -311,7 +455,9 @@ export async function acquireManagedSessionPolicyLock(options) {
311
455
  break;
312
456
  }
313
457
  if (!blocked) {
314
- await cleanDeadPolicyArtifacts(directory);
458
+ await cleanDeadPolicyArtifacts(directory, budget);
459
+ if (options.signal?.aborted || (budget.deadline !== undefined && Date.now() >= budget.deadline))
460
+ return undefined;
315
461
  lockAcquired = true;
316
462
  return { release: async () => { await removeClaimOwnedBy(claimPath, token, Date.now() + POLICY_LOCK_WAIT_MS); } };
317
463
  }
@@ -2,7 +2,7 @@ import { lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
2
2
  import { basename, dirname, extname, join, resolve } from "node:path";
3
3
  import { foldAgentBrowserFilesystemIdentity } from "../../argv-grammar.js";
4
4
  import { parseWaitCommandTokens } from "../../argv-descriptor.js";
5
- import { getRecordCommandOperands } from "../../command-taxonomy.js";
5
+ import { getRecordCommandOperandIndices, getRecordCommandOperands } from "../../command-taxonomy.js";
6
6
  const SCREENSHOT_IMAGE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".webp"];
7
7
  function isSingleScreenshotPathToken(token) {
8
8
  const explicitlyRelative = token.startsWith("./") || token.startsWith("../");
@@ -38,20 +38,25 @@ export function getScreenshotPathTokenIndex(commandTokens) {
38
38
  return undefined;
39
39
  }
40
40
  const DIFF_SCREENSHOT_VALUE_FLAGS = new Set(["-b", "--baseline", "-o", "--output", "-s", "--selector", "-t", "--threshold"]);
41
- function getDiffScreenshotOutputPath(commandTokens) {
42
- let outputPath;
41
+ export function getDiffFilePathIndices(commandTokens) {
42
+ if (commandTokens[0] !== "diff" || !["snapshot", "screenshot"].includes(commandTokens[1]))
43
+ return {};
44
+ const valueFlags = commandTokens[1] === "screenshot" ? DIFF_SCREENSHOT_VALUE_FLAGS : new Set(["-b", "--baseline", "-s", "--selector", "-d", "--depth"]);
45
+ const paths = {};
43
46
  for (let index = 2; index < commandTokens.length; index += 1) {
44
47
  const token = commandTokens[index];
45
- if (!DIFF_SCREENSHOT_VALUE_FLAGS.has(token))
48
+ if (!valueFlags.has(token))
46
49
  continue;
47
50
  const value = commandTokens[index + 1];
48
51
  if (value === undefined)
49
- return undefined;
52
+ return {};
50
53
  if (token === "-o" || token === "--output")
51
- outputPath = value;
54
+ paths.output = index + 1;
55
+ if (token === "-b" || token === "--baseline")
56
+ paths.baseline = index + 1;
52
57
  index += 1;
53
58
  }
54
- return outputPath;
59
+ return paths;
55
60
  }
56
61
  function canonicalizeArtifactPath(absolutePath, platform, seenSymlinks) {
57
62
  let cursor = absolutePath;
@@ -102,28 +107,30 @@ export function getRecordContactSheetDestination(commandTokens) {
102
107
  const extension = extname(path);
103
108
  return extension ? `${path.slice(0, -extension.length)}.contact-sheet.png` : undefined;
104
109
  }
105
- export function getExplicitArtifactDestination(commandTokens) {
110
+ export function getExplicitArtifactDestinationIndex(commandTokens) {
106
111
  const command = commandTokens[0];
107
112
  const subcommand = commandTokens[1];
108
- if (command === "screenshot") {
109
- const index = getScreenshotPathTokenIndex(commandTokens);
110
- return index === undefined ? undefined : commandTokens[index];
111
- }
113
+ if (command === "screenshot")
114
+ return getScreenshotPathTokenIndex(commandTokens);
112
115
  if (command === "download")
113
- return commandTokens[2];
116
+ return 2;
114
117
  if (command === "pdf")
115
- return commandTokens[1];
118
+ return 1;
116
119
  if (command === "wait")
117
- return parseWaitCommandTokens(commandTokens).downloadPath;
120
+ return parseWaitCommandTokens(commandTokens).downloadPathIndex;
118
121
  if (command === "state" && subcommand === "save")
119
- return commandTokens[2];
122
+ return 2;
120
123
  if (command === "diff" && subcommand === "screenshot")
121
- return getDiffScreenshotOutputPath(commandTokens);
124
+ return getDiffFilePathIndices(commandTokens).output;
122
125
  if (command === "network" && subcommand === "har" && commandTokens[2] === "stop")
123
- return commandTokens[3];
126
+ return 3;
124
127
  if ((command === "trace" || command === "profiler") && subcommand === "stop")
125
- return commandTokens[2];
128
+ return 2;
126
129
  if (command === "record")
127
- return getRecordCommandOperands(commandTokens).path;
130
+ return getRecordCommandOperandIndices(commandTokens)[0];
128
131
  return undefined;
129
132
  }
133
+ export function getExplicitArtifactDestination(commandTokens) {
134
+ const index = getExplicitArtifactDestinationIndex(commandTokens);
135
+ return index === undefined ? undefined : commandTokens[index];
136
+ }
@@ -432,7 +432,7 @@ export function buildSourceLookupElectronNextActions(sourceLookup) {
432
432
  if (sessionName)
433
433
  actions.push({ id: "snapshot-electron-session", params: { args: withOptionalSessionArgs(sessionName, ["snapshot", "-i"]) }, reason: "Refresh interactive refs in the attached Electron session before retrying source lookup with a narrower target.", safety: "Read-only snapshot; no app mutation.", tool: "agent_browser" });
434
434
  if (launchId)
435
- actions.push({ id: "probe-electron-launch", params: { electron: { action: "probe", launchId } }, reason: "Collect bounded wrapper/session context for the packaged Electron launch after sourceLookup found no candidates.", safety: "Read-only probe of title, URL, focus, tabs, and compact snapshot metadata.", tool: "agent_browser" });
435
+ actions.push({ id: "probe-electron-launch", params: { action: "probe", launchId }, reason: "Collect bounded wrapper/session context for the packaged Electron launch after sourceLookup found no candidates.", safety: "Read-only probe of title, URL, focus, tabs, and compact snapshot metadata.", tool: "agent_browser_electron" });
436
436
  if (sessionName)
437
437
  actions.push({ id: "list-electron-tabs", params: { args: withOptionalSessionArgs(sessionName, ["tab", "list"]) }, reason: "Check current Electron tabs/targets before choosing a narrower selector or @ref.", safety: "Read-only tab listing.", tool: "agent_browser" });
438
438
  return actions;
@@ -863,7 +863,7 @@ export async function collectTimeoutPartialProgress(options) {
863
863
  if ((options.commandTokens[0] === "session" && options.commandTokens[1] === "info") || isBrowserIndependentRead(options.commandTokens, options.stdin))
864
864
  return undefined;
865
865
  const rawSteps = getTimeoutProgressSteps(options.compiledJob, options.commandTokens, options.stdin);
866
- const artifacts = await collectTimeoutArtifactEvidence(options.cwd, rawSteps);
866
+ const artifacts = await collectTimeoutArtifactEvidence(options.operationCwd ?? options.cwd, rawSteps);
867
867
  const urlData = await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName });
868
868
  const recoveredUrl = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
869
869
  const titleData = recoveredUrl
@@ -901,6 +901,18 @@ function sanitizeCurrentPageUrlForTimeoutDiagnostic(url) {
901
901
  return redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(url));
902
902
  }
903
903
  }
904
+ export function redactTimeoutPartialProgress(progress) {
905
+ const redact = (value) => {
906
+ if (typeof value === "string")
907
+ return /^https?:\/\//i.test(value) ? sanitizeCurrentPageUrlForTimeoutDiagnostic(value) : redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(value));
908
+ if (Array.isArray(value))
909
+ return value.map(redact);
910
+ if (isRecord(value))
911
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item)]));
912
+ return value;
913
+ };
914
+ return redact(progress);
915
+ }
904
916
  export function formatTimeoutPartialProgressText(progress, pageTargetUnknown = false) {
905
917
  const lines = [`Timeout partial progress: ${progress.summary}`];
906
918
  const currentPageTitle = progress.currentPage?.title ? redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(progress.currentPage.title)) : undefined;
@@ -4,6 +4,7 @@ import { redactNetworkSourceLookupSurface } from "../../input-modes/lookups.js";
4
4
  import { buildAgentBrowserNextActions } from "../../results/action-recommendations.js";
5
5
  import { buildAgentBrowserResultCategoryDetails } from "../../results/categories.js";
6
6
  import { extractAgentBrowserLifecycle } from "../../results/presentation/common.js";
7
+ import { projectAgentBrowserObservation } from "../../results/presentation/content.js";
7
8
  import { formatSessionArtifactRetentionSummary } from "../../results/artifact-manifest.js";
8
9
  import { alignPageChangeSummaryNextActionIds, appendUniqueAgentBrowserNextActions, applyNamespaceToNextActions, isStandaloneSnapshotNextAction, withOptionalSessionArgs, } from "../../results/next-actions.js";
9
10
  import { buildConnectedSessionNextActions, buildNoActivePageNextActions, buildPendingWebMcpNextActions, buildSessionAwareStaleRefNextActions, buildSessionTabRecoveryNextActions, } from "../../results/recovery-next-actions.js";
@@ -86,9 +87,7 @@ export function redactRecoveryHint(recoveryHint) {
86
87
  }
87
88
  export function buildJsonVisibleContent(options) {
88
89
  const { error, presentation, succeeded, warnings } = options;
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
- if (isRecord(payload) && isRecord(payload.data) && isRecord(presentation.data) && typeof presentation.data.wsUrl === "string")
91
- payload.data.wsUrl = presentation.data.wsUrl;
90
+ const payload = projectAgentBrowserObservation({ ...presentation, ...options.details, error, warnings: warnings && warnings.length > 0 ? warnings : undefined }, succeeded);
92
91
  const images = presentation.content.filter((item) => item.type === "image");
93
92
  return [{ type: "text", text: JSON.stringify(payload, null, 2) }, ...images];
94
93
  }
@@ -175,8 +174,9 @@ export function buildRedactedPresentationContent(options) {
175
174
  return contentWithSessionWarnings.map((item) => {
176
175
  if (item.type !== "text")
177
176
  return item;
178
- const exactRedactedText = redactExactSensitiveText(item.text, exactSensitiveValues);
179
- return userRequestedJson && !plainTextInspection ? { ...item, text: exactRedactedText } : { ...item, text: redactSensitiveText(exactRedactedText) };
177
+ if (userRequestedJson && !plainTextInspection)
178
+ return { ...item, text: JSON.stringify(redactExactSensitiveValue(JSON.parse(item.text), exactSensitiveValues), null, 2) };
179
+ return { ...item, text: redactSensitiveText(redactExactSensitiveText(item.text, exactSensitiveValues)) };
180
180
  });
181
181
  }
182
182
  export async function prepareFinalResultRecoveryState(options) {
@@ -339,6 +339,10 @@ function buildResultNextActions(options) {
339
339
  append(buildComboboxFocusNextActions(options.executionPlan.sessionName));
340
340
  if (options.managedSessionOutcome)
341
341
  appendUnique(buildManagedSessionFreshFailureNextActions(options.managedSessionOutcome));
342
+ if (options.currentSessionTabTargetUnknown && ["aborted", "parse-failure"].includes(options.categoryDetails.failureCategory ?? "")) {
343
+ nextActions = nextActions.filter(action => !isStandaloneSnapshotNextAction(action));
344
+ appendUnique([{ id: "verify-page-target-after-interruption", tool: "agent_browser", params: { args: withOptionalSessionArgs(options.executionPlan.sessionName, ["batch", "--bail"]), stdin: JSON.stringify([["get", "url"], ["snapshot", "-i"]]) }, reason: "Verify the current URL and inspect the page after an interrupted operation.", safety: "Read-only recovery. The mutation may already have happened; inspect before deciding whether to retry." }]);
345
+ }
342
346
  if (options.categoryDetails.failureCategory === "timeout" && options.processResult.timedOut) {
343
347
  if (options.currentSessionTabTargetUnknown)
344
348
  nextActions = nextActions.filter((action) => !isStandaloneSnapshotNextAction(action));
@@ -354,14 +358,11 @@ function buildResultNextActions(options) {
354
358
  export function formatAgentBrowserNextActionsText(nextActions) {
355
359
  if (!nextActions || nextActions.length === 0)
356
360
  return undefined;
357
- const lines = nextActions.slice(0, 6).map((action) => {
358
- const params = action.params
359
- ? { ...action.params, ...(action.params.stdin !== undefined && action.params.stdin.length > 500 ? { stdin: "[omitted; use details.nextActions]" } : {}) }
360
- : undefined;
361
- const payload = action.artifactPath ? { artifactPath: action.artifactPath } : params;
361
+ const lines = nextActions.map((action) => {
362
+ const payload = action.artifactPath ? { artifactPath: action.artifactPath } : action.params;
362
363
  return `- ${action.id}${payload ? ` ${redactSensitiveText(JSON.stringify(payload))}` : ""}: ${redactSensitiveText(action.reason)}`;
363
364
  });
364
- return ["Next actions:", ...lines, "The same redacted payloads are available in details.nextActions."].join("\n");
365
+ return ["Next actions:", ...lines].join("\n");
365
366
  }
366
367
  function formatFailureNextActionsText(options, nextActions) {
367
368
  return options.categoryDetails.resultCategory === "failure" ? formatAgentBrowserNextActionsText(nextActions) : undefined;
@@ -438,6 +439,7 @@ function buildAgentBrowserResultDetails(options, nextActions) {
438
439
  managedSessionOutcome: options.managedSessionOutcome,
439
440
  imagePath: options.presentation.imagePath,
440
441
  imagePaths: options.presentation.imagePaths,
442
+ imageObservations: options.presentation.imageObservations,
441
443
  nextActions,
442
444
  pageChangeSummary,
443
445
  clickDispatch: options.clickDispatchDiagnostic,
@@ -485,6 +487,10 @@ export function buildFinalAgentBrowserToolResult(options) {
485
487
  const lifecycle = extractAgentBrowserLifecycle(options.presentationEnvelope?.data);
486
488
  const browserWindow = buildBrowserWindowStatus(options, lifecycle);
487
489
  const details = buildAgentBrowserResultDetails(options, nextActions);
490
+ if (options.modelVisible === false) {
491
+ const result = { content: [], details: redactToolDetails(details, options.exactSensitiveValues), isError: !options.succeeded };
492
+ return options.compiledNetworkSourceLookup ? redactNetworkSourceLookupSurface(result) : result;
493
+ }
488
494
  const visibleRefFallbackText = formatVisibleRefFallbackText(options.visibleRefFallbackDiagnostic);
489
495
  const richInputRecoveryText = formatRichInputRecoveryText(options.richInputRecoveryDiagnostic);
490
496
  const semanticActionCandidateText = nextActions ? formatSemanticActionCandidateText(nextActions) : undefined;
@@ -509,10 +515,17 @@ export function buildFinalAgentBrowserToolResult(options) {
509
515
  const appendedDiagnosticText = redactSensitiveText(redactExactSensitiveText(rawAppendedDiagnosticText, options.exactSensitiveValues));
510
516
  const shouldAppendDiagnosticText = appendedDiagnosticText.length > 0 && (!options.userRequestedJson || options.plainTextInspection);
511
517
  let content = shouldAppendDiagnosticText && options.redactedContent[0]?.type === "text" ? [{ ...options.redactedContent[0], text: `${options.redactedContent[0].text}\n\n${appendedDiagnosticText}` }, ...options.redactedContent.slice(1)] : options.redactedContent;
512
- if (options.electronLaunchRecord && options.succeeded && content[0]?.type === "text") {
518
+ if (options.electronLaunchRecord && options.succeeded && !options.userRequestedJson && content[0]?.type === "text") {
513
519
  content = [{ ...content[0], text: redactSensitiveText(formatElectronLaunchText({ handoff: options.electronHandoff, record: options.electronLaunchRecord, targets: options.electronLaunch?.targets ?? [], upstreamText: content[0].text })) }, ...content.slice(1)];
514
520
  }
515
- const result = { content, details: redactToolDetails(details, options.exactSensitiveValues), isError: !options.succeeded };
521
+ const redactedDetails = redactToolDetails(details, options.exactSensitiveValues);
522
+ if (options.userRequestedJson && !options.plainTextInspection) {
523
+ const prior = options.redactedContent[0]?.type === "text" ? JSON.parse(options.redactedContent[0].text) : undefined;
524
+ if (Array.isArray(prior?.warnings))
525
+ redactedDetails.warnings = prior.warnings;
526
+ content = buildJsonVisibleContent({ error: redactedDetails.error, details: redactedDetails, presentation: options.presentation, succeeded: options.succeeded, warnings: prior?.warnings });
527
+ }
528
+ const result = { content, details: redactedDetails, isError: !options.succeeded };
516
529
  return options.compiledNetworkSourceLookup ? redactNetworkSourceLookupSurface(result) : result;
517
530
  }
518
531
  export function isMissingAgentBrowserBinary(processResult) {
@@ -6,6 +6,7 @@ import { formatWebMcpCatalogUpdate } from "../../results/presentation/common.js"
6
6
  import { withOwnedManagedSessionContext } from "../../managed-session-restore.js";
7
7
  import { cleanupClickDispatchProbe } from "./click-dispatch.js";
8
8
  import { applyBrowserRunStatePatch, getSessionContextKey } from "./session-state.js";
9
+ import { buildScreenshotGeometry, collectScreenshotSample } from "./screenshot-observation.js";
9
10
  import { buildJsonVisibleContent, buildMissingBinaryFailureResult } from "./final-result.js";
10
11
  import { prepareBrowserRun } from "./prepare.js";
11
12
  import { processBrowserOutput } from "./process-output.js";
@@ -27,6 +28,12 @@ export async function runAgentBrowserTool(options) {
27
28
  result.content.push({ type: "text", text: notice });
28
29
  }
29
30
  }
31
+ if (result.isError && details?.error === undefined) {
32
+ details = { ...details, error: details?.validationError ?? details?.summary ?? result.content.filter(part => part.type === "text").map(part => part.text).join("\n") };
33
+ result.details = details;
34
+ }
35
+ if (options.modelVisible === false)
36
+ result.content = [];
30
37
  const sessionKey = getSessionContextKey(typeof details?.sessionName === "string" ? details.sessionName : undefined, typeof details?.namespace === "string" ? details.namespace : undefined);
31
38
  const page = options.state.sessionPageState.get(sessionKey);
32
39
  return page.tabReopenPending === undefined ? result : {
@@ -39,11 +46,12 @@ async function runAgentBrowserToolInContext(options) {
39
46
  applyBrowserRunStatePatch(options.state, preparedResult.kind === "ready" ? preparedResult.prepared.statePatch : preparedResult.statePatch);
40
47
  if (preparedResult.kind === "early-result") {
41
48
  const result = preparedResult.result;
42
- if (options.input.toolArgs.includes("--json") && !isPlainTextInspectionArgs(options.input.toolArgs)) {
49
+ if (options.modelVisible !== false && options.input.toolArgs.includes("--json") && !isPlainTextInspectionArgs(options.input.toolArgs)) {
43
50
  const details = isRecord(result.details) ? result.details : {};
44
51
  const summary = result.content.filter(item => item.type === "text").map(item => item.text).join("\n");
45
52
  result.content = buildJsonVisibleContent({
46
53
  error: result.isError ? details.validationError ?? summary : null,
54
+ details,
47
55
  presentation: { content: result.content, data: details.data, summary },
48
56
  succeeded: result.isError !== true,
49
57
  });
@@ -54,6 +62,11 @@ async function runAgentBrowserToolInContext(options) {
54
62
  const ownedManagedSession = prepared.ownedManagedSessionContext;
55
63
  return await withOwnedManagedSessionContext(ownedManagedSession, async () => {
56
64
  try {
65
+ const geometryOptions = { command: prepared.commandTokens, cwd: options.cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, signal: options.signal,
66
+ env: ownedManagedSession ? { AGENT_BROWSER_IDLE_TIMEOUT_MS: options.implicitSessionIdleTimeoutMs } : undefined };
67
+ // Do not start a cold browser through a probe or consume launch flags ahead of the requested capture.
68
+ const probeGeometry = prepared.commandTokens[0] === "screenshot" && !!prepared.priorSessionTabTarget && !prepared.priorSessionTabTargetUnknown && !prepared.executionPlan.startupScopedFlags?.length;
69
+ const before = probeGeometry ? await withChromeStartupArgs(prepared.chromeStartupArgs, () => collectScreenshotSample(geometryOptions)) : undefined;
57
70
  const artifactRunStartedAtMs = Date.now();
58
71
  const processResult = await withChromeStartupArgs(prepared.chromeStartupArgs, () => runAgentBrowserProcess({
59
72
  args: prepared.processArgs,
@@ -70,6 +83,8 @@ async function runAgentBrowserToolInContext(options) {
70
83
  stdin: prepared.processStdin,
71
84
  timeoutMs: prepared.processTimeoutMs,
72
85
  }));
86
+ const after = probeGeometry && !processResult.aborted && !processResult.timedOut && processResult.exitCode === 0
87
+ ? await withChromeStartupArgs(prepared.chromeStartupArgs, () => collectScreenshotSample(geometryOptions)) : undefined;
73
88
  const missingBinaryResult = await buildMissingBinaryFailureResult({
74
89
  compatibilityWorkaround: prepared.compatibilityWorkaround,
75
90
  electronLaunch: prepared.electronLaunch,
@@ -88,6 +103,11 @@ async function runAgentBrowserToolInContext(options) {
88
103
  return missingBinaryResult;
89
104
  const output = await processBrowserOutput({ ...options, artifactRunStartedAtMs, prepared, processResult });
90
105
  applyBrowserRunStatePatch(options.state, output.statePatch);
106
+ if (isRecord(output.result.details) && Array.isArray(output.result.details.imageObservations) && prepared.commandTokens[0] === "screenshot") {
107
+ output.result.details.imageObservations = redactSensitiveValue(output.result.details.imageObservations.map(image => ({
108
+ ...image, geometry: buildScreenshotGeometry({ capture: image.capture, pixels: image.pixels, before, after }),
109
+ })));
110
+ }
91
111
  return output.result;
92
112
  }
93
113
  finally {
@@ -2,7 +2,7 @@ import { isRecord } from "../../../parsing.js";
2
2
  import { buildAgentBrowserResultCategoryDetails } from "../../../results/categories.js";
3
3
  import { buildSnapshotPresentation } from "../../../results/snapshot.js";
4
4
  import { extractRefSnapshotFromData } from "../../../session-page-state.js";
5
- import { redactSensitiveText } from "../../../runtime.js";
5
+ import { redactSensitiveText, redactSensitiveValue } from "../../../runtime.js";
6
6
  import { collectScrollPositionSnapshot } from "../diagnostics.js";
7
7
  import { buildSessionDetailFields, runSessionCommandData } from "../session-state.js";
8
8
  function parseSnapshotFilterRequest(commandTokens) {
@@ -237,7 +237,9 @@ export async function trySnapshotFilter(options) {
237
237
  const diff = request.diff ? buildSnapshotDiff(options.previousRefSnapshot, fullSnapshot) : undefined;
238
238
  if (fullSnapshot)
239
239
  options.sessionPageState.applyRefSnapshot({ sessionName: options.sessionStateKey ?? options.sessionName, snapshot: fullSnapshot, update: options.sessionPageStateUpdate });
240
- const presentation = await buildSnapshotPresentation(filtered.data, options.persistentArtifactStore, options.artifactManifest);
240
+ const presentation = options.modelVisible === false
241
+ ? { content: [], data: redactSensitiveValue(filtered.data), summary: "Snapshot" }
242
+ : await buildSnapshotPresentation(filtered.data, options.persistentArtifactStore, options.artifactManifest);
241
243
  const summary = request.role || request.search
242
244
  ? `Snapshot filter: ${filtered.matchedRefs}/${filtered.totalRefs} direct refs matched${request.role ? ` role=${request.role}` : ""}${request.search ? ` search ${JSON.stringify(request.search)}` : ""}; ${filtered.visibleLines} surrounding snapshot line${filtered.visibleLines === 1 ? "" : "s"} shown.`
243
245
  : request.diff
@@ -36,8 +36,6 @@ export function normalizeRunInput(input) {
36
36
  switch (input.kind) {
37
37
  case "electron":
38
38
  return { ...base, compiledElectron: input.compiledElectron, redactedCompiledElectron: input.redactedCompiledElectron };
39
- case "job":
40
- return { ...base, compiledJob: input.compiledJob, redactedCompiledJob: input.redactedCompiledJob };
41
39
  case "networkSourceLookup":
42
40
  return { ...base, compiledNetworkSourceLookup: input.compiledNetworkSourceLookup, redactedCompiledNetworkSourceLookup: input.redactedCompiledNetworkSourceLookup };
43
41
  case "qa":
@@ -46,7 +44,6 @@ export function normalizeRunInput(input) {
46
44
  return { ...base, compiledSemanticAction: input.compiledSemanticAction, redactedCompiledSemanticAction: input.redactedCompiledSemanticAction };
47
45
  case "sourceLookup":
48
46
  return { ...base, compiledSourceLookup: input.compiledSourceLookup, redactedCompiledSourceLookup: input.redactedCompiledSourceLookup };
49
- case "script":
50
47
  case "args":
51
48
  return base;
52
49
  }
@@ -937,6 +934,7 @@ export async function prepareBrowserRun(options) {
937
934
  }
938
935
  const persistentArtifactStore = getPersistentSessionArtifactStore(options.ctx);
939
936
  const snapshotFilter = await trySnapshotFilter({
937
+ modelVisible: options.modelVisible,
940
938
  artifactManifest: state.artifactManifest,
941
939
  commandTokens,
942
940
  compatibilityWorkaround,