pi-agent-browser-native 0.8.3 → 0.9.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 (48) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +12 -11
  3. package/dist/extensions/agent-browser/index.js +96 -320
  4. package/dist/extensions/agent-browser/lib/argv-descriptor.js +1 -1
  5. package/dist/extensions/agent-browser/lib/argv-grammar.js +3 -3
  6. package/dist/extensions/agent-browser/lib/browser-journal.js +33 -12
  7. package/dist/extensions/agent-browser/lib/browser-transcript.js +1 -1
  8. package/dist/extensions/agent-browser/lib/command-taxonomy.js +0 -3
  9. package/dist/extensions/agent-browser/lib/config-policy.js +0 -13
  10. package/dist/extensions/agent-browser/lib/config.js +3 -48
  11. package/dist/extensions/agent-browser/lib/electron/launch.js +4 -4
  12. package/dist/extensions/agent-browser/lib/execution-cwd.js +0 -16
  13. package/dist/extensions/agent-browser/lib/input-modes/params.js +2 -3
  14. package/dist/extensions/agent-browser/lib/input-modes/script.js +28 -6
  15. package/dist/extensions/agent-browser/lib/input-modes/types.js +2 -2
  16. package/dist/extensions/agent-browser/lib/managed-session-restore.js +1 -4
  17. package/dist/extensions/agent-browser/lib/native-output.js +46 -0
  18. package/dist/extensions/agent-browser/lib/orchestration/batch-stdin.js +0 -9
  19. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-merge.js +22 -0
  20. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +1 -1
  21. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +2 -2
  22. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +1 -1
  23. package/dist/extensions/agent-browser/lib/orchestration/execution-queue.js +86 -0
  24. package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +31 -17
  25. package/dist/extensions/agent-browser/lib/process.js +0 -3
  26. package/dist/extensions/agent-browser/lib/prompt-policy.js +16 -12
  27. package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +2 -2
  28. package/dist/extensions/agent-browser/lib/results/network-routes.js +1 -1
  29. package/dist/extensions/agent-browser/lib/results/network.js +1 -1
  30. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +1 -1
  31. package/dist/extensions/agent-browser/lib/results/presentation/content.js +4 -0
  32. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +1 -1
  33. package/dist/extensions/agent-browser/lib/results/presentation/large-output.js +15 -8
  34. package/dist/extensions/agent-browser/lib/results/presentation/semantic-action.js +2 -2
  35. package/dist/extensions/agent-browser/lib/results/selector-recovery.js +1 -1
  36. package/dist/extensions/agent-browser/lib/runtime.js +4 -2
  37. package/dist/extensions/agent-browser/lib/session-page-state.js +1 -1
  38. package/dist/extensions/agent-browser/lib/tool-surface.js +39 -11
  39. package/dist/extensions/agent-browser/lib/web-search.js +100 -79
  40. package/docs/ARCHITECTURE.md +17 -19
  41. package/docs/COMMAND_REFERENCE.md +6 -2
  42. package/docs/RELEASE.md +26 -20
  43. package/docs/REQUIREMENTS.md +5 -5
  44. package/docs/SESSION_STORAGE.md +4 -4
  45. package/docs/SUPPORT_MATRIX.md +15 -13
  46. package/docs/TOOL_CONTRACT.md +12 -8
  47. package/package.json +6 -6
  48. package/scripts/doctor.mjs +12 -4
@@ -38,7 +38,7 @@ export const GLOBAL_VALUE_FLAGS = [
38
38
  "--idle-timeout",
39
39
  "--input-mode",
40
40
  ];
