pi-agent-browser-native 0.8.2 → 0.9.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 (37) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +24 -15
  3. package/dist/extensions/agent-browser/index.js +496 -343
  4. package/dist/extensions/agent-browser/lib/browser-journal.js +439 -0
  5. package/dist/extensions/agent-browser/lib/browser-session-conversion.js +381 -0
  6. package/dist/extensions/agent-browser/lib/browser-transcript.js +119 -19
  7. package/dist/extensions/agent-browser/lib/execution-cwd.js +0 -16
  8. package/dist/extensions/agent-browser/lib/input-modes/job.js +49 -2
  9. package/dist/extensions/agent-browser/lib/input-modes/params.js +32 -35
  10. package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +25 -5
  11. package/dist/extensions/agent-browser/lib/managed-session-restore.js +20 -5
  12. package/dist/extensions/agent-browser/lib/native-output.js +46 -0
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +97 -78
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +23 -2
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +52 -17
  16. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +1 -1
  17. package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +13 -8
  18. package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +30 -16
  19. package/dist/extensions/agent-browser/lib/playbook.js +9 -2
  20. package/dist/extensions/agent-browser/lib/prompt-policy.js +16 -26
  21. package/dist/extensions/agent-browser/lib/results/presentation/content.js +4 -0
  22. package/dist/extensions/agent-browser/lib/results/presentation/large-output.js +17 -9
  23. package/dist/extensions/agent-browser/lib/session-page-state.js +95 -157
  24. package/dist/extensions/agent-browser/lib/temp.js +14 -6
  25. package/dist/extensions/agent-browser/lib/tool-surface.js +50 -33
  26. package/dist/extensions/agent-browser/lib/web-search.js +85 -72
  27. package/docs/ARCHITECTURE.md +25 -25
  28. package/docs/COMMAND_REFERENCE.md +13 -9
  29. package/docs/RELEASE.md +25 -19
  30. package/docs/REQUIREMENTS.md +6 -6
  31. package/docs/SESSION_STORAGE.md +60 -0
  32. package/docs/SUPPORT_MATRIX.md +16 -11
  33. package/docs/TOOL_CONTRACT.md +26 -19
  34. package/package.json +9 -5
  35. package/scripts/build.mjs +4 -3
  36. package/scripts/convert-browser-session.mjs +22 -0
  37. package/scripts/doctor.mjs +2 -2
@@ -1,5 +1,6 @@
1
1
  import { isRecord } from "../parsing.js";
2
2
  import { summarizeNetworkFailures } from "../results/network.js";
3
+ import { truncateText } from "../results/text.js";
3
4
  import { getBatchResultItems, getCommandNameFromBatchItem } from "./shared.js";
4
5
  import { AGENT_BROWSER_QA_LOAD_STATES, } from "./types.js";
