pi-agent-browser-native 0.8.1 → 0.8.3

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 (32) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +16 -7
  3. package/dist/extensions/agent-browser/index.js +440 -163
  4. package/dist/extensions/agent-browser/lib/browser-journal.js +418 -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/input-modes/job.js +49 -2
  8. package/dist/extensions/agent-browser/lib/input-modes/params.js +32 -35
  9. package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +25 -5
  10. package/dist/extensions/agent-browser/lib/managed-session-restore.js +20 -5
  11. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +97 -78
  12. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +23 -2
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +52 -17
  14. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +1 -1
  15. package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +13 -8
  16. package/dist/extensions/agent-browser/lib/playbook.js +9 -2
  17. package/dist/extensions/agent-browser/lib/prompt-policy.js +0 -14
  18. package/dist/extensions/agent-browser/lib/results/presentation/large-output.js +2 -1
  19. package/dist/extensions/agent-browser/lib/session-page-state.js +95 -157
  20. package/dist/extensions/agent-browser/lib/temp.js +14 -6
  21. package/dist/extensions/agent-browser/lib/tool-surface.js +15 -11
  22. package/dist/extensions/agent-browser/lib/web-search.js +0 -8
  23. package/docs/ARCHITECTURE.md +15 -9
  24. package/docs/COMMAND_REFERENCE.md +10 -6
  25. package/docs/RELEASE.md +3 -3
  26. package/docs/REQUIREMENTS.md +1 -1
  27. package/docs/SESSION_STORAGE.md +60 -0
  28. package/docs/SUPPORT_MATRIX.md +8 -5
  29. package/docs/TOOL_CONTRACT.md +19 -14
  30. package/package.json +9 -5
  31. package/scripts/build.mjs +4 -3
  32. package/scripts/convert-browser-session.mjs +22 -0
