pi-agent-browser-native 0.6.17 → 0.7.1

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 (49) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +55 -167
  3. package/dist/extensions/agent-browser/index.js +312 -223
  4. package/dist/extensions/agent-browser/lib/browser-transcript.js +33 -0
  5. package/dist/extensions/agent-browser/lib/execution-cwd.js +4 -2
  6. package/dist/extensions/agent-browser/lib/input-modes/job.js +2 -213
  7. package/dist/extensions/agent-browser/lib/input-modes/params.js +146 -142
  8. package/dist/extensions/agent-browser/lib/input-modes/script.js +64 -92
  9. package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +170 -24
  10. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +13 -1
  11. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +26 -13
  12. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +21 -1
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/snapshot-filter.js +4 -2
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +1 -3
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +35 -20
  16. package/dist/extensions/agent-browser/lib/orchestration/browser-run/recording-recovery.js +3 -1
  17. package/dist/extensions/agent-browser/lib/orchestration/browser-run/screenshot-observation.js +79 -0
  18. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +3 -4
  19. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +19 -52
  20. package/dist/extensions/agent-browser/lib/orchestration/native-session-defaults.js +3 -3
  21. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +3 -3
  22. package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +91 -278
  23. package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +9 -9
  24. package/dist/extensions/agent-browser/lib/playbook.js +46 -25
  25. package/dist/extensions/agent-browser/lib/process-identity.js +22 -6
  26. package/dist/extensions/agent-browser/lib/process.js +16 -13
  27. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +2 -2
  28. package/dist/extensions/agent-browser/lib/results/next-actions.js +1 -2
  29. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +25 -4
  30. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +6 -2
  31. package/dist/extensions/agent-browser/lib/results/presentation/common.js +2 -0
  32. package/dist/extensions/agent-browser/lib/results/presentation/content.js +39 -0
  33. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +3 -3
  34. package/dist/extensions/agent-browser/lib/results/presentation/large-output.js +41 -1
  35. package/dist/extensions/agent-browser/lib/results/presentation.js +16 -11
  36. package/dist/extensions/agent-browser/lib/runtime.js +18 -6
  37. package/dist/extensions/agent-browser/lib/session-page-state.js +3 -4
  38. package/dist/extensions/agent-browser/lib/tool-surface.js +138 -0
  39. package/dist/extensions/agent-browser/script-worker.js +4 -1
  40. package/docs/ARCHITECTURE.md +46 -62
  41. package/docs/COMMAND_REFERENCE.md +77 -136
  42. package/docs/ELECTRON.md +51 -49
  43. package/docs/RELEASE.md +35 -21
  44. package/docs/REQUIREMENTS.md +18 -14
  45. package/docs/SUPPORT_MATRIX.md +33 -15
  46. package/docs/TOOL_CONTRACT.md +216 -405
  47. package/docs/platform-smoke.md +2 -2
  48. package/package.json +1 -1
  49. package/scripts/doctor.mjs +2 -2
@@ -1,15 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
2
  import { existsSync } from "node:fs";
4
3
  import { dirname, join } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
- import { parseArgvDescriptor } from "../argv-descriptor.js";
7
- import { getFlagName } from "../argv-grammar.js";
8
- import { isBrowserIndependentRead, needsManagedSession } from "../command-policy.js";
9
- import { isCloseCommand } from "../command-taxonomy.js";
10
- import { LAUNCH_SCOPED_FLAGS, MANAGED_RESTORE_INCOMPATIBLE_FLAGS } from "../launch-scoped-flags.js";
11
5
  import { isRecord } from "../parsing.js";
12
- import { validateToolArgs } from "../runtime.js";
6
+ import { extractExplicitNamespace, extractExplicitSessionName, scanUpstreamGlobalFlagOccurrences } from "../argv-grammar.js";
7
+ import { getUpstreamEffectiveBatchSteps } from "../orchestration/batch-stdin.js";
8
+ import { extractUpstreamCommandTokens } from "../argv-descriptor.js";
9
+ import { isCloseAllCommand } from "../command-taxonomy.js";
10
+ import { redactSensitiveText, validateToolArgs } from "../runtime.js";
13
11
  export const AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES = 64 * 1_024;
