pi-agent-browser-native 0.6.9 → 0.6.11

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 (54) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +62 -19
  3. package/dist/extensions/agent-browser/index.js +424 -451
  4. package/dist/extensions/agent-browser/lib/argv-descriptor.js +6 -7
  5. package/dist/extensions/agent-browser/lib/argv-grammar.js +7 -1
  6. package/dist/extensions/agent-browser/lib/batch-lifecycle.js +4 -8
  7. package/dist/extensions/agent-browser/lib/command-policy.js +41 -2
  8. package/dist/extensions/agent-browser/lib/command-taxonomy.js +15 -2
  9. package/dist/extensions/agent-browser/lib/input-modes/params.js +1 -1
  10. package/dist/extensions/agent-browser/lib/input-modes/script.js +3 -2
  11. package/dist/extensions/agent-browser/lib/managed-session-restore.js +42 -12
  12. package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +3 -5
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +6 -14
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +14 -25
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +12 -6
  16. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +1 -0
  17. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/wait-timeouts.js +3 -2
  18. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +38 -31
  19. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +60 -20
  20. package/dist/extensions/agent-browser/lib/orchestration/browser-run/recording-recovery.js +161 -0
  21. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +5 -5
  22. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +2 -4
  23. package/dist/extensions/agent-browser/lib/orchestration/native-session-defaults.js +68 -0
  24. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +41 -6
  25. package/dist/extensions/agent-browser/lib/page-target-validation.js +9 -5
  26. package/dist/extensions/agent-browser/lib/playbook.js +13 -12
  27. package/dist/extensions/agent-browser/lib/process-environment.js +26 -8
  28. package/dist/extensions/agent-browser/lib/process.js +8 -5
  29. package/dist/extensions/agent-browser/lib/read-confirmation.js +59 -0
  30. package/dist/extensions/agent-browser/lib/recording-reservations.js +11 -1
  31. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +8 -0
  32. package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +6 -5
  33. package/dist/extensions/agent-browser/lib/results/categories.js +4 -2
  34. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +76 -57
  35. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +19 -8
  36. package/dist/extensions/agent-browser/lib/results/presentation/common.js +5 -25
  37. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +40 -38
  38. package/dist/extensions/agent-browser/lib/results/presentation/errors.js +1 -0
  39. package/dist/extensions/agent-browser/lib/results/presentation/navigation.js +3 -3
  40. package/dist/extensions/agent-browser/lib/results/presentation.js +38 -9
  41. package/dist/extensions/agent-browser/lib/results/recording.js +50 -0
  42. package/dist/extensions/agent-browser/lib/runtime.js +72 -20
  43. package/dist/extensions/agent-browser/lib/session-page-state.js +24 -8
  44. package/dist/extensions/agent-browser/lib/temp.js +4 -0
  45. package/dist/scripts/agent-browser-target.mjs +1 -1
  46. package/docs/ARCHITECTURE.md +29 -12
  47. package/docs/COMMAND_REFERENCE.md +67 -31
  48. package/docs/RELEASE.md +6 -4
  49. package/docs/SUPPORT_MATRIX.md +24 -16
  50. package/docs/TOOL_CONTRACT.md +82 -28
  51. package/package.json +1 -1
  52. package/scripts/agent-browser-capability-baseline.mjs +10 -3
  53. package/scripts/agent-browser-target.mjs +1 -1
  54. package/scripts/prepare.mjs +2 -4
@@ -4,7 +4,8 @@ export function isPendingRecordingCommand(command, subcommand, kind) {
4
4
  return command === "record" && (subcommand === "start" || subcommand === "restart") && kind === "video";
5
5
  }
6
6
  export function isPendingRecordingArtifact(artifact) {
7
- return isPendingRecordingCommand(artifact.command, artifact.subcommand, artifact.kind);
7
+ return artifact.recordingState === "openRecording" || artifact.status === "pending"
8
+ || (artifact.status === undefined && isPendingRecordingCommand(artifact.command, artifact.subcommand, artifact.kind));
8
9
  }
9
10
  export const SESSION_ARTIFACT_MANIFEST_VERSION = 1;
10
11
  export const SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES_ENV = "PI_AGENT_BROWSER_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES";