@@ -0,0 +1,381 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { link, lstat, mkdir, open, unlink } from "node:fs/promises";
4
+ import { dirname, resolve } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import parser from "stream-json/parser.js";
8
+ import ignore from "stream-json/filters/ignore.js";
9
+ import stringer from "stream-json/stringer.js";
10
+ import disassembler from "stream-json/disassembler.js";
11
+ import { extractUpstreamCommandTokens } from "./argv-descriptor.js";
12
+ import { getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "./argv-grammar.js";
13
+ import { batchHasSuccessfulCloseAll, getSuccessfulBatchCloseLifecycle } from "./batch-lifecycle.js";
14
+ import { BROWSER_RESULT_TOOLS, BROWSER_STATE_FIELDS, BROWSER_TRANSITION_ENTRY, applyArtifactChanges, artifactChanges, browserStateEffects, getBrowserRecord, snapshotDefinition } from "./browser-transcript.js";
15
+ import { projectJson, readRange, scanJournalMetadata } from "./browser-journal.js";
16
+ import { isCloseAllCommand, isCloseCommand, isReadOnlyDiagnosticSessionTargetCommand } from "./command-taxonomy.js";
17
+ import { isRecord } from "./parsing.js";
18
+ import { isSessionArtifactManifest } from "./results/artifact-manifest.js";
19
+ import { buildNoActivePageRefSnapshotInvalidation, extractSessionTabTargetFromBatchResults, isNoActivePageSnapshotFailure, normalizeSessionTabTarget } from "./session-page-state.js";
20
+ const emptyProjection = () => ({ pages: new Map(), nextIndex: 0 });
21
+ function legacyTarget(value) {
22
+ return normalizeSessionTabTarget({
23
+ url: typeof value.url === "string" ? value.url : undefined,
24
+ title: typeof value.title === "string" ? value.title : undefined,
25
+ targetId: typeof value.targetId === "string" ? value.targetId : undefined,
26
+ });
27
+ }
28
+ function legacyMessage(entry) {
29
+ if (entry.type === "custom" && entry.customType === BROWSER_TRANSITION_ENTRY && isRecord(entry.data) && isRecord(entry.data.details))
30
+ return entry.data;
31
+ const message = entry.type === "message" && isRecord(entry.message) ? entry.message : undefined;
32
+ return message && typeof message.toolName === "string" && BROWSER_RESULT_TOOLS.has(message.toolName) && isRecord(message.details)
33
+ && message.details.browserEventVersion !== 1 ? message : undefined;
34
+ }
35
+ function legacySnapshot(value, id) {
36
+ if (!isRecord(value) || !Array.isArray(value.refIds))
37
+ return undefined;
38
+ const refIds = value.refIds.filter((ref) => typeof ref === "string" && /^e\d+$/.test(ref));
39
+ const sourceRefs = isRecord(value.refs) ? value.refs : {};
40
+ const snapshot = {
41
+ snapshotId: typeof value.snapshotId === "string" ? value.snapshotId : id, refIds,
42
+ generation: typeof value.generation === "string" ? value.generation : undefined,
43
+ refs: Object.fromEntries(refIds.flatMap(refId => {
44
+ const ref = sourceRefs[refId];
45
+ if (!isRecord(ref) || typeof ref.name !== "string" || typeof ref.role !== "string")
46
+ return [];
47
+ return [[refId, { name: ref.name, role: ref.role, ...(typeof ref.isEditable === "boolean" ? { isEditable: ref.isEditable } : {}), ...(typeof ref.isContentEditable === "boolean" ? { isContentEditable: ref.isContentEditable } : {}) }]];
48
+ })),
49
+ target: isRecord(value.target) ? legacyTarget(value.target) : undefined,
50
+ };
51
+ return snapshotDefinition(snapshot);
52
+ }
53
+ /** Offline only: normalize old receipts along their actual parent ancestry, never physical-order equality. */
54
+ export function convertLegacyBrowserEntry(entry, parent = emptyProjection(), ownerSessionId) {
55
+ const current = { pages: new Map(parent.pages), manifest: parent.manifest, nextIndex: parent.nextIndex };
56
+ const nativeId = typeof entry.id === "string" ? entry.id : randomUUID();
57
+ const canonical = getBrowserRecord(entry);
58
+ if (canonical) {
59
+ current.manifest = applyArtifactChanges(parent.manifest, canonical.event.artifacts);
60
+ for (const page of canonical.event.pages ?? []) {
61
+ const prior = current.pages.get(page.key) ?? {};
62
+ if (page.clear) {
63
+ current.pages.delete(page.key);
64
+ continue;
65
+ }
66
+ const definition = canonical.snapshot;
67
+ const digest = definition?.refs ? createHash("sha256").update(JSON.stringify({ refs: definition.refs, target: definition.target, generation: definition.generation })).digest("hex") : prior.snapshot?.digest ?? "";
68
+ current.pages.set(page.key, { target: page.target, unknown: page.unknown, reopenPending: page.reopenPending,
69
+ snapshot: page.refs.kind === "replace" || page.refs.kind === "reuse" ? { id: page.refs.snapshotId, digest, usable: true, candidate: false }
70
+ : prior.snapshot ? { ...prior.snapshot, usable: false, candidate: canonical.event.phase === "begin" } : undefined,
71
+ pending: canonical.event.phase === "begin" ? { operationId: canonical.event.operationId, toolCallId: canonical.event.toolCallId, index: canonical.event.commandIndex } : undefined });
72
+ }
73
+ return { projection: current };
74
+ }
75
+ if (entry.type === "custom" && entry.customType === "agent-browser-script-session" && isRecord(entry.data)) {
76
+ return { record: { event: { version: 1, phase: "state", operationId: `legacy-${nativeId}`, toolCallId: `legacy-${nativeId}`, commandIndex: 0, isError: entry.data.cleanup !== "closed", state: { scriptLease: { ...entry.data, ownerSessionId: entry.data.ownerSessionId ?? ownerSessionId } } } }, projection: current };
77
+ }
78
+ const message = legacyMessage(entry);
79
+ if (!message || !isRecord(message.details))
80
+ return { projection: parent };
81
+ const details = message.details;
82
+ const sessionName = typeof details.sessionName === "string" ? details.sessionName : undefined;
83
+ const namespace = typeof details.namespace === "string" ? details.namespace : undefined;
84
+ const key = sessionName ? getAgentBrowserSessionIdentityKey(sessionName, namespace) : undefined;
85
+ const args = Array.isArray(details.args) ? details.args.filter((arg) => typeof arg === "string") : [];
86
+ const tokens = extractUpstreamCommandTokens(args);
87
+ const command = typeof details.command === "string" ? details.command : tokens[0];
88
+ const subcommand = typeof details.subcommand === "string" ? details.subcommand : tokens[1];
89
+ const isError = message.isError === true;
90
+ const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : `legacy-${nativeId}`;
91
+ const prior = key ? current.pages.get(key) ?? {} : {};
92
+ const begin = entry.type === "custom" && details.sessionTabTargetUnknown === true && details.command === undefined && details.exitCode === undefined && details.agentBrowserStarted === undefined;
93
+ const pending = prior.pending?.toolCallId === toolCallId ? prior.pending : undefined;
94
+ const phase = begin ? "begin" : pending ? "finish" : "state";
95
+ const commandIndex = begin ? current.nextIndex++ : pending?.index ?? 0;
96
+ const operationId = pending?.operationId ?? `legacy-${nativeId}`;
97
+ const pages = [];
98
+ const closeLifecycle = getSuccessfulBatchCloseLifecycle(details.batchSteps);
99
+ const closeAll = details.closeAllApplied === true || (!isError && isCloseAllCommand(tokens)) || batchHasSuccessfulCloseAll(details.batchSteps);
100
+ const lifecycleReset = !isError && isCloseCommand(command) || closeLifecycle !== undefined;
101
+ if (closeAll) {
102
+ for (const pageKey of current.pages.keys())
103
+ if (isAgentBrowserSessionIdentityKeyInNamespace(pageKey, namespace)) {
104
+ current.pages.delete(pageKey);
105
+ pages.push({ key: pageKey, clear: true, refs: { kind: "invalidate" } });
106
+ }
107
+ }
108
+ else if (key && lifecycleReset) {
109
+ current.pages.delete(key);
110
+ pages.push({ key, clear: true, refs: { kind: "invalidate" } });
111
+ }
112
+ let definition;
113
+ const hasPageEffects = begin || ["sessionTabTargetUnknown", "sessionTabTarget", "sessionTabReopenPending", "refSnapshot", "refSnapshotInvalidation"].some(field => details[field] !== undefined)
114
+ || command === "batch" && Array.isArray(details.data);
115
+ if (key && hasPageEffects && !((closeAll || lifecycleReset) && (isCloseCommand(command) || closeLifecycle?.endsClosed === true))) {
116
+ const page = { ...(current.pages.get(key) ?? {}) };
117
+ if (begin) {
118
+ page.target = undefined;
119
+ page.unknown = true;
120
+ page.snapshot = prior.snapshot ? { ...prior.snapshot, usable: false, candidate: true } : undefined;
121
+ page.pending = { toolCallId, operationId, index: commandIndex };
122
+ pages.push({ key, refs: { kind: "unknown" }, unknown: true });
123
+ }
124
+ else if (details.sessionTabTargetUnknown === true) {
125
+ page.target = undefined;
126
+ page.unknown = true;
127
+ page.pending = undefined;
128
+ page.snapshot = page.snapshot ? { ...page.snapshot, usable: false, candidate: false } : undefined;
129
+ pages.push({ key, refs: { kind: "unknown", invalidation: isRecord(details.refSnapshotInvalidation) ? details.refSnapshotInvalidation : undefined }, unknown: true });
130
+ }
131
+ else {
132
+ if (!isRecord(details.compiledNetworkSourceLookup) && !isReadOnlyDiagnosticSessionTargetCommand(command, subcommand) && isRecord(details.sessionTabTarget))
133
+ page.target = legacyTarget(details.sessionTabTarget) ?? page.target;
134
+ else if (command === "batch" && !isRecord(details.compiledNetworkSourceLookup))
135
+ page.target = extractSessionTabTargetFromBatchResults(details.data) ?? page.target;
136
+ if (page.target)
137
+ page.unknown = undefined;
138
+ if (typeof details.sessionTabReopenPending === "boolean")
139
+ page.reopenPending = details.sessionTabReopenPending;
140
+ const invalidation = isRecord(details.refSnapshotInvalidation) && ["no-active-page", "page-transition"].includes(String(details.refSnapshotInvalidation.reason))
141
+ ? details.refSnapshotInvalidation
142
+ : isNoActivePageSnapshotFailure(command, typeof details.error === "string" ? details.error : typeof details.summary === "string" ? details.summary : undefined) ? buildNoActivePageRefSnapshotInvalidation() : undefined;
143
+ const candidate = invalidation ? undefined : legacySnapshot(details.refSnapshot, `snapshot-${nativeId}`);
144
+ let refs;
145
+ if (candidate) {
146
+ const digest = createHash("sha256").update(JSON.stringify({ refs: candidate.refs, target: candidate.target, generation: candidate.generation })).digest("hex");
147
+ if (page.snapshot?.digest === digest && (page.snapshot.usable || page.snapshot.candidate)
148
+ && (!isRecord(details.refSnapshot) || typeof details.refSnapshot.snapshotId !== "string" || details.refSnapshot.snapshotId === page.snapshot.id))
149
+ refs = { kind: "reuse", snapshotId: page.snapshot.id };
150
+ else {
151
+ definition = candidate;
152
+ refs = { kind: "replace", snapshotId: candidate.id };
153
+ }
154
+ page.snapshot = { id: refs.snapshotId, digest, usable: true, candidate: false };
155
+ }
156
+ else if (invalidation) {
157
+ refs = { kind: "invalidate", invalidation: { reason: invalidation.reason, summary: typeof invalidation.summary === "string" ? invalidation.summary : "The prior refs were invalidated; take a fresh snapshot." } };
158
+ page.snapshot = page.snapshot ? { ...page.snapshot, usable: false, candidate: false } : undefined;
159
+ }
160
+ else
161
+ refs = page.snapshot?.usable ? { kind: "reuse", snapshotId: page.snapshot.id } : { kind: "invalidate" };
162
+ page.pending = undefined;
163
+ pages.push({ key, target: page.target, unknown: page.unknown, reopenPending: page.reopenPending, refs });
164
+ }
165
+ current.pages.set(key, page);
166
+ }
167
+ if (isSessionArtifactManifest(details.artifactManifest) && (!parent.manifest || details.artifactManifest.updatedAtMs >= parent.manifest.updatedAtMs))
168
+ current.manifest = details.artifactManifest;
169
+ const artifacts = artifactChanges(parent.manifest, current.manifest);
170
+ if (!artifacts)
171
+ current.manifest = parent.manifest;
172
+ const state = browserStateEffects(details);
173
+ if (isRecord(state.electron)) {
174
+ const stamp = (value) => isRecord(value) ? { ...value, ownerSessionId: value.ownerSessionId ?? ownerSessionId } : value;
175
+ const cleanup = isRecord(state.electron.cleanup) ? state.electron.cleanup : undefined;
176
+ state.electron = { ...state.electron, launch: stamp(state.electron.launch), ...(cleanup ? { cleanup: {
177
+ ...cleanup, records: Array.isArray(cleanup.records) ? cleanup.records.map(stamp) : cleanup.records,
178
+ results: Array.isArray(cleanup.results) ? cleanup.results.map(row => isRecord(row) ? { ...row, record: stamp(row.record) } : row) : cleanup.results,
179
+ } } : {}) };
180
+ }
181
+ return { record: { event: { version: 1, phase, operationId, toolCallId, commandIndex, isError, state, pages, artifacts }, ...(definition ? { snapshot: definition } : {}) }, projection: current };
182
+ }
183
+ /** Explicit archive/in-memory conversion. Runtime restoration never invokes the legacy decoder. */
184
+ export function convertBrowserEntries(entries, ownerSessionId) {
185
+ const states = new Map();
186
+ let linear = emptyProjection();
187
+ return entries.map((value, index) => {
188
+ if (!isRecord(value))
189
+ return value;
190
+ const entry = { ...value, id: typeof value.id === "string" ? value.id : `archive-${index}`, parentId: value.parentId ?? null };
191
+ const parent = typeof value.parentId === "string" ? states.get(value.parentId) ?? emptyProjection() : Object.hasOwn(value, "parentId") ? emptyProjection() : linear;
192
+ const converted = convertLegacyBrowserEntry(entry, parent, ownerSessionId);
193
+ states.set(entry.id, converted.projection);
194
+ linear = converted.projection;
195
+ return converted.record ? { ...entry, ...(entry.type === "custom" ? { customType: BROWSER_TRANSITION_ENTRY } : {}), data: converted.record } : entry;
196
+ });
197
+ }
198
+ const LEGACY_DETAILS_FIELDS = [...BROWSER_STATE_FIELDS, "browserEventVersion", "electron", "compiledNetworkSourceLookup", "sessionTabTarget", "sessionTabTargetUnknown", "sessionTabReopenPending", "refSnapshot", "refSnapshotInvalidation", "artifactManifest", "error", "summary"];
199
+ const LEGACY_FIELDS = [
200
+ ["data", "event"], ["data", "snapshot"], ["data", "cleanup"], ["data", "sessionName"], ["data", "closeCommandArgs"], ["data", "launchAttempted"],
201
+ ["data", "toolCallId"], ["data", "isError"], ["message", "toolName"], ["message", "toolCallId"], ["message", "isError"],
202
+ ...["data", "message"].flatMap(root => LEGACY_DETAILS_FIELDS.map(field => [root, "details", field])),
203
+ ...["data", "message"].flatMap(root => [
204
+ ...["command", "success", "lifecycle"].map(field => [root, "details", "batchSteps", "*", field]),
205
+ ...[["data", "lifecycle"], ["result", "lifecycle"]].map(field => [root, "details", "batchSteps", "*", ...field]),
206
+ ]),
207
+ ...["data", "message"].flatMap(root => [
208
+ ...[["command"], ["success"], ["error"]].map(field => [root, "details", "data", "*", ...field]),
209
+ ...["url", "title", "origin", "targetId"].map(field => [root, "details", "data", "*", "result", field]),
210
+ ]),
211
+ ];
212
+ async function sourceChecksum(file, size) {
213
+ const hash = createHash("sha256");
214
+ for await (const chunk of readRange(file, { offset: 0, length: size }))
215
+ hash.update(chunk);
216
+ return hash.digest("hex");
217
+ }
218
+ async function writeBytes(file, chunks) {
219
+ for await (const chunk of chunks) {
220
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
221
+ let offset = 0;
222
+ while (offset < buffer.length) {
223
+ const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset);
224
+ if (!bytesWritten)
225
+ throw new Error("Could not write converted journal.");
226
+ offset += bytesWritten;
227
+ }
228
+ }
229
+ }
230
+ async function rewriteMessage(file, range, destination, record) {
231
+ async function* decode() {
232
+ const decoder = new TextDecoder("utf-8", { fatal: true });
233
+ for await (const bytes of readRange(file, range))
234
+ yield decoder.decode(bytes, { stream: true });
235
+ const tail = decoder.decode();
236
+ if (tail)
237
+ yield tail;
238
+ }
239
+ const tokenizer = parser.asStream({ packValues: false, packKeys: true, streamKeys: false });
240
+ const filter = ignore.asStream({ filter: /^(?:data|message\.details\.(?:refSnapshot|artifactManifest|browserEventVersion))$/ });
241
+ async function* inject(tokens) {
242
+ let depth = 0;
243
+ for await (const token of tokens) {
244
+ if (token.name === "startObject" || token.name === "startArray")
245
+ depth++;
246
+ if (token.name === "endObject" || token.name === "endArray") {
247
+ depth--;
248
+ if (depth === 0) {
249
+ yield { name: "keyValue", value: "data" };
250
+ yield* disassembler({ packValues: false, packKeys: true, streamKeys: false })(record);
251
+ }
252
+ }
253
+ yield token;
254
+ }
255
+ }
256
+ const output = stringer.asStream({ useKeyValues: true });
257
+ const processing = pipeline(Readable.from(decode()), tokenizer, filter, inject, output);
258
+ try {
259
+ await writeBytes(destination, output);
260
+ await processing;
261
+ }
262
+ finally {
263
+ tokenizer.destroy();
264
+ filter.destroy();
265
+ output.destroy();
266
+ }
267
+ await destination.write("\n");
268
+ }
269
+ /** Separate-copy publication only. The operator must quiesce the exact writer first. */
270
+ export async function convertBrowserSession(options) {
271
+ if (!options.confirmedStopped)
272
+ throw new Error("Stop/quiesce the exact session writer first, then pass --confirm-stopped. Conversion never stops a session.");
273
+ const source = resolve(options.source);
274
+ const destination = resolve(options.destination);
275
+ if (source === destination)
276
+ throw new Error("Conversion requires a separate destination; originals are never rewritten.");
277
+ const sourcePathStat = await lstat(source);
278
+ if (!sourcePathStat.isFile() || sourcePathStat.isSymbolicLink())
279
+ throw new Error("Conversion source must be a regular file, not a symlink.");
280
+ const directory = dirname(destination);
281
+ await mkdir(directory, { mode: 0o700, recursive: true });
282
+ const directoryStat = await lstat(directory);
283
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || process.platform !== "win32" && (directoryStat.uid !== process.getuid?.() || (directoryStat.mode & 0o077) !== 0))
284
+ throw new Error("Conversion destination must be a private current-user directory (0700 on POSIX), without symlinks.");
285
+ try {
286
+ await lstat(destination);
287
+ throw new Error("Conversion destination is occupied.");
288
+ }
289
+ catch (error) {
290
+ if (error.code !== "ENOENT")
291
+ throw error;
292
+ }
293
+ const file = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
294
+ const stagePath = `${destination}.${randomUUID()}.tmp`;
295
+ let stage;
296
+ let published = false;
297
+ try {
298
+ const initial = await file.stat();
299
+ if (initial.dev !== sourcePathStat.dev || initial.ino !== sourcePathStat.ino)
300
+ throw new Error("Conversion source changed before capture.");
301
+ const checksum = await sourceChecksum(file, initial.size);
302
+ const metadata = await scanJournalMetadata(file, initial.size, true);
303
+ const header = metadata[0];
304
+ if (header?.value.type !== "session" || typeof header.value.id !== "string")
305
+ throw new Error("Conversion source has no native session header.");
306
+ const ids = new Set();
307
+ const states = new Map();
308
+ stage = await open(stagePath, "wx+", 0o600);
309
+ let convertedRecords = 0;
310
+ let snapshotDefinitions = 0;
311
+ for (const entry of metadata) {
312
+ let record;
313
+ if (entry.value.type !== "session") {
314
+ if (typeof entry.value.id !== "string" || ids.has(entry.value.id))
315
+ throw new Error("Conversion source contains a missing or duplicate native entry ID.");
316
+ if (entry.value.parentId !== null && (typeof entry.value.parentId !== "string" || !ids.has(entry.value.parentId)))
317
+ throw new Error("Conversion source contains a missing or forward parent.");
318
+ ids.add(entry.value.id);
319
+ const parent = typeof entry.value.parentId === "string" ? states.get(entry.value.parentId) ?? emptyProjection() : emptyProjection();
320
+ const relevant = entry.value.type === "custom" && [BROWSER_TRANSITION_ENTRY, "agent-browser-script-session"].includes(String(entry.value.customType))
321
+ || isRecord(entry.value.message) && BROWSER_RESULT_TOOLS.has(String(entry.value.message.toolName));
322
+ if (relevant) {
323
+ const details = await projectJson(readRange(file, entry), LEGACY_FIELDS, Infinity);
324
+ const conversion = convertLegacyBrowserEntry({ ...entry.value, ...details }, parent, header.value.id);
325
+ record = conversion.record;
326
+ states.set(entry.value.id, conversion.projection);
327
+ }
328
+ else
329
+ states.set(entry.value.id, parent);
330
+ }
331
+ if (record) {
332
+ const data = { ...record, archive: { sourceSha256: checksum, offset: entry.offset, length: entry.length } };
333
+ if (entry.value.type === "message")
334
+ await rewriteMessage(file, entry, stage, data);
335
+ else
336
+ await stage.write(`${JSON.stringify({ ...entry.value, customType: BROWSER_TRANSITION_ENTRY, data })}\n`);
337
+ convertedRecords++;
338
+ if (record.snapshot)
339
+ snapshotDefinitions++;
340
+ }
341
+ else {
342
+ await writeBytes(stage, readRange(file, entry));
343
+ if (entry.offset + entry.length === initial.size) {
344
+ const lastByte = Buffer.alloc(1);
345
+ await file.read(lastByte, 0, 1, initial.size - 1);
346
+ if (lastByte[0] !== 10)
347
+ await stage.write("\n");
348
+ }
349
+ }
350
+ }
351
+ await stage.sync();
352
+ const output = await stage.stat();
353
+ const destinationSha256 = await sourceChecksum(stage, output.size);
354
+ const final = await file.stat();
355
+ const finalPath = await lstat(source);
356
+ if (final.dev !== initial.dev || final.ino !== initial.ino || final.size !== initial.size || final.mtimeMs !== initial.mtimeMs
357
+ || finalPath.dev !== initial.dev || finalPath.ino !== initial.ino || await sourceChecksum(file, initial.size) !== checksum)
358
+ throw new Error("Conversion source changed; no destination was published.");
359
+ await stage.close();
360
+ stage = undefined;
361
+ await link(stagePath, destination); // Exclusive publication; never overwrite an existing destination.
362
+ published = true;
363
+ return { version: 1, source, destination, sessionId: header.value.id, sourceSha256: checksum, destinationSha256, entryCount: ids.size,
364
+ convertedRecords, snapshotDefinitions, sourceBytes: initial.size, destinationBytes: output.size };
365
+ }
366
+ finally {
367
+ await stage?.close();
368
+ await file.close();
369
+ await unlink(stagePath).catch(error => { if (error.code !== "ENOENT")
370
+ throw error; });
371
+ if (published && process.platform !== "win32") {
372
+ const parent = await open(directory, constants.O_RDONLY);
373
+ try {
374
+ await parent.sync();
375
+ }
376
+ finally {
377
+ await parent.close();
378
+ }
379
+ }
380
+ }
381
+ }
@@ -1,33 +1,133 @@
1
1
  import { isRecord } from "./parsing.js";