14
12
  export const AGENT_BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS = 120_000;
15
13
  export const AGENT_BROWSER_SCRIPT_NAMESPACE = "";
@@ -18,7 +16,6 @@ export const AGENT_BROWSER_SCRIPT_MAX_CALLS = 25;
18
16
  export const AGENT_BROWSER_SCRIPT_FINAL_OUTPUT_MAX_BYTES = 64 * 1_024;
19
17
  export const AGENT_BROWSER_SCRIPT_IPC_MESSAGE_MAX_BYTES = 1 * 1_024 * 1_024;
20
18
  export const AGENT_BROWSER_SCRIPT_IPC_CUMULATIVE_MAX_BYTES = 8 * 1_024 * 1_024;
21
- export const AGENT_BROWSER_SCRIPT_SPILL_MAX_BYTES = 512 * 1_024;
22
19
  function findPackageRoot(startDir) {
23
20
  let currentDir = startDir;
24
21
  for (;;) {
@@ -37,25 +34,13 @@ function resolveScriptWorkerPath() {
37
34
  return workerPath;
38
35
  }
39
36
  const SCRIPT_SESSION_NAME_PATTERN = /^piab-script-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
40
- const SCRIPT_ALLOWED_LAUNCH_FLAG = "--allowed-domains";
41
- const SCRIPT_FORBIDDEN_COMMANDS = new Set(["attach", "auth", "batch", "connect", "script", "session", "state"]);
42
- const SCRIPT_FORBIDDEN_FLAGS = new Set([
43
- ...LAUNCH_SCOPED_FLAGS.filter((flag) => flag !== SCRIPT_ALLOWED_LAUNCH_FLAG),
44
- ...MANAGED_RESTORE_INCOMPATIBLE_FLAGS.filter((flag) => flag !== SCRIPT_ALLOWED_LAUNCH_FLAG),
45
- "--namespace",
46
- "--session",
47
- "--config",
48
- ]);
49
- export function compileAgentBrowserScript(input) {
37
+ export function validateAgentBrowserScriptSource(input) {
50
38
  if (typeof input !== "string")
51
39
  return { error: "script must be a string." };
52
40
  const bytes = Buffer.byteLength(input, "utf8");
53
41
  return bytes > AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES
54
42
  ? { error: `script must be ${AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES} bytes or less.` }
55
- : { compiled: { code: input } };
56
- }
57
- export function createAgentBrowserScriptSessionName() {
58
- return `piab-script-${randomUUID()}`;
43
+ : {};
59
44
  }
60
45
  export function createAgentBrowserScriptCloseArgs(sessionName) {
61
46
  return ["--namespace", AGENT_BROWSER_SCRIPT_NAMESPACE, "--session", sessionName, "close"];
@@ -63,25 +48,6 @@ export function createAgentBrowserScriptCloseArgs(sessionName) {
63
48
  export function isAgentBrowserScriptSessionName(value) {
64
49
  return typeof value === "string" && SCRIPT_SESSION_NAME_PATTERN.test(value);
65
50
  }
66
- function getScriptCallPolicyError(args) {
67
- const descriptor = parseArgvDescriptor(args);
68
- const command = descriptor.commandInfo.command;
69
- if (!command)
70
- return "script browser call args must contain an agent-browser command.";
71
- if (isCloseCommand(command))
72
- return "script browser calls cannot close, quit, or exit their isolated session.";
73
- if (SCRIPT_FORBIDDEN_COMMANDS.has(command))
74
- return `script browser calls cannot use ${command}.`;
75
- if (!needsManagedSession(descriptor) && !isBrowserIndependentRead(descriptor.upstreamCommandTokens))
76
- return `script browser calls cannot use sessionless/local command ${command}.`;
77
- for (const token of args) {
78
- const flag = getFlagName(token);
79
- if (SCRIPT_FORBIDDEN_FLAGS.has(flag)) {
80
- return `script browser calls cannot use ${flag}; the parent owns the isolated session identity and launch policy.`;
81
- }
82
- }
83
- return undefined;
84
- }
85
51
  export function validateAgentBrowserScriptBrowserParams(input) {
86
52
  if (!isRecord(input))
87
53
  return { error: "script browser(params) requires an object." };
@@ -102,53 +68,51 @@ export function validateAgentBrowserScriptBrowserParams(input) {
102
68
  stdin: input.stdin,
103
69
  timeoutMs: input.timeoutMs,
104
70
  };
105
- const policyError = getScriptCallPolicyError(params.args);
106
- if (policyError)
107
- return { error: policyError, policyBlocked: true };
108
71
  const validationError = validateToolArgs(params.args);
109
72
  return validationError ? { error: validationError } : { params };
110
73
  }
111
- function buildRejectedCallEnvelope(error, policyBlocked) {
74
+ export function bindBrowserCodeCall(params, identity) {
75
+ const explicitSession = extractExplicitSessionName(params.args);
76
+ const namespaceFlags = scanUpstreamGlobalFlagOccurrences(params.args, "--namespace");
77
+ const explicitNamespace = extractExplicitNamespace(params.args);
78
+ if ((explicitSession !== undefined && explicitSession !== identity.sessionName)
79
+ || (namespaceFlags.length > 0 && (explicitNamespace || undefined) !== (identity.namespace || undefined))) {
80
+ throw new Error("A code call uses one browser identity. Set session/namespace on agent_browser_code to choose another browser.");
81
+ }
82
+ const tokens = extractUpstreamCommandTokens(params.args);
83
+ if (isCloseAllCommand(tokens) || getUpstreamEffectiveBatchSteps(tokens, params.stdin).some(isCloseAllCommand)) {
84
+ throw new Error("Run namespace-wide close --all directly with agent_browser, outside a session-scoped code call.");
85
+ }
86
+ return { ...params, args: [
87
+ ...(namespaceFlags.length === 0 ? ["--namespace", identity.namespace ?? ""] : []),
88
+ ...(explicitSession === undefined ? ["--session", identity.sessionName] : []),
89
+ ...params.args,
90
+ ] };
91
+ }
92
+ function buildRejectedCallEnvelope(error) {
112
93
  return {
113
94
  data: null,
114
- details: { failureCategory: policyBlocked ? "policy-blocked" : "validation-error", resultCategory: "failure" },
115
95
  error,
116
- failureCategory: policyBlocked ? "policy-blocked" : "validation-error",
117
- ok: false,
96
+ failureCategory: "validation-error",
97
+ success: false,
118
98
  resultCategory: "failure",
119
99
  summary: error,
120
- text: error,
121
100
  };
122
101
  }
123
102
  function normalizeBrowserEnvelope(value) {
124
- if (!isRecord(value)
125
- || typeof value.ok !== "boolean"
126
- || typeof value.text !== "string"
127
- || typeof value.summary !== "string"
128
- || (value.resultCategory !== "success" && value.resultCategory !== "failure")) {
129
- return buildRejectedCallEnvelope("The ordinary agent_browser executor returned an invalid script envelope.", false);
103
+ if (!isRecord(value) || typeof value.success !== "boolean" || (value.resultCategory !== "success" && value.resultCategory !== "failure")) {
104
+ return buildRejectedCallEnvelope("The browser executor returned an invalid code observation.");
130
105
  }
131
- return {
132
- data: value.data ?? null,
133
- details: isRecord(value.details) ? value.details : undefined,
134
- error: typeof value.error === "string" ? value.error : undefined,
135
- failureCategory: value.failureCategory,
136
- nextActions: Array.isArray(value.nextActions) ? value.nextActions : undefined,
137
- ok: value.ok,
138
- resultCategory: value.resultCategory,
139
- successCategory: value.successCategory,
140
- summary: value.summary,
141
- text: value.text,
142
- };
106
+ return value;
143
107
  }
144
108
  function buildStepSummary(index, envelope) {
145
109
  return {
146
110
  failureCategory: envelope.failureCategory,
147
111
  index,
148
- ok: envelope.ok,
112
+ ok: envelope.success,
149
113
  resultCategory: envelope.resultCategory,
150
114
  successCategory: envelope.successCategory,
151
- summary: envelope.summary,
115
+ summary: envelope.summary ?? (typeof envelope.error === "string" ? envelope.error : "Browser call completed."),
152
116
  };
153
117
  }
154
118
  function buildFailedRun(options) {
@@ -168,7 +132,7 @@ function isScriptChildMessage(value) {
168
132
  return true;
169
133
  if (value.type === "call")
170
134
  return typeof value.id === "number" && Number.isSafeInteger(value.id) && value.id > 0;
171
- if (value.type === "emit")
135
+ if (value.type === "emit" || value.type === "image")
172
136
  return true;
173
137
  return value.type === "complete";
174
138
  }
@@ -206,7 +170,7 @@ function serializeFinalOutput(value) {
206
170
  return JSON.stringify(value);
207
171
  }
208
172
  export async function runAgentBrowserScript(options) {
209
- const compiled = compileAgentBrowserScript(options.code);
173
+ const compiled = validateAgentBrowserScriptSource(options.code);
210
174
  if (compiled.error) {
211
175
  return buildFailedRun({ callCount: 0, emitCount: 0, error: compiled.error, failureCategory: "validation-error", rejectedCallCount: 0, steps: [] });
212
176
  }
@@ -241,12 +205,12 @@ export async function runAgentBrowserScript(options) {
241
205
  let cumulativeBytes = 0;
242
206
  let callCount = 0;
243
207
  let rejectedCallCount = 0;
244
- let leaseStarted = false;
245
208
  let ready = false;
246
209
  let stopping = false;
247
210
  let activeCallController;
248
211
  const emissions = [];
249
212
  const steps = [];
213
+ const failures = [];
250
214
  const messages = [];
251
215
  let draining = false;
252
216
  let drainPromise = Promise.resolve();
@@ -275,10 +239,11 @@ export async function runAgentBrowserScript(options) {
275
239
  if (timeout)
276
240
  clearTimeout(timeout);
277
241
  options.signal?.removeEventListener("abort", abortListener);
278
- activeCallController?.abort();
242
+ activeCallController?.abort(result.timedOut ? new DOMException("Browser code deadline exceeded.", "TimeoutError") : options.signal?.reason);
279
243
  killTimer = terminateChild(child);
244
+ // The caller holds the browser lease until dispatch actually settles.
280
245
  if (waitForDrain)
281
- await settleWithin(drainPromise, 5_000);
246
+ await drainPromise.catch(() => undefined);
282
247
  await settleWithin(childExit, 1_000);
283
248
  clearTimeout(killTimer);
284
249
  resolveResult(result);
@@ -287,13 +252,16 @@ export async function runAgentBrowserScript(options) {
287
252
  ...flags,
288
253
  callCount,
289
254
  emitCount: emissions.length,
255
+ ...(emissions.length ? { data: emissions.length === 1 ? emissions[0] : emissions } : {}),
290
256
  error,
291
257
  failureCategory,
258
+ failures,
292
259
  rejectedCallCount,
293
260
  steps,
294
261
  }), waitForDrain);
295
262
  const abortListener = () => {
296
- void fail("Script execution was aborted.", "aborted", { aborted: true }, true);
263
+ const timedOut = options.signal?.reason instanceof Error && options.signal.reason.name === "TimeoutError";
264
+ void fail(timedOut ? "Browser code deadline exceeded." : "Browser code was aborted.", timedOut ? "timeout" : "aborted", timedOut ? { timedOut: true } : { aborted: true }, true);
297
265
  };
298
266
  options.signal?.addEventListener("abort", abortListener, { once: true });
299
267
  timeout = setTimeout(() => {
@@ -333,6 +301,18 @@ export async function runAgentBrowserScript(options) {
333
301
  emissions.push(message.value);
334
302
  continue;
335
303
  }
304
+ if (message.type === "image") {
305
+ try {
306
+ if (!options.emitImage)
307
+ throw new Error("Image emission is unavailable.");
308
+ await options.emitImage(message.value);
309
+ }
310
+ catch (error) {
311
+ await fail(error instanceof Error ? error.message : "Invalid image handle.", "validation-error");
312
+ return;
313
+ }
314
+ continue;
315
+ }
336
316
  if (message.type === "complete") {
337
317
  if (message.error) {
338
318
  await fail(describeScriptError(message.error), "script-error");
@@ -353,7 +333,7 @@ export async function runAgentBrowserScript(options) {
353
333
  await fail(`Final script output exceeds ${AGENT_BROWSER_SCRIPT_FINAL_OUTPUT_MAX_BYTES} bytes.`, "validation-error");
354
334
  return;
355
335
  }
356
- await finish({ callCount, data, emitCount: emissions.length, ok: true, rejectedCallCount, steps }, false);
336
+ await finish({ callCount, data, emitCount: emissions.length, failures, ok: true, rejectedCallCount, steps }, false);
357
337
  return;
358
338
  }
359
339
  callCount += 1;
@@ -365,38 +345,30 @@ export async function runAgentBrowserScript(options) {
365
345
  let envelope;
366
346
  if (!validated.params) {
367
347
  rejectedCallCount += 1;
368
- envelope = buildRejectedCallEnvelope(validated.error ?? "Invalid script browser call.", validated.policyBlocked === true);
348
+ envelope = buildRejectedCallEnvelope(validated.error ?? "Invalid script browser call.");
369
349
  }
370
350
  else {
371
- if (!leaseStarted) {
372
- try {
373
- options.beforeFirstCall?.();
374
- leaseStarted = true;
375
- }
376
- catch {
377
- await fail("Unable to persist the isolated script session lease.", "upstream-error");
378
- return;
379
- }
380
- }
381
351
  activeCallController = new AbortController();
382
352
  try {
383
353
  envelope = normalizeBrowserEnvelope(await options.dispatch(validated.params, activeCallController.signal));
384
354
  }
385
- catch {
386
- envelope = buildRejectedCallEnvelope("The ordinary agent_browser executor failed while dispatching this call.", false);
355
+ catch (error) {
356
+ envelope = buildRejectedCallEnvelope(redactSensitiveText(error instanceof Error ? error.message : "The browser executor failed while dispatching this call."));
387
357
  }
388
358
  finally {
389
359
  activeCallController = undefined;
390
360
  }
391
- if (stopping)
392
- return;
393
361
  }
394
362
  steps.push(buildStepSummary(callCount - 1, envelope));
363
+ if (!envelope.success)
364
+ failures.push({ ...envelope, index: callCount - 1 });
365
+ if (stopping)
366
+ return;
395
367
  try {
396
368
  await sendParentMessage({ envelope, id: message.id, type: "response" });
397
369
  }
398
370
  catch {
399
- await fail("Unable to return a browser result to the script sandbox.", "upstream-error");
371
+ await fail("Unable to return a browser result within the code IPC limit. Narrow the native extraction or use agent_browser with outputPath; already-dispatched effects are not rolled back.", "upstream-error");
400
372
  return;
401
373
  }
402
374
  }
@@ -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
  }
@@ -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;
@@ -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;