pi-agent-browser-native 0.8.2 → 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 +23 -0
  2. package/README.md +16 -7
  3. package/dist/extensions/agent-browser/index.js +430 -157
  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 +12 -23
  22. package/dist/extensions/agent-browser/lib/web-search.js +0 -8
  23. package/docs/ARCHITECTURE.md +13 -11
  24. package/docs/COMMAND_REFERENCE.md +9 -7
  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 +17 -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,418 @@
1
+ import { constants, openSync, closeSync, fstatSync } from "node:fs";
2
+ import { open, stat } from "node:fs/promises";
3
+ import { Readable, Writable } from "node:stream";
4
+ import { pipeline } from "node:stream/promises";
5
+ import parser from "stream-json/parser.js";
6
+ import { BROWSER_TRANSITION_ENTRY, BROWSER_RESULT_TOOLS, applyArtifactChanges, getBrowserRecord, snapshotFromDefinition } from "./browser-transcript.js";
7
+ import { isRecord } from "./parsing.js";
8
+ const CHUNK_BYTES = 64 * 1024;
9
+ const ENVELOPE_MAX_BYTES = 16 * 1024 * 1024;
10
+ function selectedPath(path, fields) {
11
+ const matches = (field, key) => field === key || field === "*" && typeof key === "number";
12
+ return fields.some(field => field.every((key, index) => matches(key, path[index])) || path.every((key, index) => matches(field[index], key)));
13
+ }
14
+ /** Validate discarded values too; the parser never packs their strings or numbers. */
15
+ export async function projectJson(input, fields, maxBytes = ENVELOPE_MAX_BYTES) {
16
+ async function* decode() {
17
+ const decoder = new TextDecoder("utf-8", { fatal: true });
18
+ for await (const chunk of input)
19
+ yield decoder.decode(chunk, { stream: true });
20
+ const tail = decoder.decode();
21
+ if (tail)
22
+ yield tail;
23
+ }
24
+ const stack = [];
25
+ let root;
26
+ let literal;
27
+ let inKey = false;
28
+ let keyText = "";
29
+ let bytes = 0;
30
+ const reserve = (size) => {
31
+ bytes += size;
32
+ if (bytes > maxBytes)
33
+ throw new Error("Selected journal envelope exceeds its bounded read size.");
34
+ };
35
+ const path = () => stack.length ? [...stack.at(-1).path, stack.at(-1).key ?? stack.at(-1).index] : [];
36
+ const excluded = () => stack.length > 0 && stack.at(-1).value === undefined;
37
+ const put = (value) => {
38
+ const parent = stack.at(-1);
39
+ if (!parent)
40
+ root = value;
41
+ else {
42
+ if (parent.value && value !== undefined) {
43
+ if (Array.isArray(parent.value))
44
+ parent.value[parent.index] = value;
45
+ else
46
+ Object.defineProperty(parent.value, parent.key, { value, writable: true, enumerable: true, configurable: true });
47
+ }
48
+ parent.key = undefined;
49
+ parent.index += 1;
50
+ }
51
+ };
52
+ const consume = (token) => {
53
+ if (token.name === "startKey") {
54
+ inKey = true;
55
+ keyText = "";
56
+ return;
57
+ }
58
+ if (token.name === "endKey") {
59
+ inKey = false;
60
+ stack.at(-1).key = keyText;
61
+ return;
62
+ }
63
+ if (inKey) {
64
+ if (token.name === "stringChunk" && !excluded()) {
65
+ const wholeContainer = fields.some(field => field.every((key, index) => stack.at(-1).path[index] === key || key === "*" && typeof stack.at(-1).path[index] === "number"));
66
+ if (wholeContainer || keyText.length <= 1024) {
67
+ if (wholeContainer)
68
+ reserve(Buffer.byteLength(token.value));
69
+ keyText += token.value;
70
+ }
71
+ }
72
+ return;
73
+ }
74
+ if (token.name === "startObject" || token.name === "startArray") {
75
+ if (stack.length >= 1024)
76
+ throw new Error("Journal JSON nesting exceeds 1024 levels.");
77
+ // Excluded containers cannot contain a requested descendant. Validate their tokens
78
+ // without allocating a path/key for each ref in an old repeated map.
79
+ const currentPath = excluded() ? [] : path();
80
+ const selected = !excluded() && selectedPath(currentPath, fields);
81
+ if (selected)
82
+ reserve(32);
83
+ stack.push({ path: currentPath, index: 0, value: selected ? token.name === "startObject" ? {} : [] : undefined });
84
+ }
85
+ else if (token.name === "endObject" || token.name === "endArray")
86
+ put(stack.pop().value);
87
+ else if (token.name === "startString" || token.name === "startNumber")
88
+ literal = { value: "", selected: !excluded() && selectedPath(path(), fields), number: token.name === "startNumber" };
89
+ else if (token.name === "stringChunk" || token.name === "numberChunk") {
90
+ if (literal?.selected) {
91
+ reserve(Buffer.byteLength(token.value));
92
+ literal.value += token.value;
93
+ }
94
+ }
95
+ else if (token.name === "endString" || token.name === "endNumber") {
96
+ put(literal?.selected ? literal.number ? Number(literal.value) : literal.value : undefined);
97
+ literal = undefined;
98
+ }
99
+ else if (token.name === "trueValue" || token.name === "falseValue" || token.name === "nullValue") {
100
+ const selected = !excluded() && selectedPath(path(), fields);
101
+ if (selected)
102
+ reserve(8);
103
+ put(selected ? token.name === "nullValue" ? null : token.name === "trueValue" : undefined);
104
+ }
105
+ };
106
+ const sink = new Writable({ objectMode: true, write(token, _encoding, done) {
107
+ try {
108
+ consume(token);
109
+ done();
110
+ }
111
+ catch (error) {
112
+ done(error);
113
+ }
114
+ } });
115
+ await pipeline(Readable.from(decode()), parser.asStream({ packValues: false, streamValues: true }), sink);
116
+ return isRecord(root) ? root : undefined;
117
+ }
118
+ export async function* readRange(file, range) {
119
+ let position = range.offset;
120
+ const end = range.offset + range.length;
121
+ while (position < end) {
122
+ const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, end - position));
123
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, position);
124
+ if (!bytesRead)
125
+ throw new Error("Journal changed or truncated during read.");
126
+ position += bytesRead;
127
+ yield buffer.subarray(0, bytesRead);
128
+ }
129
+ }
130
+ /** Byte framing, not readline: even a single discarded value may exceed V8's string limit. */
131
+ export async function* journalRanges(file, end, sealed = false, offset = 0) {
132
+ let position = offset;
133
+ let start = offset;
134
+ let nonblank = false;
135
+ while (position < end) {
136
+ const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, end - position));
137
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, position);
138
+ if (!bytesRead)
139
+ throw new Error("Journal truncated during read.");
140
+ for (let index = 0; index < bytesRead; index++) {
141
+ const byte = buffer[index];
142
+ if (byte === 10) {
143
+ if (nonblank)
144
+ yield { offset: start, length: position + index + 1 - start };
145
+ start = position + index + 1;
146
+ nonblank = false;
147
+ }
148
+ else if (![9, 13, 32].includes(byte))
149
+ nonblank = true;
150
+ }
151
+ position += bytesRead;
152
+ }
153
+ if (nonblank && sealed)
154
+ yield { offset: start, length: end - start };
155
+ }
156
+ const STRUCTURAL_FIELDS = [["type"], ["id"], ["parentId"], ["timestamp"], ["version"], ["customType"], ["data", "event", "version"], ["data", "snapshot", "id"], ["message", "toolName"], ["message", "details", "browserEventVersion"], ["message", "details", "sessionName"], ["message", "details", "artifactManifest", "version"], ["message", "details", "electron", "action"]];
157
+ const REPLAY_CUSTOM_TYPES = new Set([BROWSER_TRANSITION_ENTRY, "agent-browser-recording-reservation", "agent-browser-script-session"]);
158
+ export async function scanJournalMetadata(file, end, sealed = false) {
159
+ const entries = [];
160
+ for await (const range of journalRanges(file, end, sealed)) {
161
+ const value = await projectJson(readRange(file, range), STRUCTURAL_FIELDS);
162
+ if (!value)
163
+ throw new Error(`Expected a journal object at byte ${range.offset}.`);
164
+ entries.push({ ...range, value });
165
+ }
166
+ return entries;
167
+ }
168
+ function branchEntries(entries, leaf) {
169
+ const byId = new Map();
170
+ for (const entry of entries) {
171
+ const id = entry.value.id;
172
+ if (typeof id !== "string" || byId.has(id))
173
+ throw new Error("Journal has a missing or duplicate native entry identity.");
174
+ byId.set(id, entry);
175
+ }
176
+ const branch = [];
177
+ const seen = new Set();
178
+ while (leaf !== null) {
179
+ if (seen.has(leaf))
180
+ throw new Error("Journal ancestry contains a cycle.");
181
+ seen.add(leaf);
182
+ const entry = byId.get(leaf);
183
+ if (!entry)
184
+ throw new Error(`Selected journal entry ${leaf} is not persisted; browser work requires a committed conversation.`);
185
+ branch.push(entry);
186
+ if (entry.value.parentId !== null && typeof entry.value.parentId !== "string")
187
+ throw new Error("Invalid journal parent identity.");
188
+ leaf = entry.value.parentId;
189
+ }
190
+ return branch.reverse();
191
+ }
192
+ export async function readBrowserEntries(manager, physical = false) {
193
+ const inMemory = () => {
194
+ const entries = physical ? manager.getEntries() : [];
195
+ if (!physical) {
196
+ const seen = new Set();
197
+ let leaf = manager.getLeafId();
198
+ while (leaf !== null) {
199
+ if (seen.has(leaf))
200
+ throw new Error("In-memory journal ancestry contains a cycle.");
201
+ seen.add(leaf);
202
+ const entry = manager.getEntry(leaf);
203
+ if (!entry || entry.parentId !== null && typeof entry.parentId !== "string")
204
+ throw new Error("In-memory journal ancestry is incomplete.");
205
+ entries.push(entry);
206
+ leaf = entry.parentId;
207
+ }
208
+ entries.reverse();
209
+ }
210
+ for (const entry of entries) {
211
+ if (isRecord(entry) && entry.type === "custom" && [BROWSER_TRANSITION_ENTRY, "agent-browser-script-session"].includes(String(entry.customType)) && !getBrowserRecord(entry)) {
212
+ throw new Error("Legacy browser records require stopped separate-copy conversion before replay.");
213
+ }
214
+ if (isRecord(entry) && entry.type === "message" && isRecord(entry.message) && BROWSER_RESULT_TOOLS.has(String(entry.message.toolName))
215
+ && isRecord(entry.message.details) && entry.message.details.browserEventVersion !== 1 && !getBrowserRecord(entry)) {
216
+ throw new Error("Legacy browser observations require stopped separate-copy conversion before replay.");
217
+ }
218
+ }
219
+ return entries;
220
+ };
221
+ const path = manager.getSessionFile();
222
+ if (!path) {
223
+ // Public in-memory ancestry is the only fallback. No writable SessionManager is constructed.
224
+ return inMemory();
225
+ }
226
+ const leaf = physical ? null : manager.getLeafId();
227
+ let file;
228
+ try {
229
+ file = await open(path, constants.O_RDONLY);
230
+ }
231
+ catch (error) {
232
+ if (error.code === "ENOENT")
233
+ return inMemory();
234
+ throw error;
235
+ }
236
+ try {
237
+ const captured = await file.stat();
238
+ if (!captured.isFile())
239
+ throw new Error("Pi session journal is not a regular file.");
240
+ const metadata = await scanJournalMetadata(file, captured.size);
241
+ const header = metadata.find(entry => entry.value.type === "session");
242
+ if (header?.value.id !== (manager.getHeader()?.id ?? manager.getSessionId()))
243
+ throw new Error("Pi session journal identity does not match the active session.");
244
+ const publicManager = manager;
245
+ 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
+ const projected = [];
253
+ const definitions = new Map();
254
+ for (const entry of selected) {
255
+ const { value } = entry;
256
+ const definition = isRecord(value.data) && isRecord(value.data.snapshot) ? value.data.snapshot.id : undefined;
257
+ if (typeof definition === "string") {
258
+ if (definitions.has(definition))
259
+ throw new Error("Browser snapshot identity is defined more than once on this ancestry.");
260
+ definitions.set(definition, entry);
261
+ }
262
+ const legacy = value.type === "custom" && (value.customType === BROWSER_TRANSITION_ENTRY || value.customType === "agent-browser-script-session")
263
+ && !(isRecord(value.data) && isRecord(value.data.event) && value.data.event.version === 1);
264
+ const message = isRecord(value.message) ? value.message : undefined;
265
+ const legacyResult = message && typeof message.toolName === "string" && BROWSER_RESULT_TOOLS.has(message.toolName)
266
+ && (!isRecord(message.details) || message.details.browserEventVersion !== 1)
267
+ && !(isRecord(value.data) && isRecord(value.data.event) && value.data.event.version === 1)
268
+ && isRecord(message.details) && (message.details.sessionName !== undefined || message.details.artifactManifest !== undefined || message.details.electron !== undefined);
269
+ if (legacy || legacyResult)
270
+ throw new Error("This session contains legacy browser records. Convert a stopped separate copy with npm exec --package pi-agent-browser-native -- pi-agent-browser-convert before resuming browser work; the original journal is retained.");
271
+ if (value.type === "custom" && REPLAY_CUSTOM_TYPES.has(String(value.customType)) || isRecord(value.data) && isRecord(value.data.event) || definition !== undefined) {
272
+ const fields = value.customType === BROWSER_TRANSITION_ENTRY || value.type === "message" ? [["data", "event"], ["data", "snapshot", "id"]] : [["data"]];
273
+ const body = await projectJson(readRange(file, entry), fields);
274
+ projected.push({ ...value, ...body });
275
+ }
276
+ }
277
+ // Resolve only the definitions that actually win on this ancestry. An unresolved begin
278
+ // retains an inaccessible candidate ID; it does not request historical ref payloads.
279
+ const { SessionPageState } = await import("./session-page-state.js");
280
+ const reduced = SessionPageState.fromBranch(projected);
281
+ const winners = new Set((physical ? [] : [...reduced.views().values()]).flatMap(page => page.refSnapshot?.snapshotId ? [page.refSnapshot.snapshotId] : []));
282
+ for (const id of winners) {
283
+ const entry = definitions.get(id);
284
+ if (!entry)
285
+ throw new Error(`Browser snapshot ${id} has no ancestral definition; inspect and take a fresh snapshot.`);
286
+ // ponytail: a requested individual snapshot must fit its consumer's memory. Metadata
287
+ // 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);
291
+ const destination = projected.find(candidate => isRecord(candidate) && candidate.id === entry.value.id);
292
+ if (!isRecord(destination) || !isRecord(destination.data) || !isRecord(body?.data))
293
+ throw new Error("Winning browser snapshot could not be read.");
294
+ destination.data.snapshot = body.data.snapshot;
295
+ if (!isRecord(body.data.snapshot) || body.data.snapshot.id !== id)
296
+ throw new Error("Winning browser snapshot identity is invalid.");
297
+ snapshotFromDefinition(body.data.snapshot);
298
+ }
299
+ let manifest;
300
+ for (const entry of projected)
301
+ manifest = applyArtifactChanges(manifest, getBrowserRecord(entry)?.event.artifacts);
302
+ const current = await file.stat();
303
+ const currentPath = await stat(path);
304
+ if (current.dev !== captured.dev || current.ino !== captured.ino || current.size < captured.size || currentPath.dev !== captured.dev || currentPath.ino !== captured.ino)
305
+ throw new Error("Pi journal replaced or truncated during replay.");
306
+ return projected;
307
+ }
308
+ finally {
309
+ await file.close();
310
+ }
311
+ }
312
+ /** A filename is not durability evidence on official Pi before its first conversation. */
313
+ export function requirePublishedBrowserJournal(manager) {
314
+ const path = manager.getSessionFile();
315
+ if (!path)
316
+ throw new Error("Browser code requires a published Pi conversation; relaunch without --no-session.");
317
+ let fd;
318
+ try {
319
+ fd = openSync(path, constants.O_RDONLY);
320
+ const stat = fstatSync(fd);
321
+ if (!stat.isFile() || stat.size === 0)
322
+ throw new Error("Pi conversation has not been published yet.");
323
+ }
324
+ catch (error) {
325
+ throw new Error("Browser work requires an actually published Pi conversation before dispatch; send a user message first.", { cause: error });
326
+ }
327
+ finally {
328
+ if (fd !== undefined)
329
+ closeSync(fd);
330
+ }
331
+ }
332
+ /** Observation-only artifact receipts are optional until Pi publishes its journal. */
333
+ export function hasPublishedBrowserJournal(manager) {
334
+ try {
335
+ requirePublishedBrowserJournal(manager);
336
+ return true;
337
+ }
338
+ catch {
339
+ return false;
340
+ }
341
+ }
342
+ /** Capture before waiting: native selection precedes sequential session_tree handlers. */
343
+ export function captureBrowserBranch(manager, isGenerationCurrent) {
344
+ const sessionId = manager.getSessionId();
345
+ const branch = { sessionId, anchorId: manager.getLeafId(), isCurrent() {
346
+ if (!isGenerationCurrent() || manager.getSessionId() !== sessionId)
347
+ return false;
348
+ let leaf = manager.getLeafId();
349
+ // Empty selection is not a universal ancestor of other independent roots.
350
+ let current = branch.anchorId === null && leaf === null;
351
+ const seen = new Set();
352
+ while (leaf !== null) {
353
+ if (typeof leaf !== "string" || seen.has(leaf))
354
+ throw new Error("Selected Pi journal ancestry is invalid or cyclic.");
355
+ seen.add(leaf);
356
+ const entry = manager.getEntry(leaf);
357
+ if (!entry || entry.id !== leaf || entry.parentId !== null && typeof entry.parentId !== "string")
358
+ throw new Error("Selected Pi journal ancestry is incomplete.");
359
+ if (leaf === branch.anchorId)
360
+ current = true;
361
+ leaf = entry.parentId;
362
+ }
363
+ return current;
364
+ } };
365
+ return branch;
366
+ }
367
+ /** A withdrawn branch returns false; required publication faults still throw. */
368
+ export async function appendBrowserRecord(manager, append, expected, branch) {
369
+ if (!manager.getSessionFile()) {
370
+ if (!branch.isCurrent())
371
+ return false;
372
+ append();
373
+ if (branch.anchorId === null)
374
+ branch.anchorId = manager.getLeafId();
375
+ return branch.isCurrent();
376
+ }
377
+ requirePublishedBrowserJournal(manager);
378
+ const path = manager.getSessionFile();
379
+ const beforeFile = await open(path, constants.O_RDONLY);
380
+ let file;
381
+ try {
382
+ const before = await beforeFile.stat();
383
+ // Native navigation changes the leaf before its awaited session_tree handlers.
384
+ if (!branch.isCurrent())
385
+ return false;
386
+ append();
387
+ const nativeId = manager.getLeafId();
388
+ if (branch.anchorId === null)
389
+ branch.anchorId = nativeId;
390
+ // Native repair can flush several accepted records or replace the journal.
391
+ // Reopen its published locator and frame records instead of parsing the suffix as one value.
392
+ file = await open(path, constants.O_RDONLY);
393
+ const after = await file.stat();
394
+ const sameFile = after.dev === before.dev && after.ino === before.ino;
395
+ if (!after.isFile() || sameFile && after.size <= before.size)
396
+ throw new Error("Pi did not publish the browser event.");
397
+ let matches = 0;
398
+ const fields = [["type"], ["id"], ["customType"], ["data", "event", "version"], ["data", "event", "operationId"], ["data", "event", "phase"], ["data", "event", "toolCallId"], ["data", "event", "commandIndex"]];
399
+ for await (const range of journalRanges(file, after.size, false, sameFile ? before.size : 0)) {
400
+ const value = await projectJson(readRange(file, range), fields);
401
+ const event = isRecord(value?.data) && isRecord(value.data.event) ? value.data.event : undefined;
402
+ if (value?.type === "session" && value.id !== (manager.getHeader()?.id ?? manager.getSessionId()))
403
+ throw new Error("Pi repaired journal identity differs from the active session.");
404
+ if (value?.id === nativeId && value?.type === "custom" && value.customType === BROWSER_TRANSITION_ENTRY
405
+ && event?.version === 1 && event.operationId === expected.event.operationId && event.phase === expected.event.phase
406
+ && event.toolCallId === expected.event.toolCallId && event.commandIndex === expected.event.commandIndex)
407
+ matches += 1;
408
+ }
409
+ const current = await stat(path);
410
+ if (matches !== 1 || current.dev !== after.dev || current.ino !== after.ino || current.size < after.size)
411
+ throw new Error("Pi browser append has no matching published journal receipt.");
412
+ }
413
+ finally {
414
+ await file?.close();
415
+ await beforeFile.close();
416
+ }
417
+ return branch.isCurrent();
418
+ }