2
+ import { redactSensitiveValue } from "./runtime.js";
3
+ import { buildPageTransitionRefSnapshotInvalidation, normalizeSessionTabTarget, targetsMatch } from "./session-page-state.js";
4
+ import { getSessionArtifactManifestEntryKey, isPendingRecordingArtifact, isSessionArtifactManifest } from "./results/artifact-manifest.js";
5
+ import { extractAgentBrowserLifecycle } from "./results/presentation/common.js";
2
6
  export const BROWSER_TRANSITION_ENTRY = "agent-browser-transition";
7
+ export const BROWSER_EVENT_VERSION = 1;
3
8
  export const BROWSER_RESULT_TOOLS = new Set([
4
9
  "agent_browser", "agent_browser_code", "agent_browser_action", "agent_browser_qa", "agent_browser_electron",
5
10
  "agent_browser_source", "agent_browser_network_source",
6
11
  ]);
7
- /** Direct tools and ordered code calls replay through the same browser state reducers. */
8
- export function getBrowserResultMessage(entry) {
9
- if (!isRecord(entry))
10
- return undefined;
11
- if (entry.type === "custom" && entry.customType === BROWSER_TRANSITION_ENTRY && isRecord(entry.data)) {
12
- return isRecord(entry.data.details) && typeof entry.data.isError === "boolean" ? entry.data : undefined;
13
- }
14
- const message = entry.type === "message" && isRecord(entry.message) ? entry.message : undefined;
15
- return message && typeof message.toolName === "string" && BROWSER_RESULT_TOOLS.has(message.toolName) ? message : undefined;
16
- }
17
- // Page data and rendered output are not state. Keep only fields consumed by the
18
- // existing replay reducers; recording reservations retain their own native journal.
19
- const TRANSITION_FIELDS = [
12
+ // These are small native lifecycle/ownership receipts, not rendered page data.
13
+ export const BROWSER_STATE_FIELDS = [
20
14
  "args", "command", "subcommand", "sessionName", "namespace", "sessionMode", "usedImplicitSession",
15
+ "managedSessionSocketDir",
16
+ "managedSessionDaemon", "ownerSessionId",
21
17
  "agentBrowserStarted", "resultCategory", "exitCode", "closeAllApplied", "attachedBrowserSession",
22
- "sessionTabTarget", "sessionTabTargetUnknown", "sessionTabReopenPending", "refSnapshot", "refSnapshotInvalidation",
23
18
  "readConfirmation", "compatibilityWorkaround", "managedSessionHeadedAutosaveDisabled", "managedSessionHeadedAutosaveInterval",
24
- "managedSessionOutcome", "managedSessionCwd", "managedSessionRestoreDisabled", "artifactManifest", "electron", "compiledNetworkSourceLookup",
19
+ "managedSessionOutcome", "managedSessionCwd", "managedSessionRestoreDisabled",
20
+ "nativeSucceeded",
25
21
  ];
26
- export function appendBrowserTransition(pi, toolCallId, details, isError) {
27
- const state = Object.fromEntries(TRANSITION_FIELDS.filter(key => details[key] !== undefined).map(key => [key, details[key]]));
22
+ export function browserStateEffects(details) {
23
+ const state = Object.fromEntries(BROWSER_STATE_FIELDS.filter(key => details[key] !== undefined).map(key => [key, details[key]]));
28
24
  if (Array.isArray(details.batchSteps))
29
25
  state.batchSteps = details.batchSteps.filter(isRecord).map(step => ({
30
- command: step.command, success: step.success, lifecycle: step.lifecycle,
26
+ command: step.command, success: step.success, lifecycle: extractAgentBrowserLifecycle(step),
31
27
  }));
32
- pi.appendEntry(BROWSER_TRANSITION_ENTRY, { toolCallId, details: state, isError });
28
+ if (isRecord(details.electron) && (details.electron.launch !== undefined || details.electron.cleanup !== undefined)) {
29
+ state.electron = { launch: details.electron.launch, cleanup: details.electron.cleanup };
30
+ }
31
+ return state;
32
+ }
33
+ export function getBrowserRecord(entry) {
34
+ if (!isRecord(entry) || !isRecord(entry.data))
35
+ return undefined;
36
+ if (entry.type !== "message" && !(entry.type === "custom" && entry.customType === BROWSER_TRANSITION_ENTRY))
37
+ return undefined;
38
+ const event = entry.data.event;
39
+ if (!isRecord(event) || event.version !== BROWSER_EVENT_VERSION)
40
+ return undefined;
41
+ if (!["begin", "finish", "state"].includes(String(event.phase)) || typeof event.operationId !== "string"
42
+ || typeof event.toolCallId !== "string" || !Number.isSafeInteger(event.commandIndex) || event.commandIndex < 0 || typeof event.isError !== "boolean" || !isRecord(event.state)) {
43
+ throw new Error("Invalid browser event; inspect or convert the retained session before browser work.");
44
+ }
45
+ if (event.pages !== undefined && (!Array.isArray(event.pages) || !event.pages.every(page => isRecord(page)
46
+ && typeof page.key === "string" && isRecord(page.refs) && ["reuse", "replace", "invalidate", "unknown"].includes(String(page.refs.kind))
47
+ && (!["reuse", "replace"].includes(String(page.refs.kind)) || typeof page.refs.snapshotId === "string" && page.refs.snapshotId.length > 0)
48
+ && (page.target === undefined || isRecord(page.target) && normalizeSessionTabTarget(page.target) !== undefined)
49
+ && (page.unknown === undefined || page.unknown === true) && (page.clear === undefined || page.clear === true)
50
+ && (page.reopenPending === undefined || typeof page.reopenPending === "boolean")
51
+ && (page.pinningReason === undefined || ["restore", "drift"].includes(String(page.pinningReason)))
52
+ && (page.refs.invalidation === undefined || isRecord(page.refs.invalidation) && ["page-transition", "no-active-page"].includes(String(page.refs.invalidation.reason)) && typeof page.refs.invalidation.summary === "string")))) {
53
+ throw new Error("Invalid browser page disposition; refs cannot be restored.");
54
+ }
55
+ if (event.artifacts !== undefined) {
56
+ const changes = event.artifacts;
57
+ if (!isRecord(changes) || !Array.isArray(changes.removals) || !changes.removals.every(key => typeof key === "string")
58
+ || !isSessionArtifactManifest({ version: 1, entries: changes.upserts, maxEntries: changes.maxEntries, updatedAtMs: changes.updatedAtMs, liveCount: 0, evictedCount: 0 })) {
59
+ throw new Error("Invalid browser artifact changes; inspect the retained journal.");
60
+ }
61
+ }
62
+ return entry.data;
63
+ }
64
+ /** Only canonical receipts participate in live replay. Legacy shapes belong to the offline converter. */
65
+ export function getBrowserResultMessage(entry) {
66
+ const record = getBrowserRecord(entry);
67
+ return record ? { details: record.event.state, isError: typeof record.event.state.nativeSucceeded === "boolean" ? !record.event.state.nativeSucceeded : record.event.isError } : undefined;
68
+ }
69
+ export function snapshotDefinition(snapshot) {
70
+ if (!snapshot.snapshotId)
71
+ throw new Error("A snapshot capture must have an identity before publication.");
72
+ return {
73
+ id: snapshot.snapshotId,
74
+ refs: Object.fromEntries(snapshot.refIds.map(id => [id, snapshot.refs?.[id] ?? {}])),
75
+ target: snapshot.target,
76
+ generation: snapshot.generation,
77
+ };
78
+ }
79
+ export function snapshotFromDefinition(definition) {
80
+ if (!definition || typeof definition.id !== "string" || definition.id.length === 0 || !isRecord(definition.refs)
81
+ || definition.target !== undefined && normalizeSessionTabTarget(definition.target) === undefined
82
+ || definition.generation !== undefined && typeof definition.generation !== "string"
83
+ || !Object.entries(definition.refs).every(([id, ref]) => /^e\d+$/.test(id) && isRecord(ref)
84
+ && (ref.role === undefined || typeof ref.role === "string") && (ref.name === undefined || typeof ref.name === "string")
85
+ && (ref.isEditable === undefined || typeof ref.isEditable === "boolean") && (ref.isContentEditable === undefined || typeof ref.isContentEditable === "boolean"))) {
86
+ throw new Error("Missing or invalid browser snapshot definition; take a new complete snapshot before using refs.");
87
+ }
88
+ const refs = Object.fromEntries(Object.entries(definition.refs).flatMap(([id, ref]) => typeof ref.name === "string" && typeof ref.role === "string" ? [[id, ref]] : []));
89
+ return {
90
+ snapshotId: definition.id,
91
+ refIds: Object.keys(definition.refs),
92
+ ...(Object.keys(refs).length ? { refs } : {}),
93
+ target: definition.target,
94
+ generation: definition.generation,
95
+ };
96
+ }
97
+ export function pageChange(key, page, previousSnapshotId) {
98
+ return {
99
+ key, target: page.tabTarget, unknown: page.tabTargetUnknown, reopenPending: page.tabReopenPending, pinningReason: page.pinningReason,
100
+ refs: page.tabTargetUnknown ? { kind: "unknown", invalidation: page.refSnapshotInvalidation }
101
+ : page.refSnapshot && !targetsMatch(page.refSnapshot.target, page.tabTarget) ? { kind: "invalidate", invalidation: buildPageTransitionRefSnapshotInvalidation(`The saved refs came from a snapshot for ${page.refSnapshot.target?.url}; the current session target is ${page.tabTarget?.url}. Take a fresh snapshot before using page-scoped refs.`) }
102
+ : page.refSnapshot?.snapshotId ? { kind: page.refSnapshot.snapshotId === previousSnapshotId ? "reuse" : "replace", snapshotId: page.refSnapshot.snapshotId }
103
+ : { kind: "invalidate", invalidation: page.refSnapshotInvalidation },
104
+ };
105
+ }
106
+ export function artifactChanges(previous, next) {
107
+ if (previous === next)
108
+ return undefined;
109
+ const before = new Map((previous?.entries ?? []).map(row => [getSessionArtifactManifestEntryKey(row), row]));
110
+ const after = new Map((next?.entries ?? []).map(row => [getSessionArtifactManifestEntryKey(row), row]));
111
+ const upserts = [...after].filter(([key, row]) => JSON.stringify(before.get(key)) !== JSON.stringify(row)).map(([, row]) => redactSensitiveValue(row));
112
+ const removals = [...before.keys()].filter(key => !after.has(key));
113
+ return upserts.length || removals.length ? { upserts, removals, maxEntries: next?.maxEntries ?? previous?.maxEntries ?? 100, updatedAtMs: next?.updatedAtMs ?? Date.now() } : undefined;
114
+ }
115
+ export function applyArtifactChanges(previous, changes) {
116
+ if (!changes)
117
+ return previous;
118
+ const rows = new Map((previous?.entries ?? []).map(row => [getSessionArtifactManifestEntryKey(row), row]));
119
+ for (const key of changes.removals)
120
+ rows.delete(key);
121
+ for (const row of changes.upserts)
122
+ rows.set(getSessionArtifactManifestEntryKey(row), row);
123
+ const entries = [...rows.values()].sort((a, b) => (b.evictedAtMs ?? b.createdAtMs) - (a.evictedAtMs ?? a.createdAtMs)
124
+ || Number(isPendingRecordingArtifact(b)) - Number(isPendingRecordingArtifact(a)) || a.path.localeCompare(b.path)).slice(0, changes.maxEntries);
125
+ const manifest = { version: 1, entries, maxEntries: changes.maxEntries, updatedAtMs: changes.updatedAtMs,
126
+ liveCount: entries.filter(row => row.retentionState === "live").length, evictedCount: entries.filter(row => row.retentionState === "evicted").length };
127
+ if (!isSessionArtifactManifest(manifest))
128
+ throw new Error("Invalid browser artifact changes; inspect the retained journal.");
129
+ return entries.length ? manifest : undefined;
130
+ }
131
+ export function appendBrowserTransition(pi, record) {
132
+ pi.appendEntry(BROWSER_TRANSITION_ENTRY, record);
33
133
  }
@@ -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("; ")}.`