@@ -98,10 +99,10 @@ export function retirePendingRecordingManifestEntries(manifest, sessionName, nam
98
99
  || !entry.session
99
100
  || getAgentBrowserSessionIdentityKey(entry.session, entry.namespace) !== sessionKey
100
101
  || entry.kind !== "video"
101
- || !isPendingRecordingCommand(entry.command, entry.subcommand, entry.kind))
102
+ || !isPendingRecordingArtifact(entry))
102
103
  return entry;
103
104
  changed = true;
104
- return { ...entry, retentionState: "missing", subcommand: "close-abandoned" };
105
+ return { ...entry, recordingState: undefined, status: "unverified", subcommand: "close-abandoned" };
105
106
  });
106
107
  if (!changed)
107
108
  return manifest;
@@ -135,7 +136,7 @@ export function mergeSessionArtifactManifest(options) {
135
136
  if (candidateKey !== key
136
137
  && sameRecordingSession
137
138
  && candidate.kind === "video"
138
- && isPendingRecordingCommand(candidate.command, candidate.subcommand, candidate.kind)) {
139
+ && isPendingRecordingArtifact(candidate)) {
139
140
  byPath.delete(candidateKey);
140
141
  }
141
142
  }
@@ -155,7 +156,7 @@ export function mergeSessionArtifactManifest(options) {
155
156
  const leftTime = left.evictedAtMs ?? left.createdAtMs;
156
157
  const rightTime = right.evictedAtMs ?? right.createdAtMs;
157
158
  return rightTime - leftTime
158
- || Number(isPendingRecordingCommand(right.command, right.subcommand, right.kind)) - Number(isPendingRecordingCommand(left.command, left.subcommand, left.kind))
159
+ || Number(isPendingRecordingArtifact(right)) - Number(isPendingRecordingArtifact(left))
159
160
  || left.path.localeCompare(right.path);
160
161
  })
161
162
  .slice(0, maxEntries);
@@ -1,14 +1,16 @@
1
1
  import { isPendingRecordingArtifact } from "./artifact-manifest.js";
2
2
  function hasUnverifiedFileArtifact(artifacts) {
3
- return (artifacts ?? []).some((artifact) => !isPendingRecordingArtifact(artifact) && artifact.exists !== true);
3
+ return (artifacts ?? []).some((artifact) => !isPendingRecordingArtifact(artifact) && (artifact.exists !== true || ["failed", "stale", "unverified"].includes(artifact.status ?? "")));
4
4
  }