41
- export const COMMAND_VALUE_FLAGS = [
41
+ const COMMAND_VALUE_FLAGS = [
42
42
  "--allowed-origins",
43
43
  "--baseline",
44
44
  "--body",
@@ -79,7 +79,7 @@ export const COMMAND_VALUE_FLAGS = [
79
79
  "--password",
80
80
  "--wait-until",
81
81
  ];
82
- export const OPTIONAL_GLOBAL_VALUE_FLAGS = new Set(["--restore"]);
82
+ const OPTIONAL_GLOBAL_VALUE_FLAGS = new Set(["--restore"]);
83
83
  export const VALUE_FLAGS = new Set([...GLOBAL_VALUE_FLAGS, ...COMMAND_VALUE_FLAGS]);
84
84
  export const PREVALIDATED_VALUE_FLAGS = new Set(GLOBAL_VALUE_FLAGS);
85
85
  export const GLOBAL_VALUE_FLAGS_ALLOWING_DASH_VALUE = new Set(["--args", "--session"]);
@@ -246,7 +246,7 @@ export function extractRequestedRestoreKey(args, sessionName, envValue) {
246
246
  }
247
247
  return restoreKey;
248
248
  }
249
- export function getFlagName(token) {
249
+ function getFlagName(token) {
250
250
  return token.split("=", 1)[0] ?? token;
251
251
  }
252
252
  export function isNonFlagToken(token) {
@@ -241,14 +241,7 @@ export async function readBrowserEntries(manager, physical = false) {
241
241
  const header = metadata.find(entry => entry.value.type === "session");
242
242
  if (header?.value.id !== (manager.getHeader()?.id ?? manager.getSessionId()))
243
243
  throw new Error("Pi session journal identity does not match the active session.");
244
- const publicManager = manager;
245
244
  const selected = physical ? metadata.filter(entry => entry.value.type !== "session") : branchEntries(metadata.filter(entry => entry.value.type !== "session"), leaf);
246
- if (publicManager.iterateEntryMetadata) {
247
- const native = new Map([...publicManager.iterateEntryMetadata(physical ? {} : { branchFrom: leaf })].map(entry => [entry.id, entry.parentId]));
248
- for (const entry of selected)
249
- if (typeof entry.value.id !== "string" || native.get(entry.value.id) !== entry.value.parentId)
250
- throw new Error("Published browser journal ancestry differs from the native selected boundary.");
251
- }
252
245
  const projected = [];
253
246
  const definitions = new Map();
254
247
  for (const entry of selected) {
@@ -285,9 +278,7 @@ export async function readBrowserEntries(manager, physical = false) {
285
278
  throw new Error(`Browser snapshot ${id} has no ancestral definition; inspect and take a fresh snapshot.`);
286
279
  // ponytail: a requested individual snapshot must fit its consumer's memory. Metadata
287
280
  // scans skip unlimited unrelated values; upgrade with native per-value paging if needed.
288
- const native = publicManager.getEntryMetadata && typeof entry.value.id === "string" ? manager.getEntry(entry.value.id) : undefined;
289
- const body = native && isRecord(native) && isRecord(native.data) ? { data: native.data }
290
- : await projectJson(readRange(file, entry), [["data", "snapshot"]], Infinity);
281
+ const body = await projectJson(readRange(file, entry), [["data", "snapshot"]], Infinity);
291
282
  const destination = projected.find(candidate => isRecord(candidate) && candidate.id === entry.value.id);
292
283
  if (!isRecord(destination) || !isRecord(destination.data) || !isRecord(body?.data))
293
284
  throw new Error("Winning browser snapshot could not be read.");
@@ -342,13 +333,22 @@ export function hasPublishedBrowserJournal(manager) {
342
333
  /** Capture before waiting: native selection precedes sequential session_tree handlers. */
343
334
  export function captureBrowserBranch(manager, isGenerationCurrent) {
344
335
  const sessionId = manager.getSessionId();
345
- const branch = { sessionId, anchorId: manager.getLeafId(), isCurrent() {
336
+ let anchorId = manager.getLeafId();
337
+ let validatedAnchor;
338
+ let validatedLeaf;
339
+ const branch = { sessionId, anchorId, isCurrent() {
346
340
  if (!isGenerationCurrent() || manager.getSessionId() !== sessionId)
347
341
  return false;
342
+ if (branch.anchorId !== anchorId) {
343
+ validatedAnchor = validatedLeaf = undefined;
344
+ anchorId = branch.anchorId;
345
+ }
348
346
  let leaf = manager.getLeafId();
349
347
  // Empty selection is not a universal ancestor of other independent roots.
350
348
  let current = branch.anchorId === null && leaf === null;
351
349
  const seen = new Set();
350
+ let selected;
351
+ let anchor;
352
352
  while (leaf !== null) {
353
353
  if (typeof leaf !== "string" || seen.has(leaf))
354
354
  throw new Error("Selected Pi journal ancestry is invalid or cyclic.");
@@ -356,10 +356,31 @@ export function captureBrowserBranch(manager, isGenerationCurrent) {
356
356
  const entry = manager.getEntry(leaf);
357
357
  if (!entry || entry.id !== leaf || entry.parentId !== null && typeof entry.parentId !== "string")
358
358
  throw new Error("Selected Pi journal ancestry is incomplete.");
359
- if (leaf === branch.anchorId)
359
+ selected ??= { entry, parentId: entry.parentId };
360
+ const cached = leaf === branch.anchorId ? validatedAnchor : leaf === validatedLeaf?.entry.id ? validatedLeaf : undefined;
361
+ if (cached) {
362
+ // Keep the native indexed lookup: it also refreshes fork journal identity.
363
+ if (cached.entry === entry && cached.parentId === entry.parentId) {
364
+ current = true;
365
+ break;
366
+ }
367
+ validatedAnchor = validatedLeaf = undefined;
368
+ }
369
+ if (leaf === branch.anchorId) {
360
370
  current = true;
371
+ anchor = { entry, parentId: entry.parentId };
372
+ }
361
373
  leaf = entry.parentId;
362
374
  }
375
+ // Native entries/parents are append-only. Cache only after validating through
376
+ // the root (or an already validated endpoint), never merely on leaf === anchor.
377
+ // ponytail: behind-host mutation of an old ancestor without native reseed or
378
+ // generation change is outside this contract; durable file proofs
379
+ // remain in replay/append. Upgrade if Pi exposes a separate ancestry revision.
380
+ if (current && selected) {
381
+ validatedAnchor = anchor ?? validatedAnchor;
382
+ validatedLeaf = selected;
383
+ }
363
384
  return current;
364
385
  } };
365
386
  return branch;
@@ -4,7 +4,7 @@ import { buildPageTransitionRefSnapshotInvalidation, normalizeSessionTabTarget,
4
4
  import { getSessionArtifactManifestEntryKey, isPendingRecordingArtifact, isSessionArtifactManifest } from "./results/artifact-manifest.js";
5
5
  import { extractAgentBrowserLifecycle } from "./results/presentation/common.js";
6
6
  export const BROWSER_TRANSITION_ENTRY = "agent-browser-transition";
7
- export const BROWSER_EVENT_VERSION = 1;
7
+ const BROWSER_EVENT_VERSION = 1;
8
8
  export const BROWSER_RESULT_TOOLS = new Set([
9
9
  "agent_browser", "agent_browser_code", "agent_browser_action", "agent_browser_qa", "agent_browser_electron",
10
10
  "agent_browser_source", "agent_browser_network_source",
@@ -295,9 +295,6 @@ function getCommandCapability(command) {
295
295
  function hasCommandCapability(command, capability) {
296
296
  return getCommandCapability(command)?.[capability] === true;
297
297
  }
298
- export function normalizeCommandName(command) {
299
- return getCommandCapability(command)?.command ?? command;
300
- }
301
298
  export function isCloseCommand(command) {
302
299
  return hasCommandCapability(command, "closesSession");
303
300
  }
@@ -229,19 +229,6 @@ function validateBrowserDefaultProfile(value, path, errors) {
229
229
  }
230
230
  return { name, policy: /** @type {BrowserDefaultProfilePolicy} */ (policy) };
231
231
  }
232
- /** @param {string} rawValue */
233
- export function isPlaintextCredentialValue(rawValue) {
234
- const trimmed = rawValue.trim();
235
- return Boolean(trimmed) && !trimmed.startsWith("!") && !trimmed.startsWith("$");
236
- }
237
- /**
238
- * @param {string} rawValue
239
- * @param {WebSearchProvider} provider
240
- */
241
- export function isProjectSafeCredentialValueForProvider(rawValue, provider) {
242
- void provider;
243
- return rawValue.trim().length > 0;
244
- }
245
232
  /**
246
233
  * @param {unknown} value
247
234
  * @param {string} path
@@ -1,53 +1,8 @@
1
1
  import { exec as execCallback } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
3
2
  import { promisify } from "node:util";
4
- import { SECRET_COMMAND_TIMEOUT_MS, buildAgentBrowserConfigState, getAgentBrowserConfigPaths, getWebSearchCredentialSource, getWebSearchProviderOrder, loadAgentBrowserConfigStateSync, mergeAgentBrowserConfig, parseAgentBrowserConfigLayer, resolveEnvInterpolations, } from "./config-policy.js";
5
- export { AGENT_BROWSER_CONFIG_ENV, BRAVE_API_KEY_ENV, CONFIG_RELATIVE_PATH, DEFAULT_WEB_SEARCH_PROVIDER, EXA_API_KEY_ENV, EXA_SEARCH_TYPES, GLOBAL_CONFIG_RELATIVE_PATH, SECRET_COMMAND_TIMEOUT_MS, WEB_SEARCH_PROVIDER_CONFIG_KEYS, WEB_SEARCH_PROVIDER_DESCRIPTORS, WEB_SEARCH_PROVIDER_ENV_VARS, WEB_SEARCH_PROVIDERS, buildAgentBrowserConfigState, buildWebSearchCredentialSources, canRegisterWebSearchTool, classifyCredentialSource, formatBrowserExecutableStatus, formatBrowserProfileStatus, getAgentBrowserConfigPaths, getCredentialSourceSummary, getGlobalAgentBrowserConfigPath, getProjectAgentBrowserConfigPath, getWebSearchCredentialSource, getWebSearchProviderConfigKey, getWebSearchProviderDescriptor, getWebSearchProviderEnvVar, getWebSearchProviderLabel, getWebSearchProviderOrder, hasPotentialCredentialSource, isPlaintextCredentialValue, isProjectSafeCredentialValueForProvider, isWebSearchProvider, loadAgentBrowserConfigStateSync, mergeAgentBrowserConfig, parseAgentBrowserConfigLayer, resolveEnvInterpolations, summarizeConfigFiles, validateAgentBrowserConfig, validateWebSearchProvider, } from "./config-policy.js";
3
+ import { SECRET_COMMAND_TIMEOUT_MS, getWebSearchCredentialSource, getWebSearchProviderOrder, loadAgentBrowserConfigStateSync, resolveEnvInterpolations, } from "./config-policy.js";
4
+ export { AGENT_BROWSER_CONFIG_ENV, BRAVE_API_KEY_ENV, CONFIG_RELATIVE_PATH, DEFAULT_WEB_SEARCH_PROVIDER, EXA_API_KEY_ENV, EXA_SEARCH_TYPES, GLOBAL_CONFIG_RELATIVE_PATH, SECRET_COMMAND_TIMEOUT_MS, WEB_SEARCH_PROVIDER_CONFIG_KEYS, WEB_SEARCH_PROVIDER_DESCRIPTORS, WEB_SEARCH_PROVIDER_ENV_VARS, WEB_SEARCH_PROVIDERS, buildAgentBrowserConfigState, buildWebSearchCredentialSources, canRegisterWebSearchTool, classifyCredentialSource, formatBrowserExecutableStatus, formatBrowserProfileStatus, getAgentBrowserConfigPaths, getCredentialSourceSummary, getGlobalAgentBrowserConfigPath, getProjectAgentBrowserConfigPath, getWebSearchCredentialSource, getWebSearchProviderConfigKey, getWebSearchProviderDescriptor, getWebSearchProviderEnvVar, getWebSearchProviderLabel, getWebSearchProviderOrder, hasPotentialCredentialSource, isWebSearchProvider, loadAgentBrowserConfigStateSync, mergeAgentBrowserConfig, parseAgentBrowserConfigLayer, resolveEnvInterpolations, summarizeConfigFiles, validateAgentBrowserConfig, validateWebSearchProvider, } from "./config-policy.js";
6
5
  const exec = promisify(execCallback);
7
- async function readConfigLayer(path, scope, errors, warnings) {
8
- let raw;
9
- try {
10
- raw = await readFile(path, "utf8");
11
- }
12
- catch (error) {
13
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
14
- return undefined;
15
- }
16
- errors.push(`Could not read ${scope} config ${path}: ${error instanceof Error ? error.message : String(error)}`);
17
- return undefined;
18
- }
19
- return parseAgentBrowserConfigLayer(raw, path, scope, errors, warnings);
20
- }
21
- export async function loadAgentBrowserConfig(options = {}) {
22
- const env = options.env ?? process.env;
23
- const paths = getAgentBrowserConfigPaths({ cwd: options.cwd, env });
24
- const includeProjectConfig = options.includeProjectConfig !== false;
25
- const errors = [];
26
- const warnings = [];
27
- const layerCandidates = [
28
- { path: paths.global, scope: "global" },
29
- ...(includeProjectConfig ? [{ path: paths.project, scope: "project" }] : []),
30
- ...(paths.override ? [{ path: paths.override, scope: "override" }] : []),
31
- ];
32
- const layers = [];
33
- let mergedConfig = {};
34
- for (const candidate of layerCandidates) {
35
- const layer = await readConfigLayer(candidate.path, candidate.scope, errors, warnings);
36
- if (!layer)
37
- continue;
38
- layers.push(layer);
39
- mergedConfig = mergeAgentBrowserConfig(mergedConfig, layer.config);
40
- }
41
- return buildAgentBrowserConfigState({
42
- env,
43
- errors,
44
- layers,
45
- mergedConfig,
46
- paths,
47
- projectConfigIncluded: includeProjectConfig,
48
- warnings,
49
- });
50
- }
51
6
  export function loadAgentBrowserConfigSync(options = {}) {
52
7
  return loadAgentBrowserConfigStateSync(options);
53
8
  }
@@ -70,7 +25,7 @@ async function resolveCommandCredential(rawValue, signal) {
70
25
  throw new Error("Credential command failed without exposing command output. Check pi-agent-browser-config web-search status and the configured secret manager command.");
71
26
  }
72
27
  }
73
- export async function resolveCredentialSource(source, options = {}) {
28
+ async function resolveCredentialSource(source, options = {}) {
74
29
  if (!source)
75
30
  return undefined;
76
31
  let value;
@@ -6,8 +6,8 @@ import { fetchCdpJson, parseCdpTargets, parseCdpVersion, } from "./cdp.js";
6
6
  import { discoverElectronApps, inspectElectronAppPath, inspectElectronExecutablePath, } from "./discovery.js";
7
7
  import { createSecureTempDirectory, preserveSecureTempDirectory } from "../temp.js";
8
8
  export const ELECTRON_LAUNCH_RECORD_VERSION = 1;
9
- export const ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS = 15_000;
10
- export const ELECTRON_LAUNCH_MAX_TIMEOUT_MS = 120_000;
9
+ const ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS = 15_000;
10
+ const ELECTRON_LAUNCH_MAX_TIMEOUT_MS = 120_000;
11
11
  const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
12
12
  export const ELECTRON_PROFILE_DIR_PREFIX = "electron-profile-";
13
13
  const ELECTRON_DEFAULT_APP_ARGS = ["--disable-extensions", "--no-first-run", "--no-default-browser-check"];
@@ -46,7 +46,7 @@ function policyEntryMatchesApp(entry, app) {
46
46
  return false;
47
47
  return appIdentifiers(app).some((identifier) => identifier.toLowerCase().includes(normalizedEntry));
48
48
  }
49
- export function evaluateElectronLaunchPolicy(options) {
49
+ function evaluateElectronLaunchPolicy(options) {
50
50
  const denyEntry = options.deny?.find((entry) => policyEntryMatchesApp(entry, options.target));
51
51
  if (denyEntry) {
52
52
  return {
@@ -66,7 +66,7 @@ export function evaluateElectronLaunchPolicy(options) {
66
66
  }
67
67
  return undefined;
68
68
  }
69
- export async function resolveElectronLaunchTarget(options) {
69
+ async function resolveElectronLaunchTarget(options) {
70
70
  if (options.appPath)
71
71
  return inspectElectronAppPath(options.appPath);
72
72
  if (options.executablePath)
@@ -1,8 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join } from "node:path";
3
3
  import { isRecord } from "./parsing.js";
4
- import { getBrowserResultMessage } from "./browser-transcript.js";
5
- import { getAgentBrowserSessionIdentityKey } from "./argv-grammar.js";
6
4
  function isDirectoryOwner(source) {
7
5
  if (/^(?:npm:pi-change-working-dir|git:github\.com\/fitchmultz\/pi-change-working-dir(?:\.git)?)(?:@.+)?$/.test(source.source))
8
6
  return true;
@@ -50,17 +48,3 @@ export function getBrowserCwdError(cwd) {
50
48
  }
51
49
  return `Browser launch directory is unavailable: ${cwd}. Restore that directory, or explicitly use sessionMode: "fresh" without --session, or --config from the selected execution directory. The existing browser was left untouched.`;
52
50
  }
53
- export function restoreManagedSessionCwd(branch, sessionName, namespace, fallback) {
54
- const key = getAgentBrowserSessionIdentityKey(sessionName, namespace);
55
- for (const entry of [...branch].reverse()) {
56
- const message = getBrowserResultMessage(entry);
57
- if (!message)
58
- continue;
59
- const details = message.details;
60
- if (!isRecord(details) || typeof details.sessionName !== "string" || typeof details.managedSessionCwd !== "string" || !isAbsolute(details.managedSessionCwd))
61
- continue;
62
- if (getAgentBrowserSessionIdentityKey(details.sessionName, typeof details.namespace === "string" ? details.namespace : undefined) === key)
63
- return details.managedSessionCwd;
64
- }
65
- return fallback;
66
- }
@@ -1,9 +1,8 @@
1
1
  import { JsonSchema } from "../json-schema.js";
2
2
  import { StringEnum as localStringEnum } from "../string-enum-schema.js";
3
3
  import { ELECTRON_DISCOVERY_DEFAULT_MAX_RESULTS, ELECTRON_DISCOVERY_MAX_RESULTS } from "../electron/discovery.js";
4
- import { AGENT_BROWSER_ELECTRON_HANDOFFS, AGENT_BROWSER_ELECTRON_TARGET_TYPES, AGENT_BROWSER_QA_LOAD_STATES, AGENT_BROWSER_SEMANTIC_ACTIONS, AGENT_BROWSER_SEMANTIC_LOCATORS, DEFAULT_SESSION_MODE, SOURCE_LOOKUP_MAX_WORKSPACE_FILES, } from "./types.js";
5
- export const AGENT_BROWSER_ADVANCED_TOOLS = ["action", "qa", "electron", "source", "network"];
6
- export const AGENT_BROWSER_CODE_MAX_TIMEOUT_MS = 300_000;
4
+ import { AGENT_BROWSER_CODE_MAX_TIMEOUT_MS, AGENT_BROWSER_ELECTRON_HANDOFFS, AGENT_BROWSER_ELECTRON_TARGET_TYPES, AGENT_BROWSER_QA_LOAD_STATES, AGENT_BROWSER_SEMANTIC_ACTIONS, AGENT_BROWSER_SEMANTIC_LOCATORS, DEFAULT_SESSION_MODE, SOURCE_LOOKUP_MAX_WORKSPACE_FILES, } from "./types.js";
5
+ const AGENT_BROWSER_ADVANCED_TOOLS = ["action", "qa", "electron", "source", "network"];
7
6
  function outputProperties(Type) {
8
7
  return {
9
8
  outputPath: Type.Optional(Type.String({ description: "Result-data path, separate from browser artifact paths.", minLength: 1 })),
@@ -2,20 +2,34 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import which from "which";
5
6
  import { isRecord } from "../parsing.js";
6
7
  import { extractExplicitNamespace, extractExplicitSessionName, scanUpstreamGlobalFlagOccurrences } from "../argv-grammar.js";
7
8
  import { getUpstreamEffectiveBatchSteps } from "../orchestration/batch-stdin.js";
8
9
  import { extractUpstreamCommandTokens } from "../argv-descriptor.js";
9
10
  import { isCloseAllCommand } from "../command-taxonomy.js";
10
11
  import { redactSensitiveText, validateToolArgs } from "../runtime.js";
12
+ import { AGENT_BROWSER_CODE_MAX_TIMEOUT_MS } from "./types.js";
11
13
  export const AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES = 64 * 1_024;
12
14
  export const AGENT_BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS = 120_000;
13
15
  export const AGENT_BROWSER_SCRIPT_NAMESPACE = "";
14
- export const AGENT_BROWSER_SCRIPT_MAX_TIMEOUT_MS = 300_000;
15
16
  export const AGENT_BROWSER_SCRIPT_MAX_CALLS = 25;
16
17
  export const AGENT_BROWSER_SCRIPT_FINAL_OUTPUT_MAX_BYTES = 64 * 1_024;
17
18
  export const AGENT_BROWSER_SCRIPT_IPC_MESSAGE_MAX_BYTES = 1 * 1_024 * 1_024;
18
- export const AGENT_BROWSER_SCRIPT_IPC_CUMULATIVE_MAX_BYTES = 8 * 1_024 * 1_024;
19
+ const AGENT_BROWSER_SCRIPT_IPC_CUMULATIVE_MAX_BYTES = 8 * 1_024 * 1_024;
20
+ /**
21
+ * Resolve the Node runtime that hosts the permissioned code child. A Bun-compiled Pi binary
22
+ * points `process.execPath` at the pi executable, which rejects Node-only flags like
23
+ * `--permission`, so those hosts must spawn a real `node` from PATH instead.
24
+ */
25
+ export function resolveScriptChildNodePath(options) {
26
+ if (options.runtime.bun === undefined)
27
+ return options.execPath;
28
+ const nodePath = options.whichNode();
29
+ if (nodePath === null)
30
+ throw new Error("agent_browser_code requires a `node` runtime on PATH when pi runs on a Bun binary.");
31
+ return nodePath;
32
+ }
19
33
  function findPackageRoot(startDir) {
20
34
  let currentDir = startDir;
21
35
  for (;;) {
@@ -34,7 +48,7 @@ function resolveScriptWorkerPath() {
34
48
  return workerPath;
35
49
  }
36
50
  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}$/;
37
- export function validateAgentBrowserScriptSource(input) {
51
+ function validateAgentBrowserScriptSource(input) {
38
52
  if (typeof input !== "string")
39
53
  return { error: "script must be a string." };
40
54
  const bytes = Buffer.byteLength(input, "utf8");
@@ -175,8 +189,8 @@ export async function runAgentBrowserScript(options) {
175
189
  return buildFailedRun({ callCount: 0, emitCount: 0, error: compiled.error, failureCategory: "validation-error", rejectedCallCount: 0, steps: [] });
176
190
  }
177
191
  const timeoutMs = options.timeoutMs ?? AGENT_BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS;
178
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > AGENT_BROWSER_SCRIPT_MAX_TIMEOUT_MS) {
179
- return buildFailedRun({ callCount: 0, emitCount: 0, error: `script timeoutMs must be between 1 and ${AGENT_BROWSER_SCRIPT_MAX_TIMEOUT_MS}.`, failureCategory: "validation-error", rejectedCallCount: 0, steps: [] });
192
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > AGENT_BROWSER_CODE_MAX_TIMEOUT_MS) {
193
+ return buildFailedRun({ callCount: 0, emitCount: 0, error: `script timeoutMs must be between 1 and ${AGENT_BROWSER_CODE_MAX_TIMEOUT_MS}.`, failureCategory: "validation-error", rejectedCallCount: 0, steps: [] });
180
194
  }
181
195
  if (options.signal?.aborted) {
182
196
  return buildFailedRun({ aborted: true, callCount: 0, emitCount: 0, error: "Script execution was aborted.", failureCategory: "aborted", rejectedCallCount: 0, steps: [] });
@@ -189,7 +203,15 @@ export async function runAgentBrowserScript(options) {
189
203
  const message = error instanceof Error ? error.message : "Compiled script worker is missing.";
190
204
  return buildFailedRun({ callCount: 0, emitCount: 0, error: message, failureCategory: "missing-binary", rejectedCallCount: 0, steps: [] });
191
205
  }
192
- const child = spawn(process.execPath, [
206
+ let childNodePath;
207
+ try {
208
+ childNodePath = resolveScriptChildNodePath({ runtime: { bun: process.versions.bun }, execPath: process.execPath, whichNode: () => which.sync("node", { nothrow: true }) });
209
+ }
210
+ catch (error) {
211
+ const message = error instanceof Error ? error.message : "No Node runtime is available for the code child.";
212
+ return buildFailedRun({ callCount: 0, emitCount: 0, error: message, failureCategory: "missing-binary", rejectedCallCount: 0, steps: [] });
213
+ }
214
+ const child = spawn(childNodePath, [
193
215
  "--permission",
194
216
  "--max-old-space-size=64",
195
217
  workerPath,
@@ -1,8 +1,8 @@
1
1
  export const DEFAULT_SESSION_MODE = "auto";
2
+ export const AGENT_BROWSER_CODE_MAX_TIMEOUT_MS = 300_000;
2
3
  export const AGENT_BROWSER_SEMANTIC_ACTIONS = ["check", "click", "fill", "select"];
3
4
  export const AGENT_BROWSER_SEMANTIC_LOCATORS = ["alt", "label", "placeholder", "role", "testid", "text", "title"];
4
- export const AGENT_BROWSER_JOB_TYPE_DELAYED_TEXT_MAX_CHARACTERS = 200;
5
- export const AGENT_BROWSER_JOB_STEP_ACTIONS = ["open", "click", "fill", "type", "select", "wait", "assertText", "assertUrl", "waitForDownload", "screenshot", "snapshot"];
5
+ const AGENT_BROWSER_JOB_STEP_ACTIONS = ["open", "click", "fill", "type", "select", "wait", "assertText", "assertUrl", "waitForDownload", "screenshot", "snapshot"];
6
6
  export const AGENT_BROWSER_QA_LOAD_STATES = ["domcontentloaded", "load", "networkidle"];
7
7
  export const AGENT_BROWSER_ELECTRON_ACTIONS = ["list", "launch", "status", "cleanup", "probe"];
8
8
  export const AGENT_BROWSER_ELECTRON_HANDOFFS = ["connect", "tabs", "snapshot"];
@@ -82,9 +82,6 @@ const ownedManagedSessionStorage = new AsyncLocalStorage();
82
82
  export async function withOwnedManagedSessionContext(context, run) {
83
83
  return await ownedManagedSessionStorage.run(context, run);
84
84
  }
85
- export function getOwnedManagedSessionRestoreKey() {
86
- return ownedManagedSessionStorage.getStore()?.restoreKey;
87
- }
88
85
  export function resolveOwnedManagedSessionContext(options) {
89
86
  const namespace = canonicalizeAgentBrowserNamespace(options.namespace);
90
87
  const currentNamespace = canonicalizeAgentBrowserNamespace(options.currentManagedSessionNamespace);
@@ -115,7 +112,7 @@ function hasExplicitConfigArg(args) {
115
112
  function closesBrowserSession(args) {
116
113
  return ["close", "exit", "quit"].includes(parseCommandInfo(args).command ?? "");
117
114
  }
118
- export function agentBrowserExplicitConfigIsPresent(parentEnv = getAgentBrowserProcessEnvironment(), args = []) {
115
+ function agentBrowserExplicitConfigIsPresent(parentEnv = getAgentBrowserProcessEnvironment(), args = []) {
119
116
  return hasExplicitConfigArg(args) || hasUpstreamEnvValue(parentEnv, AGENT_BROWSER_CONFIG_ENV);
120
117
  }
121
118
  /** Caller-selected upstream config disables the wrapper's automatic restore injection without blocking that config. */
@@ -0,0 +1,46 @@
1
+ import { JsonSchema } from "./json-schema.js";
2
+ import { isRecord } from "./parsing.js";
3
+ import { finalizeAgentBrowserFailure } from "./pi-tool-rendering.js";
4
+ import { isPlainTextInspectionArgs } from "./runtime.js";
5
+ import { projectAgentBrowserObservation, OBSERVATION_INLINE_MAX_CHARS } from "./results/presentation/content.js";
6
+ export const AGENT_BROWSER_NAMESPACE = {
7
+ name: "browser",
8
+ description: "Browser commands, bounded browser code, advanced UI checks, Electron, and optional web search.",
9
+ instructions: "Use agent_browser for native commands and batch --bail for fixed sequences. Use agent_browser_code for a fresh bounded JS cell with a persistent browser. Enable advanced direct tools with agent_browser_tools before calling them. Check success and nextActions; structured imageObservations are capture metadata, not inline images. Optional web search is available only when configured. Full browser instructions remain in the agent_browser prompt section.",
10
+ };
11
+ export const AGENT_BROWSER_OUTPUT_SCHEMA = JsonSchema.Object({
12
+ success: JsonSchema.Boolean(),
13
+ resultCategory: JsonSchema.Union([JsonSchema.Literal("success"), JsonSchema.Literal("failure")]),
14
+ data: JsonSchema.Optional(JsonSchema.Unsafe({})),
15
+ error: JsonSchema.Optional(JsonSchema.Unsafe({})),
16
+ summary: JsonSchema.Optional(JsonSchema.String()),
17
+ nextActions: JsonSchema.Optional(JsonSchema.Array(JsonSchema.Object({}))),
18
+ }, { additionalProperties: true });
19
+ /** Final public boundary, after execution, persistence, and any requested output export. */
20
+ export async function finalizeAgentBrowserNativeResult(result, input) {
21
+ const finalized = finalizeAgentBrowserFailure(result, input);
22
+ // Native help/version stays plain text; the entrypoint intentionally skips observation rendering for it.
23
+ if (isRecord(input) && Array.isArray(input.args) && isPlainTextInspectionArgs(input.args))
24
+ return finalized;
25
+ const succeeded = finalized.isError !== true;
26
+ // The normal entrypoint supplies the exact bounded projection, including its complete spill.
27
+ if (isRecord(result.structuredContent) && result.structuredContent.success === succeeded)
28
+ return finalized;
29
+ const details = isRecord(result.details) ? result.details : {};
30
+ const observation = projectAgentBrowserObservation(details, succeeded);
31
+ if (JSON.stringify(observation).length > OBSERVATION_INLINE_MAX_CHARS) {
32
+ const { renderAgentBrowserObservation } = await import("./results/presentation/large-output.js");
33
+ const rendered = await renderAgentBrowserObservation({
34
+ content: finalized.content, details, succeeded,
35
+ json: isRecord(input) && ("code" in input || Array.isArray(input.args) && input.args.includes("--json")),
36
+ // The caller's exact visible output stays inline (for example formatted search results); only the structured field is bounded.
37
+ preserveContent: true,
38
+ });
39
+ const { artifactManifest, ...renderedResult } = rendered;
40
+ return finalizeAgentBrowserFailure({
41
+ ...finalized, ...renderedResult,
42
+ details: artifactManifest ? { ...details, artifactManifest } : details,
43
+ }, input);
44
+ }
45
+ return { ...finalized, structuredContent: JSON.parse(JSON.stringify(observation)) };
46
+ }
@@ -139,12 +139,3 @@ export function getUpstreamEffectiveBatchSteps(commandTokens, stdin) {
139
139
  return argumentSteps;
140
140
  return parseUserBatchStdin(stdin).steps ?? [];
141
141
  }
142
- export function parseValidBatchStepEntries(stdin) {
143
- const parsed = parseBatchStdinJsonArray(stdin);
144
- if (parsed.error || parsed.steps === undefined)
145
- return [];
146
- return parsed.steps.flatMap((step, index) => {
147
- const validated = validateUserBatchStep(step, index);
148
- return validated.ok ? [{ index, step: validated.step }] : [];
149
- });
150
- }
@@ -0,0 +1,22 @@
1
+ import { getSessionArtifactManifestEntryKey, isPendingRecordingCommand, mergeSessionArtifactManifest } from "../../results/artifact-manifest.js";
2
+ export function mergeBrowserRunArtifactManifest(current, initial, updated) {
3
+ if (!updated || updated === initial)
4
+ return current;
5
+ if (current === initial)
6
+ return updated;
7
+ const initialEntries = new Map((initial?.entries ?? []).map((entry) => [getSessionArtifactManifestEntryKey(entry), entry]));
8
+ const changedEntries = updated.entries
9
+ .map((entry, index) => ({ entry, index }))
10
+ .filter(({ entry }) => initialEntries.get(getSessionArtifactManifestEntryKey(entry)) !== entry)
11
+ .sort((left, right) => left.entry.createdAtMs - right.entry.createdAtMs
12
+ || Number(isPendingRecordingCommand(left.entry.command, left.entry.subcommand, left.entry.kind)) - Number(isPendingRecordingCommand(right.entry.command, right.entry.subcommand, right.entry.kind))
13
+ || left.index - right.index)
14
+ .map(({ entry }) => entry);
15
+ return changedEntries.length === 0
16
+ ? current
17
+ : mergeSessionArtifactManifest({
18
+ base: current,
19
+ entries: changedEntries,
20
+ nowMs: Math.max(Date.now(), (current?.updatedAtMs ?? 0) + 1, updated.updatedAtMs),
21
+ });
22
+ }
@@ -566,7 +566,7 @@ export function formatQaAttachedTargetText(target) {
566
566
  return undefined;
567
567
  return ["QA attached target:", target.sessionName, target.title, target.url].filter((part) => typeof part === "string" && part.length > 0).join(" — ");
568
568
  }
569
- export function buildQaAttachedRecoveryNextActions(sessionName) {
569
+ function buildQaAttachedRecoveryNextActions(sessionName) {
570
570
  const sessionArgs = (args) => withOptionalSessionArgs(sessionName, args);
571
571
  return [
572
572
  buildNextToolAction({
@@ -26,13 +26,13 @@ export function buildMissingBinaryMessage() {
26
26
  ].join("\n");
27
27
  }
28
28
  const SEMANTIC_ACTION_CANDIDATE_ACTION_IDS = new Set(["try-button-name-candidate", "try-link-name-candidate"]);
29
- export function formatSemanticActionCandidateText(actions) {
29
+ function formatSemanticActionCandidateText(actions) {
30
30
  const candidateActions = actions.filter((action) => SEMANTIC_ACTION_CANDIDATE_ACTION_IDS.has(action.id) && action.params?.args);
31
31
  if (candidateActions.length === 0)
32
32
  return undefined;
33
33
  return ["Agent-browser candidate fallbacks:", ...candidateActions.map((action) => `- ${action.id}: agent_browser ${JSON.stringify({ args: action.params?.args })} — ${action.reason}`)].join("\n");
34
34
  }
35
- export function buildSemanticActionCandidateActions(compiled) {
35
+ function buildSemanticActionCandidateActions(compiled) {
36
36
  const commandIndex = getCompiledSemanticActionCommandIndex(compiled);
37
37
  if (commandIndex < 0 || compiled.args[commandIndex] !== "find")
38
38
  return [];
@@ -333,7 +333,7 @@ function resolveSemanticActionVisibleRefArgsFromSnapshot(compiled, snapshotData)
333
333
  return undefined;
334
334
  return { args: [...getCompiledSemanticActionSessionPrefix(compiled), ...resolution.args], snapshot: resolution.snapshot };
335
335
  }
336
- export async function resolveSemanticActionVisibleRefArgs(options) {
336
+ async function resolveSemanticActionVisibleRefArgs(options) {
337
337
  if (!options.compiled || !options.sessionName)
338
338
  return undefined;
339
339
  const snapshotData = await runSessionCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal });
@@ -0,0 +1,86 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "../argv-grammar.js";
3
+ // Serializes managed-session read/modify/write work so overlapping tool calls cannot promote stale state or close an in-use session.
4
+ export class AsyncExecutionQueue {
5
+ tail = Promise.resolve();
6
+ active = new AsyncLocalStorage();
7
+ isCurrent() { return this.active.getStore()?.active === true; }
8
+ run(work, signal, barrier = Promise.resolve()) {
9
+ if (this.isCurrent()) {
10
+ signal?.throwIfAborted();
11
+ return work();
12
+ }
13
+ const previous = this.tail;
14
+ let release;
15
+ this.tail = new Promise((resolve) => {
16
+ release = resolve;
17
+ });
18
+ let rejectWaiting;
19
+ const cancelled = new Promise((_resolve, reject) => { rejectWaiting = reject; });
20
+ const abortWaiting = () => rejectWaiting(signal?.reason);
21
+ signal?.addEventListener("abort", abortWaiting, { once: true });
22
+ if (signal?.aborted)
23
+ abortWaiting();
24
+ const execution = (async () => {
25
+ await Promise.all([previous, barrier]);
26
+ signal?.removeEventListener("abort", abortWaiting);
27
+ try {
28
+ signal?.throwIfAborted();
29
+ const scope = { active: true };
30
+ try {
31
+ return await this.active.run(scope, work);
32
+ }
33
+ finally {
34
+ scope.active = false;
35
+ }
36
+ }
37
+ finally {
38
+ release();
39
+ }
40
+ })();
41
+ return signal ? Promise.race([execution, cancelled]) : execution;
42
+ }
43
+ }
44
+ export class KeyedAsyncExecutionQueue {
45
+ barriers = new Map();
46
+ entries = new Map();
47
+ async run(key, namespace, work, signal) {
48
+ const entry = this.entries.get(key) ?? { queue: new AsyncExecutionQueue(), users: 0 };
49
+ if (entry.queue.isCurrent())
50
+ return work();
51
+ const barrier = this.barriers.get(getAgentBrowserSessionIdentityKey("", namespace)) ?? Promise.resolve();
52
+ entry.users += 1;
53
+ this.entries.set(key, entry);
54
+ try {
55
+ return await entry.queue.run(work, signal, barrier);
56
+ }
57
+ finally {
58
+ entry.users -= 1;
59
+ if (entry.users === 0 && this.entries.get(key) === entry)
60
+ this.entries.delete(key);
61
+ }
62
+ }
63
+ async runExclusive(namespace, work) {
64
+ const namespaceKey = getAgentBrowserSessionIdentityKey("", namespace);
65
+ const previous = this.barriers.get(namespaceKey) ?? Promise.resolve();
66
+ let release;
67
+ const blocked = new Promise((resolve) => {
68
+ release = resolve;
69
+ });
70
+ const barrier = previous.then(() => blocked);
71
+ this.barriers.set(namespaceKey, barrier);
72
+ const drains = [...this.entries]
73
+ .filter(([key]) => isAgentBrowserSessionIdentityKeyInNamespace(key, namespace))
74
+ .map(([, { queue }]) => queue.run(async () => undefined));
75
+ await previous;
76
+ await Promise.all(drains);
77
+ try {
78
+ return await work();
79
+ }
80
+ finally {
81
+ release();
82
+ if (this.barriers.get(namespaceKey) === barrier)
83
+ this.barriers.delete(namespaceKey);
84
+ }
85
+ }
86
+ }