5
6
  function describeQaChecksRun(checks) {
@@ -61,15 +62,19 @@ export function buildQaCompactPassText(options) {
61
62
  return lines.join("\n");
62
63
  }
63
64
  export function buildQaCompactFailureText(options) {
64
- const lines = [options.qaPreset.summary];
65
+ const lines = [options.causalError
66
+ ? truncateText(options.causalError.replace(/\s+/g, " ").trim(), 700)
67
+ : options.qaPreset.summary];
65
68
  const pageParts = [options.page?.title, options.page?.url].filter((part) => typeof part === "string" && part.length > 0);
66
69
  if (pageParts.length > 0)
67
70
  lines.push(`Page: ${pageParts.join(" — ")}`);
68
71
  if (options.qaPreset.failedChecks.length > 0)
69
72
  lines.push("Failed checks:", ...options.qaPreset.failedChecks.map((failure) => `- ${failure}`));
73
+ if (options.qaPreset.notRunChecks.length > 0)
74
+ lines.push("Not run:", ...options.qaPreset.notRunChecks.map((check) => `- ${check}`));
70
75
  if (options.qaPreset.warnings.length > 0)
71
76
  lines.push("Warnings:", ...options.qaPreset.warnings.map((warning) => `- ${warning}`));
72
- lines.push(`Checks run: ${describeQaChecksRun(options.checks)} (${options.batchStepCount} batch step${options.batchStepCount === 1 ? "" : "s"})`);
77
+ lines.push(`Execution: ${options.executedStepCount ?? "unknown"}/${options.plannedStepCount} batch steps`);
73
78
  lines.push("Full diagnostic matrix: see details.qaPreset and details.batchSteps.");
74
79
  return lines.join("\n");
75
80
  }
@@ -182,11 +187,45 @@ export function analyzeQaPresetTimeout(compiled) {
182
187
  const failedChecks = compiled.checks.expectedText.map((text) => `expected text was not verified before timeout: ${formatQaExpectedTextPreview(text)}`);
183
188
  return {
184
189
  failedChecks,
190
+ notRunChecks: [],
185
191
  passed: false,
186
192
  summary: `QA preset failed: ${failedChecks.join("; ")}.`,
187
193
  warnings: ["The wrapper timed out before expected-text evidence could be verified; inspect timeoutPartialProgress and retry with a narrower readiness condition if the page was still loading."],
188
194
  };
189
195
  }
196
+ function describeNotRunQaChecks(compiled, executedStepCount) {
197
+ const checks = [];
198
+ let expectedTextIndex = 0;
199
+ for (const [index, step] of compiled.steps.entries()) {
200
+ let check;
201
+ if (step.action === "assertText") {
202
+ const expected = compiled.checks.expectedText[expectedTextIndex++];
203
+ if (expected)
204
+ check = `expected text: ${formatQaExpectedTextPreview(expected)}`;
205
+ }
206
+ else if (step.args[0] === "wait" && step.args[1] === "--load") {
207
+ check = `load state: ${step.args[2]}`;
208
+ }
209
+ else if (compiled.checks.expectedSelector && step.args[0] === "wait" && step.args[1] === compiled.checks.expectedSelector) {
210
+ check = `expected selector: ${formatQaExpectedTextPreview(compiled.checks.expectedSelector)}`;
211
+ }
212
+ else if (step.args[0] === "network" && !step.args.includes("--clear")) {
213
+ check = "network diagnostics";
214
+ }
215
+ else if (step.args[0] === "console" && !step.args.includes("--clear")) {
216
+ check = "console diagnostics";
217
+ }
218
+ else if (step.args[0] === "errors" && !step.args.includes("--clear") && step.generatedFrom !== "qa.errorBaselineAfterClear") {
219
+ check = "page error diagnostics";
220
+ }
221
+ else if (step.action === "screenshot") {
222
+ check = `screenshot: ${formatQaExpectedTextPreview(step.args[1] ?? "")}`;
223
+ }
224
+ if (index >= executedStepCount && check)
225
+ checks.push(check);
226
+ }
227
+ return checks;
228
+ }
190
229
  export function analyzeQaPresetResults(data, compiled) {
191
230
  const items = getBatchResultItems(data);
192
231
  if (items.length === 0)
@@ -238,6 +277,8 @@ export function analyzeQaPresetResults(data, compiled) {
238
277
  const expected = compiled.checks.expectedText[expectedTextIndex++];
239
278
  if (!expected)
240
279
  return;
280
+ if (!items[index])
281
+ return;
241
282
  const visibleTextPassed = qaVisibleTextWaitPassed(items[index], step);
242
283
  if (visibleTextPassed === true)
243
284
  return;
@@ -246,10 +287,16 @@ export function analyzeQaPresetResults(data, compiled) {
246
287
  failedChecks.push(`expected text not found: ${formatQaExpectedTextPreview(expected)}`);
247
288
  });
248
289
  }
290
+ const stoppedEarly = compiled && items.length < compiled.steps.length;
291
+ const failedFast = stoppedEarly && items.at(-1)?.success === false;
292
+ if (stoppedEarly && !failedFast)
293
+ failedChecks.push("QA execution could not be verified (incomplete batch results)");
249
294
  const uniqueFailures = [...new Set(failedChecks)];
250
295
  const uniqueWarnings = [...new Set(warnings)];
296
+ const notRunChecks = compiled && failedFast ? describeNotRunQaChecks(compiled, items.length) : [];
251
297
  return {
252
298
  failedChecks: uniqueFailures,
299
+ notRunChecks,
253
300
  passed: uniqueFailures.length === 0,
254
301
  summary: uniqueFailures.length === 0
255
302
  ? uniqueWarnings.length === 0 ? "QA preset passed." : `QA preset passed with warnings: ${uniqueWarnings.join("; ")}.`
@@ -79,41 +79,38 @@ export function createAgentBrowserQaParamsSchema(Type = JsonSchema, StringEnum =
79
79
  export function createAgentBrowserElectronParamsSchema(Type = JsonSchema, StringEnum = localStringEnum) {
80
80
  const common = { outputPath: outputProperties(Type).outputPath };
81
81
  const timeoutMs = Type.Optional(Type.Integer({ minimum: 1 }));
82
- return Type.Unsafe(Type.Union([
83
- Type.Object({
84
- action: StringEnum(["list"]),
85
- query: Type.Optional(Type.String({ description: "Case-insensitive app filter.", minLength: 1 })),
86
- maxResults: Type.Optional(Type.Integer({ description: `Result cap; default ${ELECTRON_DISCOVERY_DEFAULT_MAX_RESULTS}, clamped to ${ELECTRON_DISCOVERY_MAX_RESULTS}.`, minimum: 1 })),
87
- ...common,
88
- }, { additionalProperties: false }),
89
- Type.Object({
90
- action: StringEnum(["launch"]),
91
- appPath: Type.Optional(Type.String({ description: "macOS .app path.", minLength: 1 })),
92
- appName: Type.Optional(Type.String({ description: "Name from agent_browser_electron list.", minLength: 1 })),
93
- bundleId: Type.Optional(Type.String({ minLength: 1 })),
94
- executablePath: Type.Optional(Type.String({ minLength: 1 })),
95
- appArgs: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
96
- handoff: Type.Optional(StringEnum(AGENT_BROWSER_ELECTRON_HANDOFFS)),
97
- targetType: Type.Optional(StringEnum(AGENT_BROWSER_ELECTRON_TARGET_TYPES)),
98
- timeoutMs,
99
- allow: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
100
- deny: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
101
- ...common,
102
- }, { additionalProperties: false }),
103
- Type.Object({
104
- action: StringEnum(["status", "cleanup"]),
105
- launchId: Type.Optional(Type.String({ minLength: 1 })),
106
- all: Type.Optional(Type.Literal(true)),
107
- timeoutMs,
108
- ...common,
109
- }, { additionalProperties: false, not: { required: ["launchId", "all"] } }),
110
- Type.Object({
111
- action: StringEnum(["probe"]),
112
- launchId: Type.Optional(Type.String({ minLength: 1 })),
113
- timeoutMs,
114
- ...common,
115
- }, { additionalProperties: false }),
116
- ]));
82
+ const list = {
83
+ query: Type.Optional(Type.String({ description: "Case-insensitive app filter.", minLength: 1 })),
84
+ maxResults: Type.Optional(Type.Integer({ description: `Result cap; default ${ELECTRON_DISCOVERY_DEFAULT_MAX_RESULTS}, clamped to ${ELECTRON_DISCOVERY_MAX_RESULTS}.`, minimum: 1 })),
85
+ };
86
+ const launch = {
87
+ appPath: Type.Optional(Type.String({ description: "macOS .app path.", minLength: 1 })),
88
+ appName: Type.Optional(Type.String({ description: "Name from agent_browser_electron list.", minLength: 1 })),
89
+ bundleId: Type.Optional(Type.String({ minLength: 1 })),
90
+ executablePath: Type.Optional(Type.String({ minLength: 1 })),
91
+ appArgs: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
92
+ handoff: Type.Optional(StringEnum(AGENT_BROWSER_ELECTRON_HANDOFFS)),
93
+ targetType: Type.Optional(StringEnum(AGENT_BROWSER_ELECTRON_TARGET_TYPES)),
94
+ timeoutMs,
95
+ allow: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
96
+ deny: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
97
+ };
98
+ const probe = { launchId: Type.Optional(Type.String({ minLength: 1 })), timeoutMs };
99
+ const lifecycle = { ...probe, all: Type.Optional(Type.Literal(true)) };
100
+ // Strict providers need an object root; Pi needs root properties to normalize optional nulls.
101
+ // Each branch retains its original action-specific field boundary.
102
+ return Type.Unsafe(Type.Object({
103
+ action: StringEnum(["list", "launch", "status", "cleanup", "probe"]),
104
+ ...list, ...launch, ...lifecycle, ...common,
105
+ }, {
106
+ additionalProperties: false,
107
+ anyOf: [
108
+ Type.Object({ action: StringEnum(["list"]), ...list, ...common }, { additionalProperties: false }),
109
+ Type.Object({ action: StringEnum(["launch"]), ...launch, ...common }, { additionalProperties: false }),
110
+ Type.Object({ action: StringEnum(["status", "cleanup"]), ...lifecycle, ...common }, { additionalProperties: false, not: { required: ["launchId", "all"] } }),
111
+ Type.Object({ action: StringEnum(["probe"]), ...probe, ...common }, { additionalProperties: false }),
112
+ ],
113
+ }));
117
114
  }
118
115
  export function createAgentBrowserSourceParamsSchema(Type = JsonSchema, StringEnum = localStringEnum) {
119
116
  return Type.Unsafe(Type.Object({
@@ -1,5 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { createHash, randomUUID } from "node:crypto";
2
+ import { createHash, randomInt, randomUUID } from "node:crypto";
3
3
  import { lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, join } from "node:path";
@@ -11,6 +11,9 @@ const POLICY_LOCK_RETRY_MS = 10;
11
11
  const POLICY_LOCK_MAX_BYTES = 1_048_576; // One claim may cover an entire owned-browser cleanup set.
12
12
  const LOCK_OWNER_FILE = "owner.json";
13
13
  const LOCK_TICKET_FILE = "ticket.json";
14
+ const STALE_CLAIM_MIN_AGE_MS = 30 * 60 * 1_000;
15
+ const POLICY_GC_BATCH_SIZE = 8;
16
+ let lastPolicyGcName;
14
17
  function getCoordinationDirectory(platform = process.platform) {
15
18
  if (platform !== "win32") {
16
19
  const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
@@ -175,6 +178,7 @@ function parseOwner(content) {
175
178
  && Number.isSafeInteger(parsed.pid) && (parsed.pid ?? 0) > 0
176
179
  && typeof parsed.startIdentity === "string" && parsed.startIdentity.length > 0
177
180
  && typeof parsed.token === "string" && parsed.token.length > 0
181
+ && !parsed.token.includes("/") && !parsed.token.includes("\\") && !parsed.token.includes("\0")
178
182
  ? parsed
179
183
  : undefined;
180
184
  }
@@ -323,14 +327,30 @@ async function cleanDeadPolicyArtifacts(directory, budget) {
323
327
  catch {
324
328
  return;
325
329
  }
326
- for (const name of names.filter((candidate) => candidate.startsWith(".pi-agent-browser-policy-remove-")
327
- || candidate.includes(".lock-v4.candidate-"))) {
330
+ // ponytail: list/sort all names; stream directories if enumeration becomes the bottleneck.
331
+ const candidates = names.filter((name) => name.startsWith(".pi-agent-browser-policy-remove-")
332
+ || /^\.pi-agent-browser-policy-[a-f0-9]{64}\.lock-v4\.(?:candidate|claim)-/.test(name)).sort();
333
+ if (candidates.length === 0)
334
+ return;
335
+ const start = lastPolicyGcName === undefined ? randomInt(candidates.length)
336
+ : Math.max(0, candidates.findIndex((name) => name > lastPolicyGcName));
337
+ const batch = Array.from({ length: Math.min(POLICY_GC_BATCH_SIZE, candidates.length) }, (_, offset) => candidates[(start + offset) % candidates.length]);
338
+ lastPolicyGcName = batch.at(-1);
339
+ for (const name of batch) {
328
340
  if (budget.signal?.aborted || Date.now() >= (budget.deadline ?? Infinity))
329
341
  return;
330
342
  const path = join(directory, name);
343
+ const published = name.includes(".lock-v4.claim-");
344
+ if (published) {
345
+ const entry = await lstat(path).catch(() => undefined);
346
+ if (!entry || entry.mtimeMs >= Date.now() - STALE_CLAIM_MIN_AGE_MS)
347
+ continue;
348
+ }
331
349
  const claim = await readClaim(path);
332
- if (claim && await ownerAlive(claim.owner, budget) === false)
333
- await rm(path, { force: true, recursive: true }).catch(() => undefined);
350
+ if (!claim || (published && !name.endsWith(`.claim-${claim.owner.token}`)))
351
+ continue;
352
+ if (await ownerAlive(claim.owner, budget) === false)
353
+ await removeClaimOwnedBy(path, claim.owner.token).catch(() => false);
334
354
  }
335
355
  }
336
356
  function claimPrecedes(left, right) {
@@ -4,6 +4,7 @@ import { canonicalizeAgentBrowserNamespace, extractExplicitNamespace, extractExp
4
4
  import { hasLaunchScopedFlagToken, MANAGED_RESTORE_INCOMPATIBLE_BOOLEAN_ENVS, MANAGED_RESTORE_INCOMPATIBLE_ENVS, MANAGED_RESTORE_INCOMPATIBLE_FLAGS, } from "./launch-scoped-flags.js";
5
5
  import { createManagedSessionRestoreKey, ensureManagedSessionRestoreStorageIsSecure, getManagedSessionRestoreScope, getManagedSessionRestoreProtectedStorageEnv, hasManagedSessionRestoreProjectIdentity, resolveManagedSessionRestoreHome, } from "./managed-session-storage.js";
6
6
  import { parseUserBatchStdin } from "./orchestration/batch-stdin.js";
7
+ import { isRecord } from "./parsing.js";
7
8
  import { getAgentBrowserProcessEnvironment } from "./process-environment.js";
8
9
  export { createManagedSessionRestoreKey, ensureManagedSessionRestoreStorageIsSecure, getManagedSessionRestoreScope } from "./managed-session-storage.js";
9
10
  export { pruneOwnedManagedSessionRestoreSnapshots } from "./managed-session-snapshots.js";
@@ -39,10 +40,11 @@ export class ManagedSessionRestoreState {
39
40
  this.#disabled.add(getAgentBrowserSessionIdentityKey(sessionName, namespace));
40
41
  }
41
42
  getDaemonRestoreKey(sessionName, namespace) {
42
- return typeof sessionName === "string" ? this.#daemonRestoreKeys.get(getAgentBrowserSessionIdentityKey(sessionName, namespace)) : undefined;
43
+ return typeof sessionName === "string" ? this.#daemonRestoreKeys.get(getAgentBrowserSessionIdentityKey(sessionName, namespace))?.restoreKey : undefined;
43
44
  }
44
45
  hasDaemonRestoreKey(sessionName, namespace) {
45
- return typeof sessionName === "string" && this.#daemonRestoreKeys.has(getAgentBrowserSessionIdentityKey(sessionName, namespace));
46
+ return typeof sessionName === "string" && this.#daemonRestoreKeys.get(getAgentBrowserSessionIdentityKey(sessionName, namespace))?.restored !== true
47
+ && this.#daemonRestoreKeys.has(getAgentBrowserSessionIdentityKey(sessionName, namespace));
46
48
  }
47
49
  forgetDaemonRestoreKey(sessionName, namespace) {
48
50
  if (sessionName)
@@ -51,9 +53,22 @@ export class ManagedSessionRestoreState {
51
53
  isDisabled(sessionName, namespace) {
52
54
  return typeof sessionName === "string" && this.#disabled.has(getAgentBrowserSessionIdentityKey(sessionName, namespace));
53
55
  }
54
- recordDaemonRestoreKey(sessionName, namespace, restoreKey) {
55
- if (sessionName)
56
- this.#daemonRestoreKeys.set(getAgentBrowserSessionIdentityKey(sessionName, namespace), restoreKey);
56
+ recordDaemonRestoreKey(sessionName, namespace, restoreKey, generation) {
57
+ if (sessionName) {
58
+ const key = getAgentBrowserSessionIdentityKey(sessionName, namespace);
59
+ const previous = this.#daemonRestoreKeys.get(key);
60
+ this.#daemonRestoreKeys.set(key, { restoreKey, generation: generation ?? (previous?.restoreKey === restoreKey ? previous.generation : undefined) });
61
+ }
62
+ }
63
+ getDaemonReceipt(sessionName, namespace) {
64
+ const receipt = sessionName ? this.#daemonRestoreKeys.get(getAgentBrowserSessionIdentityKey(sessionName, namespace)) : undefined;
65
+ return receipt?.generation ? { restoreKey: receipt.restoreKey, generation: receipt.generation } : undefined;
66
+ }
67
+ restoreDaemonReceipt(sessionName, namespace, receipt) {
68
+ if (isRecord(receipt) && typeof receipt.generation === "string" && receipt.generation.length > 0
69
+ && (receipt.restoreKey === null || typeof receipt.restoreKey === "string")) {
70
+ this.#daemonRestoreKeys.set(getAgentBrowserSessionIdentityKey(sessionName, namespace), { restoreKey: receipt.restoreKey, generation: receipt.generation, restored: true });
71
+ }
57
72
  }
58
73
  replace(identities = [], options = {}) {
59
74
  if (!options.preserveDaemonRestoreKeys)
@@ -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
+ }
@@ -5,7 +5,8 @@ import { acquireManagedSessionPolicyLock } from "../../managed-session-policy-lo
5
5
  import { pruneOwnedManagedSessionRestoreSnapshots, resolveExplicitAutosaveInterval, withOwnedManagedSessionContext, } from "../../managed-session-restore.js";
6
6
  import { isManagedSessionRestoreKey } from "../../managed-session-storage.js";
7
7
  import { isRecord } from "../../parsing.js";
8
- import { getAgentBrowserProcessEnvironment } from "../../process-environment.js";
8
+ import { readProcessStartIdentity } from "../../process-identity.js";
9
+ import { getAgentBrowserProcessEnvironment, withAgentBrowserProcessEnvironment } from "../../process-environment.js";
9
10
  import { runAgentBrowserProcess, withAttachedBrowserSessionContext } from "../../process.js";
10
11
  import { getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "../../results/envelope.js";
11
12
  import { redactInvocationArgs } from "../../runtime.js";
@@ -43,9 +44,16 @@ export async function inspectManagedSessionDaemon(options) {
43
44
  return { status: "inactive" };
44
45
  if (!isRecord(data.runtime))
45
46
  return { status: "unknown" };
47
+ let generation;
48
+ if (options.includeGeneration && Number.isSafeInteger(data.runtime.backgroundPid) && Number(data.runtime.backgroundPid) > 0
49
+ && typeof data.runtime.socketDir === "string" && data.runtime.browserLaunched !== false) {
50
+ const start = await readProcessStartIdentity(Number(data.runtime.backgroundPid), process.platform, { signal: options.signal, deadline: Date.now() + (options.timeoutMs ?? 5_000) });
51
+ if (start)
52
+ generation = JSON.stringify({ pid: data.runtime.backgroundPid, start, socketDir: data.runtime.socketDir });
53
+ }
46
54
  if (typeof data.runtime.restoreKey === "string" && data.runtime.restoreKey.length > 0)
47
- return { restoreKey: data.runtime.restoreKey, status: "active" };
48
- return data.runtime.restoreKey === null ? { restoreKey: null, status: "active" } : { status: "unknown" };
55
+ return { restoreKey: data.runtime.restoreKey, status: "active", generation };
56
+ return data.runtime.restoreKey === null ? { restoreKey: null, status: "active", generation } : { status: "unknown" };
49
57
  }
50
58
  finally {
51
59
  if (processResult.stdoutSpillPath)
@@ -86,8 +94,10 @@ export async function acquireOwnedManagedSessionDaemonPolicy(options) {
86
94
  };
87
95
  }
88
96
  try {
97
+ const receipt = context.restoreState.getDaemonReceipt(context.sessionName, context.namespace);
89
98
  const daemon = await inspectManagedSessionDaemon({
90
99
  cwd: context.cwd,
100
+ includeGeneration: receipt !== undefined && !context.restoreState.hasDaemonRestoreKey(context.sessionName, context.namespace),
91
101
  headedManagedAutosaveInterval: context.headedManagedAutosaveInterval,
92
102
  namespace: context.namespace,
93
103
  sessionName: context.sessionName,
@@ -95,6 +105,13 @@ export async function acquireOwnedManagedSessionDaemonPolicy(options) {
95
105
  });
96
106
  if (daemon.status === "inactive")
97
107
  context.restoreState.forgetDaemonRestoreKey(context.sessionName, context.namespace);
108
+ if (daemon.status === "active" && receipt && !context.restoreState.hasDaemonRestoreKey(context.sessionName, context.namespace)) {
109
+ if (daemon.generation === receipt.generation && daemon.restoreKey === receipt.restoreKey) {
110
+ context.restoreState.recordDaemonRestoreKey(context.sessionName, context.namespace, daemon.restoreKey, daemon.generation);
111
+ }
112
+ else
113
+ context.restoreState.forgetDaemonRestoreKey(context.sessionName, context.namespace);
114
+ }
98
115
  if (options.mode === "close") {
99
116
  if (daemon.status === "active")
100
117
  context.restoreState.recordDaemonRestoreKey(context.sessionName, context.namespace, daemon.restoreKey);
@@ -141,87 +158,89 @@ export async function acquireOwnedManagedSessionDaemonPolicy(options) {
141
158
  }
142
159
  }
143
160
  export async function closeManagedSession(options) {
144
- const controller = new AbortController();
145
- let phase = "policy coordination";
146
- let phaseStartedAt = Date.now();
147
- let timeoutError;
148
- const timer = setTimeout(() => {
149
- timeoutError = `Managed-session cleanup timed out after ${options.timeoutMs} ms during ${phase} (${Date.now() - phaseStartedAt} ms in this phase).`;
150
- controller.abort();
151
- }, options.timeoutMs);
152
- let stdoutSpillPath;
153
- const closeArgs = [...(options.namespace !== undefined ? ["--namespace", options.namespace] : []), "--session", options.sessionName, "close"];
154
- const policyLock = options.policyLock ?? await acquireManagedSessionPolicyLock({
155
- namespace: options.namespace,
156
- sessionName: options.sessionName,
157
- signal: controller.signal,
158
- timeoutMs: Math.min(options.timeoutMs, 1_000),
159
- });
160
- if (!policyLock) {
161
- clearTimeout(timer);
162
- return timeoutError ?? "Managed-session policy coordination is unavailable or busy; cleanup did not run. Retry after the current operation finishes or repair the private policy-lock directory.";
163
- }
164
- try {
165
- phase = "daemon inspection";
166
- phaseStartedAt = Date.now();
167
- const daemon = await inspectManagedSessionDaemon({
168
- cwd: options.cwd,
169
- headedManagedAutosaveInterval: options.headedManagedAutosaveInterval,
161
+ return withAgentBrowserProcessEnvironment(options.socketDir ? { PI_AGENT_BROWSER_SOCKET_DIR: options.socketDir } : {}, async () => {
162
+ const controller = new AbortController();
163
+ let phase = "policy coordination";
164
+ let phaseStartedAt = Date.now();
165
+ let timeoutError;
166
+ const timer = setTimeout(() => {
167
+ timeoutError = `Managed-session cleanup timed out after ${options.timeoutMs} ms during ${phase} (${Date.now() - phaseStartedAt} ms in this phase).`;
168
+ controller.abort();
169
+ }, options.timeoutMs);
170
+ let stdoutSpillPath;
171
+ const closeArgs = [...(options.namespace !== undefined ? ["--namespace", options.namespace] : []), "--session", options.sessionName, "close"];
172
+ const policyLock = options.policyLock ?? await acquireManagedSessionPolicyLock({
170
173
  namespace: options.namespace,
171
- preserveAttachedBrowserSession: options.preserveAttachedBrowserSession,
172
174
  sessionName: options.sessionName,
173
175
  signal: controller.signal,
174
- timeoutMs: Math.min(options.timeoutMs, 2_000),
175
- });
176
- if (daemon.status === "active")
177
- options.restoreState.recordDaemonRestoreKey(options.sessionName, options.namespace, daemon.restoreKey);
178
- const daemonRestoreKey = options.restoreState.getDaemonRestoreKey(options.sessionName, options.namespace);
179
- const ownedRestoreKey = !options.restoreState.isDisabled(options.sessionName, options.namespace)
180
- && isManagedSessionRestoreKey(daemonRestoreKey) ? daemonRestoreKey : null;
181
- phase = "native close";
182
- phaseStartedAt = Date.now();
183
- const processResult = await runAgentBrowserProcess({
184
- args: closeArgs,
185
- cwd: options.cwd,
186
- env: { AGENT_BROWSER_JSON: "1", ...getHeadedManagedAutosaveEnv(options.headedManagedAutosaveInterval) },
187
- managedSessionRestoreState: options.restoreState,
188
- ownedManagedSession: true,
189
- preserveAttachedBrowserSession: options.preserveAttachedBrowserSession,
190
- signal: controller.signal,
176
+ timeoutMs: Math.min(options.timeoutMs, 1_000),
191
177
  });
192
- clearTimeout(timer);
193
- stdoutSpillPath = processResult.stdoutSpillPath;
194
- if (!processResult.aborted && !processResult.spawnError && processResult.exitCode === 0) {
195
- const parsed = await parseAgentBrowserEnvelope({ stdout: processResult.stdout, stdoutPath: processResult.stdoutSpillPath });
196
- const data = parsed.envelope?.success === true && isRecord(parsed.envelope.data) ? parsed.envelope.data : undefined;
197
- options.restoreState.clear(options.sessionName, options.namespace);
198
- pruneOwnedManagedSessionRestoreSnapshots({
178
+ if (!policyLock) {
179
+ clearTimeout(timer);
180
+ return timeoutError ?? "Managed-session policy coordination is unavailable or busy; cleanup did not run. Retry after the current operation finishes or repair the private policy-lock directory.";
181
+ }
182
+ try {
183
+ phase = "daemon inspection";
184
+ phaseStartedAt = Date.now();
185
+ const daemon = await inspectManagedSessionDaemon({
199
186
  cwd: options.cwd,
187
+ headedManagedAutosaveInterval: options.headedManagedAutosaveInterval,
200
188
  namespace: options.namespace,
201
- restoreKey: ownedRestoreKey,
202
- statePath: typeof data?.statePath === "string" ? data.statePath : undefined,
189
+ preserveAttachedBrowserSession: options.preserveAttachedBrowserSession,
190
+ sessionName: options.sessionName,
191
+ signal: controller.signal,
192
+ timeoutMs: Math.min(options.timeoutMs, 2_000),
193
+ });
194
+ if (daemon.status === "active")
195
+ options.restoreState.recordDaemonRestoreKey(options.sessionName, options.namespace, daemon.restoreKey);
196
+ const daemonRestoreKey = options.restoreState.getDaemonRestoreKey(options.sessionName, options.namespace);
197
+ const ownedRestoreKey = !options.restoreState.isDisabled(options.sessionName, options.namespace)
198
+ && isManagedSessionRestoreKey(daemonRestoreKey) ? daemonRestoreKey : null;
199
+ phase = "native close";
200
+ phaseStartedAt = Date.now();
201
+ const processResult = await runAgentBrowserProcess({
202
+ args: closeArgs,
203
+ cwd: options.cwd,
204
+ env: { AGENT_BROWSER_JSON: "1", ...getHeadedManagedAutosaveEnv(options.headedManagedAutosaveInterval) },
205
+ managedSessionRestoreState: options.restoreState,
206
+ ownedManagedSession: true,
207
+ preserveAttachedBrowserSession: options.preserveAttachedBrowserSession,
208
+ signal: controller.signal,
209
+ });
210
+ clearTimeout(timer);
211
+ stdoutSpillPath = processResult.stdoutSpillPath;
212
+ if (!processResult.aborted && !processResult.spawnError && processResult.exitCode === 0) {
213
+ const parsed = await parseAgentBrowserEnvelope({ stdout: processResult.stdout, stdoutPath: processResult.stdoutSpillPath });
214
+ const data = parsed.envelope?.success === true && isRecord(parsed.envelope.data) ? parsed.envelope.data : undefined;
215
+ options.restoreState.clear(options.sessionName, options.namespace);
216
+ pruneOwnedManagedSessionRestoreSnapshots({
217
+ cwd: options.cwd,
218
+ namespace: options.namespace,
219
+ restoreKey: ownedRestoreKey,
220
+ statePath: typeof data?.statePath === "string" ? data.statePath : undefined,
221
+ });
222
+ }
223
+ return timeoutError ?? getAgentBrowserErrorText({
224
+ aborted: processResult.aborted,
225
+ command: "close",
226
+ effectiveArgs: redactInvocationArgs(closeArgs),
227
+ exitCode: processResult.exitCode,
228
+ plainTextInspection: false,
229
+ spawnError: processResult.spawnError,
230
+ stderr: processResult.stderr,
231
+ timedOut: processResult.timedOut,
232
+ timeoutMs: processResult.timeoutMs,
203
233
  });
204
234
  }
205
- return timeoutError ?? getAgentBrowserErrorText({
206
- aborted: processResult.aborted,
207
- command: "close",
208
- effectiveArgs: redactInvocationArgs(closeArgs),
209
- exitCode: processResult.exitCode,
210
- plainTextInspection: false,
211
- spawnError: processResult.spawnError,
212
- stderr: processResult.stderr,
213
- timedOut: processResult.timedOut,
214
- timeoutMs: processResult.timeoutMs,
215
- });
216
- }
217
- catch (error) {
218
- return timeoutError ?? (error instanceof Error ? error.message : String(error));
219
- }
220
- finally {
221
- clearTimeout(timer);
222
- if (stdoutSpillPath)
223
- await rm(stdoutSpillPath, { force: true }).catch(() => undefined);
224
- if (!options.policyLock)
225
- await policyLock.release();
226
- }
235
+ catch (error) {
236
+ return timeoutError ?? (error instanceof Error ? error.message : String(error));
237
+ }
238
+ finally {
239
+ clearTimeout(timer);
240
+ if (stdoutSpillPath)
241
+ await rm(stdoutSpillPath, { force: true }).catch(() => undefined);
242
+ if (!options.policyLock)
243
+ await policyLock.release();
244
+ }
245
+ });
227
246
  }
@@ -392,6 +392,7 @@ export async function prepareBrowserRun(options) {
392
392
  }) };
393
393
  }
394
394
  electronLaunch = launchResult.value;
395
+ electronLaunch.record.ownerSessionId = options.ctx.sessionManager.getSessionId();
395
396
  runtimeToolArgs = ["connect", electronLaunch.connectArg];
396
397
  runtimeToolStdin = undefined;
397
398
  }
@@ -570,7 +571,18 @@ export async function prepareBrowserRun(options) {
570
571
  const sessionTabPinningReason = priorSessionPageState.pinningReason;
571
572
  let priorRefSnapshotState = priorSessionPageState.refSnapshot;
572
573
  let priorRefSnapshotInvalidation = priorSessionPageState.refSnapshotInvalidation;
573
- const coldManagedSession = !browserIndependent && (managedSessionDaemonInactive || priorSessionPageState.tabReopenPending === true)
574
+ let nativeGenerationChanged = false;
575
+ if (!browserIndependent && !isCloseCommand(executionPlan.commandInfo.command) && priorRefSnapshotState?.refIds.length && sessionStateKey && executionPlan.sessionName) {
576
+ const daemon = await inspectManagedSessionDaemon({ cwd, namespace: executionPlan.namespace, sessionName: executionPlan.sessionName, signal, includeGeneration: true, headedManagedAutosaveInterval: ownedManagedSession?.headedManagedAutosaveInterval ?? (ownedManagedSession?.headedManagedAutosaveDisabled ? "0" : undefined), timeoutMs: params.timeoutMs });
577
+ sessionPageState.bindSnapshotGeneration(sessionStateKey, daemon.status === "active" ? daemon.generation : undefined);
578
+ if (daemon.status !== "active" || daemon.generation === undefined || priorRefSnapshotState.generation !== daemon.generation) {
579
+ nativeGenerationChanged = daemon.status === "active" && daemon.generation !== undefined && priorRefSnapshotState.generation !== undefined && priorRefSnapshotState.generation !== daemon.generation;
580
+ priorRefSnapshotState = undefined;
581
+ priorRefSnapshotInvalidation = buildPageTransitionRefSnapshotInvalidation("The native browser generation changed or could not be verified. Take a new complete snapshot before using refs, even when the URL is unchanged.");
582
+ sessionPageState.applyRefSnapshotInvalidation({ invalidation: priorRefSnapshotInvalidation, sessionName: sessionStateKey, update: options.sessionPageStateUpdate });
583
+ }
584
+ }
585
+ const coldManagedSession = !browserIndependent && (managedSessionDaemonInactive || nativeGenerationChanged || priorSessionPageState.tabReopenPending === true)
574
586
  && recordedOwnedSession !== undefined
575
587
  && sessionTabPinningReason === "restore"
576
588
  && ownedManagedSession?.restoreDecision === "enabled"
@@ -960,8 +972,17 @@ export async function prepareBrowserRun(options) {
960
972
  signal,
961
973
  usedImplicitSession: executionPlan.usedImplicitSession,
962
974
  });
963
- if (snapshotFilter)
975
+ if (snapshotFilter) {
976
+ if (sessionStateKey && executionPlan.sessionName) {
977
+ const daemon = await inspectManagedSessionDaemon({ cwd, namespace: executionPlan.namespace, sessionName: executionPlan.sessionName, signal, includeGeneration: true, headedManagedAutosaveInterval: ownedManagedSession?.headedManagedAutosaveInterval ?? (ownedManagedSession?.headedManagedAutosaveDisabled ? "0" : undefined), timeoutMs: params.timeoutMs });
978
+ sessionPageState.bindSnapshotGeneration(sessionStateKey, daemon.status === "active" ? daemon.generation : undefined);
979
+ if (ownedManagedSession && daemon.status === "active" && daemon.generation)
980
+ state.managedSessionRestoreState.recordDaemonRestoreKey(executionPlan.sessionName, executionPlan.namespace, daemon.restoreKey, daemon.generation);
981
+ if (isRecord(snapshotFilter.result.details))
982
+ snapshotFilter.result.details.refSnapshot = sessionPageState.get(sessionStateKey).refSnapshot;
983
+ }
964
984
  return { kind: "early-result", statePatch: { ...statePatch, artifactManifest: snapshotFilter.artifactManifest ?? statePatch.artifactManifest }, result: snapshotFilter.result };
985
+ }
965
986
  const networkRequestsPageFilter = await tryNetworkRequestsPageFilter({
966
987
  commandTokens,
967
988
  compatibilityWorkaround,