5
5
  export function classifyAgentBrowserSuccessCategory(options) {
6
6
  if (options.inspection)
7
7
  return "inspection";
8
+ if (hasUnverifiedFileArtifact(options.artifacts))
9
+ return "artifact-unverified";
8
10
  if ((options.artifacts ?? []).some(isPendingRecordingArtifact))
9
11
  return "artifact-pending";
10
12
  if ((options.artifacts ?? []).length > 0)
11
- return hasUnverifiedFileArtifact(options.artifacts) ? "artifact-unverified" : "artifact-saved";
13
+ return "artifact-saved";
12
14
  if (options.savedFile)
13
15
  return "artifact-saved";
14
16
  return "completed";
@@ -3,9 +3,9 @@ import { extname, resolve } from "node:path";
3
3
  import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
4
4
  import { getExplicitArtifactDestination } from "../../orchestration/browser-run/artifact-paths.js";
5
5
  import { isRecord, parsePositiveInteger } from "../../parsing.js";
6
- import { extractUpstreamCommandTokens } from "../../runtime.js";
7
6
  import { formatSessionArtifactRetentionSummary, getSessionArtifactManifestEntryKey, isPendingRecordingArtifact, isPendingRecordingCommand, mergeSessionArtifactManifest, } from "../artifact-manifest.js";
8
7
  import { classifyAgentBrowserSuccessCategory } from "../categories.js";
8
+ import { formatRecordingReceipt, getRecordingReceipt } from "../recording.js";
9
9
  const PNG_HEADER = Buffer.from("89504e470d0a1a0a0000000d49484452", "hex");
10
10
  const INLINE_IMAGE_MAX_BYTES_ENV = "PI_AGENT_BROWSER_INLINE_IMAGE_MAX_BYTES";
11
11
  const DEFAULT_INLINE_IMAGE_MAX_BYTES = 5 * 1_024 * 1_024;
@@ -160,24 +160,26 @@ async function buildFileArtifactMetadata(options) {
160
160
  const absolutePath = options.artifactRequest?.absolutePath ?? resolve(options.cwd, options.path);
161
161
  const displayPath = options.artifactRequest?.path ?? options.path;
162
162
  const extension = extname(absolutePath || options.path).toLowerCase() || undefined;
163
- const pendingRecording = isPendingRecordingCommand(options.commandInfo.command, options.commandInfo.subcommand, kind);
163
+ const pendingRecording = options.recordingPending === true || (isPendingRecordingCommand(options.commandInfo.command, options.commandInfo.subcommand, kind) && options.recordingOutcome !== false && options.recording?.success !== false);
164
+ const captureStartedAtMs = options.recording?.capture.startedAt ? Date.parse(options.recording.capture.startedAt) : NaN;
165
+ const recordingStartedAtMs = Number.isFinite(captureStartedAtMs) ? captureStartedAtMs : options.artifactMinUpdatedAtMs;
164
166
  let exists;
165
167
  let sizeBytes;
166
168
  let mediaType;
167
169
  let stale = false;
168
170
  let updatedAtMs;
169
- if (!pendingRecording) {
171
+ if (!pendingRecording || options.commandInfo.subcommand === "stop") {
170
172
  try {
171
173
  const fileStats = await stat(absolutePath);
172
- exists = true;
174
+ exists = fileStats.isFile();
173
175
  sizeBytes = fileStats.size;
174
176
  updatedAtMs = fileStats.mtimeMs;
175
177
  mediaType = fileStats.isFile() ? await getFileImageMimeType(absolutePath) : undefined;
176
178
  const commandCreatesArtifact = !(options.commandInfo.command === "wait" && isDownloadWaitSubcommand(options.commandInfo.subcommand));
177
- stale = commandCreatesArtifact && artifactMtimeIsOutsideCommandWindow(updatedAtMs, options.artifactMinUpdatedAtMs, options.artifactMaxUpdatedAtMs);
179
+ stale = commandCreatesArtifact && artifactMtimeIsOutsideCommandWindow(updatedAtMs, kind === "video" ? recordingStartedAtMs : options.artifactMinUpdatedAtMs, options.artifactMaxUpdatedAtMs);
178
180
  }
179
- catch {
180
- exists = false;
181
+ catch (error) {
182
+ exists = ["ENOENT", "ENOTDIR"].includes(error.code ?? "") ? false : undefined;
181
183
  }
182
184
  }
183
185
  return {
@@ -191,11 +193,17 @@ async function buildFileArtifactMetadata(options) {
191
193
  mediaType,
192
194
  namespace: options.namespace,
193
195
  path: displayPath,
196
+ recording: options.recording,
197
+ recordingStartedAtMs: kind === "video" ? recordingStartedAtMs : undefined,
194
198
  recordingState: pendingRecording ? "openRecording" : undefined,
195
- requestedPath: options.artifactRequest?.path ?? getExplicitArtifactDestination(extractUpstreamCommandTokens(options.commandInfo.commandTokens ?? [])),
199
+ requestedPath: options.artifactRequest?.path ?? getExplicitArtifactDestination(options.commandInfo.commandTokens ?? []),
196
200
  session: options.sessionName,
197
201
  sizeBytes,
198
- status: pendingRecording ? "pending" : exists === false ? "missing" : stale ? "stale" : options.artifactRequest?.status ?? "saved",
202
+ status: pendingRecording ? "pending" : exists !== true ? exists === false ? "missing" : "unverified" : stale ? "stale"
203
+ : kind === "video" && (options.recording?.success === false || options.recording?.output.encoderSucceeded === false || (options.recordingOutcome === false && options.recording?.success !== null)) ? "failed"
204
+ : kind === "video" && ((options.recording?.success !== true && options.recordingOutcome !== true)
205
+ || (options.recording?.file.sizeBytes != null && options.recording.file.sizeBytes !== sizeBytes)) ? "unverified"
206
+ : options.artifactRequest?.status ?? "saved",
199
207
  subcommand: options.commandInfo.subcommand,
200
208
  tempPath: options.artifactRequest?.tempPath,
201
209
  updatedAtMs,
@@ -205,6 +213,10 @@ async function buildFileArtifactMetadata(options) {
205
213
  async function buildPreviousRestartRecordingArtifact(options) {
206
214
  if (options.commandInfo.command !== "record" || options.commandInfo.subcommand !== "restart")
207
215
  return undefined;
216
+ if (isRecord(options.data) && "previousRecording" in options.data) {
217
+ const recording = getRecordingReceipt(options.data.previousRecording);
218
+ return recording ? buildFileArtifactMetadata({ ...options, commandInfo: { command: "record", subcommand: "restart-previous" }, path: recording.path, recording }) : undefined;
219
+ }
208
220
  const sessionKey = options.sessionName ? getAgentBrowserSessionIdentityKey(options.sessionName, options.namespace) : undefined;
209
221
  const previousRecording = options.artifactManifest?.entries.find((entry) => (entry.command === "record" &&
210
222
  (entry.subcommand === "start" || entry.subcommand === "restart") &&
@@ -213,50 +225,42 @@ async function buildPreviousRestartRecordingArtifact(options) {
213
225
  if (!previousRecording)
214
226
  return undefined;
215
227
  const absolutePath = previousRecording.absolutePath ?? resolve(options.cwd, previousRecording.path);
228
+ const base = {
229
+ absolutePath,
230
+ artifactType: "video",
231
+ command: "record",
232
+ cwd: previousRecording.cwd ?? options.cwd,
233
+ extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
234
+ kind: "video",
235
+ namespace: previousRecording.namespace ?? options.namespace,
236
+ path: previousRecording.path,
237
+ requestedPath: previousRecording.requestedPath,
238
+ session: previousRecording.session ?? options.sessionName,
239
+ subcommand: "restart-previous",
240
+ };
216
241
  try {
217
242
  const fileStats = await stat(absolutePath);
218
243
  const stale = artifactMtimeIsOutsideCommandWindow(fileStats.mtimeMs, options.artifactMinUpdatedAtMs, options.artifactMaxUpdatedAtMs);
219
244
  return {
220
- absolutePath,
221
- artifactType: "video",
222
- command: "record",
223
- cwd: previousRecording.cwd ?? options.cwd,
245
+ ...base,
224
246
  exists: true,
225
- extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
226
- kind: "video",
227
247
  mediaType: fileStats.isFile() ? await getFileImageMimeType(absolutePath) : undefined,
228
- namespace: previousRecording.namespace ?? options.namespace,
229
- path: previousRecording.path,
230
- requestedPath: previousRecording.requestedPath,
231
- session: previousRecording.session ?? options.sessionName,
232
248
  sizeBytes: fileStats.size,
233
- status: stale ? "stale" : "saved",
234
- subcommand: "restart-previous",
249
+ status: stale ? "stale" : "unverified",
235
250
  updatedAtMs: fileStats.mtimeMs,
236
251
  };
237
252
  }
238
- catch {
239
- return {
240
- absolutePath,
241
- artifactType: "video",
242
- command: "record",
243
- cwd: previousRecording.cwd ?? options.cwd,
244
- exists: false,
245
- extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
246
- kind: "video",
247
- namespace: previousRecording.namespace ?? options.namespace,
248
- path: previousRecording.path,
249
- requestedPath: previousRecording.requestedPath,
250
- session: previousRecording.session ?? options.sessionName,
251
- status: "missing",
252
- subcommand: "restart-previous",
253
- };
253
+ catch (error) {
254
+ const missing = ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
255
+ return { ...base, exists: missing ? false : undefined, status: missing ? "missing" : "unverified" };
254
256
  }
255
257
  }
256
258
  export async function extractFileArtifacts(options) {
257
259
  const candidates = extractPathStrings(options.data);
258
- const currentArtifacts = (await Promise.all(candidates.map((path) => buildFileArtifactMetadata({ ...options, path })))).filter((artifact) => artifact !== undefined);
259
- const previousRestartRecordingArtifact = await buildPreviousRestartRecordingArtifact({ artifactManifest: options.artifactManifest, artifactMaxUpdatedAtMs: options.artifactMaxUpdatedAtMs, artifactMinUpdatedAtMs: options.artifactMinUpdatedAtMs, commandInfo: options.commandInfo, cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName });
260
+ const recording = options.commandInfo.command === "record" ? getRecordingReceipt(options.data, options.commandInfo.subcommand === "stop" ? options.recordingOutcome : undefined) : undefined;
261
+ const recordingPending = options.recordingPending ?? (recording?.success === null && isRecord(options.data) && isRecord(options.data.capture) && recording.capture.endedAt === null);
262
+ const currentArtifacts = (await Promise.all(candidates.map((path) => buildFileArtifactMetadata({ ...options, path, recording, recordingPending })))).filter((artifact) => artifact !== undefined);
263
+ const previousRestartRecordingArtifact = await buildPreviousRestartRecordingArtifact(options);
260
264
  return previousRestartRecordingArtifact ? [previousRestartRecordingArtifact, ...currentArtifacts] : currentArtifacts;
261
265
  }
262
266
  export function buildManifestEntriesForFileArtifacts(artifacts, nowMs = Date.now()) {
@@ -271,6 +275,10 @@ export function buildManifestEntriesForFileArtifacts(artifacts, nowMs = Date.now
271
275
  mediaType: artifact.mediaType,
272
276
  namespace: artifact.namespace,
273
277
  path: artifact.path,
278
+ recording: artifact.recording,
279
+ recordingStartedAtMs: artifact.recordingStartedAtMs,
280
+ recordingState: artifact.recordingState,
281
+ status: artifact.status,
274
282
  requestedPath: artifact.requestedPath,
275
283
  retentionState: artifact.exists === false || artifact.status === "stale" ? "missing" : "live",
276
284
  session: artifact.session,
@@ -280,10 +288,7 @@ export function buildManifestEntriesForFileArtifacts(artifacts, nowMs = Date.now
280
288
  }));
281
289
  }
282
290
  export function isManifestFileArtifact(artifact) {
283
- if (artifact.status === "stale") {
284
- return artifact.kind === "video" && artifact.command === "record" && artifact.subcommand === "restart-previous";
285
- }
286
- return artifact.kind === "video" && artifact.command === "record" ? true : !isPendingRecordingArtifact(artifact);
291
+ return artifact.kind === "video" && artifact.command === "record" ? true : artifact.status !== "stale" && !isPendingRecordingArtifact(artifact);
287
292
  }
288
293
  function getArtifactVerificationEntry(artifact) {
289
294
  if (isPendingRecordingArtifact(artifact)) {
@@ -291,7 +296,9 @@ function getArtifactVerificationEntry(artifact) {
291
296
  absolutePath: artifact.absolutePath,
292
297
  exists: artifact.exists,
293
298
  kind: artifact.kind,
294
- limitation: "Recording output is pending until record stop completes.",
299
+ limitation: "Recording output is pending until native finalization succeeds and the file is verified.",
300
+ recording: artifact.recording,
301
+ recordingStartedAtMs: artifact.recordingStartedAtMs,
295
302
  mediaType: artifact.mediaType,
296
303
  path: artifact.path,
297
304
  recordingState: artifact.recordingState ?? "openRecording",
@@ -304,7 +311,7 @@ function getArtifactVerificationEntry(artifact) {
304
311
  willExistOnStop: artifact.willExistOnStop ?? true,
305
312
  };
306
313
  }
307
- const state = artifact.status === "stale"
314
+ const state = ["failed", "stale", "unverified"].includes(artifact.status ?? "")
308
315
  ? "unverified"
309
316
  : artifact.exists === true
310
317
  ? "verified"
@@ -315,13 +322,17 @@ function getArtifactVerificationEntry(artifact) {
315
322
  absolutePath: artifact.absolutePath,
316
323
  exists: artifact.exists,
317
324
  kind: artifact.kind,
318
- limitation: artifact.status === "stale"
319
- ? "The reported path's modification time fell outside this command's bounded artifact window. Treat the artifact as stale until regenerated."
320
- : state === "missing"
321
- ? "The wrapper did not find the reported artifact at absolutePath. Treat the path as unverified until recovered or regenerated."
322
- : state === "unverified"
323
- ? "The wrapper could not prove local filesystem existence for this artifact."
324
- : undefined,
325
+ recording: artifact.recording,
326
+ recordingStartedAtMs: artifact.recordingStartedAtMs,
327
+ limitation: artifact.status === "failed" || artifact.status === "unverified"
328
+ ? "File presence does not prove successful native recording finalization or encoding. Inspect the receipt and original failure."
329
+ : artifact.status === "stale"
330
+ ? "The reported path's modification time fell outside this command's bounded artifact window. Treat the artifact as stale until regenerated."
331
+ : state === "missing"
332
+ ? "The wrapper did not find the reported artifact at absolutePath. Treat the path as unverified until recovered or regenerated."
333
+ : state === "unverified"
334
+ ? "The wrapper could not prove local filesystem existence for this artifact."
335
+ : undefined,
325
336
  mediaType: artifact.mediaType,
326
337
  path: artifact.path,
327
338
  requestedPath: artifact.requestedPath,
@@ -427,6 +438,10 @@ function formatArtifactLabel(artifact) {
427
438
  case "trace":
428
439
  return "Saved trace";
429
440
  case "video":
441
+ if (artifact.status === "failed")
442
+ return artifact.subcommand === "restart-previous" ? "Previous recording failed" : "Recording failed";
443
+ if (artifact.status === "unverified")
444
+ return artifact.subcommand === "restart-previous" ? "Previous recording unverified" : "Recording unverified";
430
445
  if (artifact.command === "record" && artifact.subcommand === "restart-previous") {
431
446
  if (artifact.status === "stale")
432
447
  return "Previous recording stale";
@@ -435,8 +450,10 @@ function formatArtifactLabel(artifact) {
435
450
  return "Previous recording saved";
436
451
  }
437
452
  if (!isPendingRecordingArtifact(artifact))
438
- return "Saved recording";
439
- return artifact.subcommand === "restart" ? "Recording restarted; output will be written on stop" : "Recording started in a fresh active page; output will be written on stop";
453
+ return artifact.status === "saved" ? "Saved recording" : "Recording reported; file not verified";
454
+ if (artifact.subcommand === "stop")
455
+ return "Recording finalization pending";
456
+ return artifact.subcommand === "restart" ? "Recording restarted; output will be written on stop" : "Recording started; output will be written on stop";
440
457
  }
441
458
  }
442
459
  export function formatArtifactSummary(artifacts) {
@@ -452,7 +469,7 @@ export function formatArtifactSummary(artifacts) {
452
469
  if (restartArtifact && previousRecordingArtifacts.length > 0) {
453
470
  return [...previousRecordingArtifacts, restartArtifact].map((artifact) => `${formatArtifactLabel(artifact)}: ${artifact.path}`).join("\n");
454
471
  }
455
- return `Saved ${artifacts.length} artifacts: ${artifacts.map((artifact) => `${artifact.kind} ${artifact.path}`).join(", ")}`;
472
+ return `${artifacts.every((artifact) => artifact.status === "saved") ? "Saved" : "Reported"} ${artifacts.length} artifacts: ${artifacts.map((artifact) => `${artifact.kind} ${artifact.path}`).join(", ")}`;
456
473
  }
457
474
  export function formatArtifactMetadataLines(artifacts) {
458
475
  return artifacts.map((artifact, index) => {
@@ -462,12 +479,13 @@ export function formatArtifactMetadataLines(artifacts) {
462
479
  `Artifact type: ${artifact.kind}`,
463
480
  artifact.requestedPath ? `Requested path: ${artifact.requestedPath}` : undefined,
464
481
  `Absolute path: ${artifact.absolutePath}`,
465
- "Exists: pending until record stop",
482
+ `Exists: ${artifact.exists ?? "pending until record stop"}`,
466
483
  `Status: ${artifact.status ?? "pending"}`,
467
484
  `Recording state: ${artifact.recordingState ?? "openRecording"}`,
468
485
  `Will exist on stop: ${artifact.willExistOnStop !== false}`,
469
486
  artifact.session ? `Session: ${artifact.session}` : undefined,
470
487
  artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
488
+ artifact.recording ? formatRecordingReceipt(artifact.recording) : undefined,
471
489
  `Machine data: details.artifacts[${index}]`,
472
490
  ].filter((item) => item !== undefined).join("\n");
473
491
  }
@@ -476,7 +494,7 @@ export function formatArtifactMetadataLines(artifacts) {
476
494
  `Artifact type: ${artifact.kind}`,
477
495
  artifact.requestedPath ? `Requested path: ${artifact.requestedPath}` : undefined,
478
496
  `Absolute path: ${artifact.absolutePath}`,
479
- `Exists: ${artifact.exists === true}`,
497
+ `Exists: ${artifact.exists ?? "unknown"}`,
480
498
  artifact.exists === false ? "not found on disk" : undefined,
481
499
  typeof artifact.sizeBytes === "number" ? `Size: ${formatByteCount(artifact.sizeBytes)}` : undefined,
482
500
  typeof artifact.sizeBytes === "number" ? `Size bytes: ${artifact.sizeBytes}` : undefined,
@@ -485,6 +503,7 @@ export function formatArtifactMetadataLines(artifacts) {
485
503
  artifact.mediaType ? `Media type: ${artifact.mediaType}` : undefined,
486
504
  artifact.session ? `Session: ${artifact.session}` : undefined,
487
505
  artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
506
+ artifact.recording ? formatRecordingReceipt(artifact.recording) : undefined,
488
507
  `Machine data: details.artifacts[${index}]`,
489
508
  ].filter((item) => item !== undefined).join("\n");
490
509
  });
@@ -1,3 +1,4 @@
1
+ import { stat } from "node:fs/promises";
1
2
  import { isCloseCommand } from "../../command-taxonomy.js";
2
3
  import { isRecord } from "../../parsing.js";
3
4
  import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
@@ -194,7 +195,7 @@ async function buildBatchStepPresentation(options) {
194
195
  const redactedCommand = command ? redactInvocationArgs(command) : undefined;
195
196
  const commandText = formatBatchStepCommand(hasModelFacingArgRedaction(redactedCommand) ? redactedCommand : command, index);
196
197
  const lifecycle = extractAgentBrowserLifecycle(item.result);
197
- if (item.success === false) {
198
+ if (item.success === false && command?.[0] !== "record") {
198
199
  const redactedErrorData = redactBatchStepErrorData(command, item.error);
199
200
  const errorText = formatBatchStepError(redactedErrorData);
200
201
  const failureCategory = classifyAgentBrowserFailureCategory({
@@ -252,7 +253,9 @@ async function buildBatchStepPresentation(options) {
252
253
  commandInfo: commandInfoWithTokens,
253
254
  cwd,
254
255
  args: command,
255
- envelope: { data: item.result, success: true },
256
+ envelope: { data: item.result, success: item.success !== false, error: item.error },
257
+ errorText: item.success === false ? formatBatchStepError(redactBatchStepErrorData(command, item.error)) : undefined,
258
+ piCleanupOwnership: options.piCleanupOwnership,
256
259
  networkRouteDiagnostics,
257
260
  namespace,
258
261
  persistentArtifactStore,
@@ -320,11 +323,18 @@ async function buildBatchStepPresentation(options) {
320
323
  presentation,
321
324
  };
322
325
  }
323
- function abandonedRecordingArtifact(artifact) {
326
+ async function abandonedRecordingArtifact(artifact) {
324
327
  const { recordingState: _recordingState, willExistOnStop: _willExistOnStop, ...terminal } = artifact;
325
- return { ...terminal, exists: false, status: "missing", subcommand: "close-abandoned" };
328
+ try {
329
+ const file = await stat(artifact.absolutePath);
330
+ return { ...terminal, exists: file.isFile(), sizeBytes: file.size, status: file.isFile() ? "unverified" : "missing", subcommand: "close-abandoned" };
331
+ }
332
+ catch (error) {
333
+ const missing = error.code === "ENOENT";
334
+ return { ...terminal, exists: missing ? false : undefined, status: missing ? "missing" : "unverified", subcommand: "close-abandoned" };
335
+ }
326
336
  }
327
- function coalesceTerminalBatchRecordingArtifacts(steps, sessionName, namespace) {
337
+ async function coalesceTerminalBatchRecordingArtifacts(steps, sessionName, namespace) {
328
338
  const artifacts = [];
329
339
  const pendingIndexesBySession = new Map();
330
340
  const removedPendingIndexes = new Set();
@@ -351,7 +361,7 @@ function coalesceTerminalBatchRecordingArtifacts(steps, sessionName, namespace)
351
361
  for (const pendingIndex of pendingIndexesBySession.get(session) ?? []) {
352
362
  const pending = artifacts[pendingIndex];
353
363
  if (pending && !removedPendingIndexes.has(pendingIndex))
354
- artifacts[pendingIndex] = abandonedRecordingArtifact(pending);
364
+ artifacts[pendingIndex] = await abandonedRecordingArtifact(pending);
355
365
  }
356
366
  }
357
367
  return artifacts.filter((_, index) => !removedPendingIndexes.has(index));
@@ -372,6 +382,7 @@ export async function buildBatchPresentation(options) {
372
382
  cwd,
373
383
  index,
374
384
  item,
385
+ piCleanupOwnership: options.piCleanupOwnership,
375
386
  namespace,
376
387
  networkRoutes: currentNetworkRoutes,
377
388
  persistentArtifactStore: persistentArtifactStore ? { ...persistentArtifactStore, protectedPaths: protectedPersistentPaths } : undefined,
@@ -387,7 +398,7 @@ export async function buildBatchPresentation(options) {
387
398
  }
388
399
  const batchFailure = getBatchFailureDetails(steps);
389
400
  const images = steps.flatMap((step) => getPresentationImages(step.presentation));
390
- const artifacts = coalesceTerminalBatchRecordingArtifacts(steps, sessionName, namespace);
401
+ const artifacts = await coalesceTerminalBatchRecordingArtifacts(steps, sessionName, namespace);
391
402
  const artifactVerification = buildArtifactVerificationSummary(artifacts);
392
403
  const fullOutputPaths = steps.flatMap((step) => getPresentationPaths({
393
404
  primaryPath: step.presentation.fullOutputPath,
@@ -399,7 +410,7 @@ export async function buildBatchPresentation(options) {
399
410
  }));
400
411
  const redactedBatchData = steps.map(({ details }) => (details.success
401
412
  ? { command: details.command, result: details.data, success: true }
402
- : { command: details.command, error: details.text, success: false }));
413
+ : { command: details.command, error: details.text, ...(details.command?.[0] === "record" ? { result: details.data } : {}), success: false }));
403
414
  const unverifiedMutationCount = steps.filter((step) => step.details.pageChangeSummary?.changeType === "mutation" && step.details.pageChangeSummary.observed === false).length;
404
415
  const mutationEvidenceText = unverifiedMutationCount > 0
405
416
  ? `Mutation evidence: ${unverifiedMutationCount} action result${unverifiedMutationCount === 1 ? " proves" : "s prove"} dispatch only, not application state change. Use explicit later assertions or external receipts as postconditions; fixed waits are not postconditions.`
@@ -5,29 +5,9 @@ const UNTITLED_PAGE_SUMMARY = "(untitled page)";
5
5
  export function stringifyModelFacing(value) {
6
6
  return stringifyUnknown(redactSensitiveValue(value));
7
7
  }
8
- export function parseJsonPreviewString(value) {
9
- const trimmed = value.trim();
10
- if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
11
- return value;
12
- try {
13
- return JSON.parse(trimmed);
14
- }
15
- catch {
16
- return value;
17
- }
18
- }
19
8
  export function redactModelFacingText(text) {
20
- const parsed = parseJsonPreviewString(text);
21
- if (parsed !== text) {
22
- return stringifyModelFacing(parsed);
23
- }
24
9
  return redactSensitiveText(text);
25
10
  }
26
- export function redactModelFacingTextIfSensitive(text) {
27
- return /(?:@|\b(?:access[_-]?key|api[_-]?key|auth|authorization|basic|bearer|connection[_-]?string|cookie|database[_-]?url|db[_-]?url|mongo(?:db)?[_-]?uri|pass(?:word)?|private[_-]?key|redis[_-]?url|secret|session[_-]?id|token)\b)/i.test(text)
28
- ? redactModelFacingText(text)
29
- : text;
30
- }
31
11
  export function getArrayField(data, key) {
32
12
  return Array.isArray(data[key]) ? data[key] : undefined;
33
13
  }
@@ -64,11 +44,11 @@ export function getPageSummary(data) {
64
44
  const url = typeof data.url === "string" ? data.url : undefined;
65
45
  if (title === undefined && url === undefined)
66
46
  return undefined;
67
- if (title && url)
68
- return `${title}\n${url}`;
69
- if (url)
70
- return url;
71
- return title || UNTITLED_PAGE_SUMMARY;
47
+ const summary = title && url ? `${title}\n${url}` : url || title || UNTITLED_PAGE_SUMMARY;
48
+ const webmcp = isRecord(data.webmcp) ? data.webmcp : undefined;
49
+ return webmcp?.available === true && typeof webmcp.toolCount === "number" && Number.isInteger(webmcp.toolCount) && webmcp.toolCount > 0
50
+ ? `${summary}\n\nWebMCP tools are available on this page (experimental). Run webmcp list to view them.`
51
+ : summary;
72
52
  }
73
53
  export function formatCount(count, singular, plural = `${singular}s`) {
74
54
  return `${count} ${count === 1 ? singular : plural}`;