pi-agent-browser-native 0.6.10 → 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 (45) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +49 -6
  3. package/dist/extensions/agent-browser/index.js +416 -430
  4. package/dist/extensions/agent-browser/lib/argv-grammar.js +1 -1
  5. package/dist/extensions/agent-browser/lib/command-policy.js +41 -2
  6. package/dist/extensions/agent-browser/lib/input-modes/params.js +1 -1
  7. package/dist/extensions/agent-browser/lib/input-modes/script.js +3 -2
  8. package/dist/extensions/agent-browser/lib/managed-session-restore.js +40 -10
  9. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +3 -0
  10. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +8 -2
  11. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +1 -0
  12. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/wait-timeouts.js +3 -2
  13. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +25 -14
  14. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +56 -10
  15. package/dist/extensions/agent-browser/lib/orchestration/browser-run/recording-recovery.js +161 -0
  16. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +3 -3
  17. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +2 -4
  18. package/dist/extensions/agent-browser/lib/orchestration/native-session-defaults.js +68 -0
  19. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +41 -6
  20. package/dist/extensions/agent-browser/lib/page-target-validation.js +9 -5
  21. package/dist/extensions/agent-browser/lib/playbook.js +10 -9
  22. package/dist/extensions/agent-browser/lib/process-environment.js +26 -8
  23. package/dist/extensions/agent-browser/lib/process.js +8 -5
  24. package/dist/extensions/agent-browser/lib/read-confirmation.js +59 -0
  25. package/dist/extensions/agent-browser/lib/recording-reservations.js +11 -1
  26. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +8 -0
  27. package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +6 -5
  28. package/dist/extensions/agent-browser/lib/results/categories.js +4 -2
  29. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +60 -29
  30. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +19 -8
  31. package/dist/extensions/agent-browser/lib/results/presentation/common.js +0 -20
  32. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +40 -38
  33. package/dist/extensions/agent-browser/lib/results/presentation/errors.js +1 -0
  34. package/dist/extensions/agent-browser/lib/results/presentation/navigation.js +3 -3
  35. package/dist/extensions/agent-browser/lib/results/presentation.js +38 -9
  36. package/dist/extensions/agent-browser/lib/results/recording.js +50 -0
  37. package/dist/extensions/agent-browser/lib/runtime.js +72 -20
  38. package/dist/extensions/agent-browser/lib/session-page-state.js +23 -7
  39. package/dist/extensions/agent-browser/lib/temp.js +4 -0
  40. package/docs/ARCHITECTURE.md +26 -10
  41. package/docs/COMMAND_REFERENCE.md +35 -15
  42. package/docs/SUPPORT_MATRIX.md +7 -3
  43. package/docs/TOOL_CONTRACT.md +72 -20
  44. package/package.json +1 -1
  45. package/scripts/prepare.mjs +2 -4
@@ -286,10 +286,12 @@ export function buildAgentBrowserProcessEnv(baseEnv = processEnv, overrides = un
286
286
  clampUpstreamDefaultTimeout(childEnv);
287
287
  return childEnv;
288
288
  }
289
- function getManagedPreSpawnPolicyError(options, currentPageUrl, pageUrlUnknown = false) {
289
+ function getManagedPreSpawnPolicyError(options, currentPageUrl, pageUrlUnknown = false, browserIndependentReadConfirmation = false) {
290
290
  if (!validateManagedSessionRestoreContextForSpawn(options)) {
291
291
  return "Managed session restore policy, storage, or checkout identity changed after planning; refusing to start agent-browser.";
292
292
  }
293
+ if (browserIndependentReadConfirmation)
294
+ return undefined;
293
295
  return getPageTargetValidationError({
294
296
  args: options.args,
295
297
  currentPageUrl,
@@ -316,7 +318,7 @@ export async function runAgentBrowserProcess(options) {
316
318
  restoreState: managedSessionRestoreState,
317
319
  stdin,
318
320
  };
319
- const planningPolicyError = getManagedPreSpawnPolicyError(managedSessionRestoreOptions, managedStateCurrentPageUrl, managedStatePageUrlUnknown);
321
+ const planningPolicyError = getManagedPreSpawnPolicyError(managedSessionRestoreOptions, managedStateCurrentPageUrl, managedStatePageUrlUnknown, options.browserIndependentReadConfirmation);
320
322
  if (planningPolicyError) {
321
323
  return {
322
324
  aborted: false,
@@ -331,7 +333,7 @@ export async function runAgentBrowserProcess(options) {
331
333
  const managedSessionRestoreEnv = getManagedSessionRestoreEnv(managedSessionRestoreOptions);
332
334
  const ownedManagedSessionCompatibilityEnv = getOwnedManagedSessionCompatibilityEnv(managedSessionRestoreOptions);
333
335
  const processOverrides = {
334
- [AGENT_BROWSER_IDLE_TIMEOUT_ENV]: String(getImplicitSessionIdleTimeoutMs()),
336
+ ...(ownedManagedSession ? { [AGENT_BROWSER_IDLE_TIMEOUT_ENV]: String(getImplicitSessionIdleTimeoutMs()) } : {}),
335
337
  ...managedSessionRestoreEnv,
336
338
  ...env,
337
339
  ...getManagedSessionRestoreProtectedEnv(managedSessionRestoreOptions, managedSessionRestoreEnv),
@@ -340,7 +342,8 @@ export async function runAgentBrowserProcess(options) {
340
342
  };
341
343
  const explicitSocketDir = processOverrides[AGENT_BROWSER_SOCKET_DIR_ENV];
342
344
  let effectiveEnv = explicitSocketDir === undefined ? { ...processOverrides, [AGENT_BROWSER_SOCKET_DIR_ENV]: undefined } : processOverrides;
343
- const requestedSocketDir = explicitSocketDir ?? parentEnv[PI_AGENT_BROWSER_SOCKET_DIR_ENV] ?? getAgentBrowserSocketDir();
345
+ const requestedSocketDir = explicitSocketDir ?? parentEnv[PI_AGENT_BROWSER_SOCKET_DIR_ENV]
346
+ ?? (!ownedManagedSession ? parentEnv[AGENT_BROWSER_SOCKET_DIR_ENV] : undefined) ?? getAgentBrowserSocketDir();
344
347
  if (requestedSocketDir !== undefined) {
345
348
  const socketDirError = requestedSocketDir.length > 0
346
349
  ? await getAgentBrowserSocketDirValidationError(requestedSocketDir)
@@ -463,7 +466,7 @@ export async function runAgentBrowserProcess(options) {
463
466
  });
464
467
  };
465
468
  const childEnv = buildAgentBrowserProcessEnv(parentEnv, effectiveEnv);
466
- const spawnPolicyError = getManagedPreSpawnPolicyError(managedSessionRestoreOptions, managedStateCurrentPageUrl, managedStatePageUrlUnknown);
469
+ const spawnPolicyError = getManagedPreSpawnPolicyError(managedSessionRestoreOptions, managedStateCurrentPageUrl, managedStatePageUrlUnknown, options.browserIndependentReadConfirmation);
467
470
  if (spawnPolicyError) {
468
471
  resolve({ aborted: false, agentBrowserStarted: false, exitCode: 1, spawnError: new Error(spawnPolicyError), stderr: "", stdout: "", timedOut: false });
469
472
  return;
@@ -0,0 +1,59 @@
1
+ import { extractUpstreamCommandTokens } from "./argv-descriptor.js";
2
+ import { extractExplicitSessionName, getAgentBrowserSessionIdentityKey, resolveAgentBrowserNamespace, scanUpstreamGlobalFlagOccurrences } from "./argv-grammar.js";
3
+ import { getExplicitReadUrl } from "./command-policy.js";
4
+ import { isCloseCommand } from "./command-taxonomy.js";
5
+ import { isRecord } from "./parsing.js";
6
+ export function parseReadConfirmation(value) {
7
+ if (!isRecord(value) || value.source !== "native-explicit-url-read" || (value.state !== "pending" && value.state !== "cleared"))
8
+ return undefined;
9
+ if (typeof value.id !== "string" || !value.id || typeof value.sessionName !== "string" || !value.sessionName || (value.namespace !== undefined && typeof value.namespace !== "string"))
10
+ return undefined;
11
+ return { ...(isRecord(value.capabilities) && value.capabilities.readRequiresConfirmation === true ? { capabilities: { readRequiresConfirmation: true } } : {}), id: value.id, sessionName: value.sessionName, namespace: value.namespace, source: "native-explicit-url-read", state: value.state };
12
+ }
13
+ export function findReadConfirmation(args, confirmations, namespace) {
14
+ const tokens = extractUpstreamCommandTokens(args);
15
+ if (tokens.length !== 2 || !["confirm", "deny"].includes(tokens[0]))
16
+ return undefined;
17
+ const sessionName = extractExplicitSessionName(args);
18
+ const effectiveNamespace = resolveAgentBrowserNamespace(args, namespace);
19
+ const matches = [...confirmations].filter(value => value.state === "pending" && value.id === tokens[1]
20
+ && (sessionName === undefined || getAgentBrowserSessionIdentityKey(sessionName, value.namespace) === getAgentBrowserSessionIdentityKey(value.sessionName, value.namespace))
21
+ && (effectiveNamespace === undefined || getAgentBrowserSessionIdentityKey(value.sessionName, effectiveNamespace) === getAgentBrowserSessionIdentityKey(value.sessionName, value.namespace)));
22
+ return matches.length === 1 ? matches[0] : undefined;
23
+ }
24
+ export function scopeReadConfirmationArgs(args, confirmation) {
25
+ return [
26
+ ...(scanUpstreamGlobalFlagOccurrences(args, "--namespace").length === 0 ? ["--namespace", confirmation.namespace ?? ""] : []),
27
+ ...(extractExplicitSessionName(args) === undefined ? ["--session", confirmation.sessionName] : []),
28
+ ...args,
29
+ ];
30
+ }
31
+ export function nextReadConfirmation(options) {
32
+ const { commandTokens: tokens, current } = options;
33
+ const settlesRead = current?.state === "pending" && tokens.length === 2 && ["confirm", "deny"].includes(tokens[0]) && tokens[1] === current.id;
34
+ const confirmedResult = settlesRead && tokens[0] === "confirm" && isRecord(options.data) && options.data.confirmed === true && options.data.action === "read" && isRecord(options.data.result)
35
+ ? options.data.result.data : options.data;
36
+ // Native control fields only. Never parse response content, snapshot text or nested page JSON as provenance.
37
+ if (isRecord(confirmedResult) && confirmedResult.confirmation_required === true && typeof confirmedResult.confirmation_id === "string" && confirmedResult.confirmation_id && !("content" in confirmedResult)) {
38
+ if (confirmedResult.action === "read" && (typeof getExplicitReadUrl(tokens) === "string" || settlesRead)) {
39
+ return { ...(isRecord(confirmedResult.capabilities) && confirmedResult.capabilities.readRequiresConfirmation === true ? { capabilities: { readRequiresConfirmation: true } } : {}), id: confirmedResult.confirmation_id, namespace: options.namespace, sessionName: options.sessionName, source: "native-explicit-url-read", state: "pending" };
40
+ }
41
+ if (current?.state === "pending")
42
+ return { ...current, state: "cleared" };
43
+ }
44
+ return options.succeeded && current?.state === "pending" && (settlesRead || isCloseCommand(tokens[0])) ? { ...current, state: "cleared" } : undefined;
45
+ }
46
+ export function buildReadConfirmationNextActions(confirmation, pendingResponse) {
47
+ if (confirmation.state === "cleared")
48
+ return [];
49
+ const prefix = ["--namespace", confirmation.namespace ?? "", "--session", confirmation.sessionName];
50
+ if (!pendingResponse)
51
+ return [{ id: "inspect-read-confirmation-session", tool: "agent_browser", params: { args: [...prefix, "session", "info"] }, reason: "Inspect the exact native session after the read confirmation failed; rerun the original URL read if its ID expired.", safety: "Read-only status, without browser launch or tab changes. Do not substitute a different pending confirmation ID." }];
52
+ return ["confirm", "deny"].map(command => ({
53
+ id: command === "confirm" ? "approve-confirmation" : "deny-confirmation", tool: "agent_browser", params: { args: [...prefix, command, confirmation.id] },
54
+ reason: `${command === "confirm" ? "Approve" : "Deny"} the native confirmation for this explicit URL read.`,
55
+ safety: confirmation.capabilities?.readRequiresConfirmation === true
56
+ ? "Review the requested read first. The native capability proves ID matching; no DOM confirmation is implied."
57
+ : "Native ID matching/browser independence is unproven. The exact native session is preserved, but this confirmation retains normal page checks.",
58
+ }));
59
+ }
@@ -14,6 +14,8 @@ function getArtifactReservation(artifact) {
14
14
  cwd: artifact.cwd ?? process.cwd(),
15
15
  namespace: artifact.namespace,
16
16
  path: artifact.path,
17
+ ...(artifact.recording?.recordingId ? { recordingId: artifact.recording.recordingId } : {}),
18
+ ...(artifact.recordingStartedAtMs !== undefined ? { startedAtMs: artifact.recordingStartedAtMs } : {}),
17
19
  sessionName: artifact.session,
18
20
  };
19
21
  }
@@ -43,7 +45,7 @@ export function applyRecordingArtifactsToReservations(reservations, artifacts) {
43
45
  for (const [key, pending] of pendingBySession) {
44
46
  const existing = reservations.get(key);
45
47
  reservations.set(key, pending);
46
- if (!existing || existing.absolutePath !== pending.absolutePath || existing.cwd !== pending.cwd) {
48
+ if (!existing || existing.absolutePath !== pending.absolutePath || existing.cwd !== pending.cwd || existing.recordingId !== pending.recordingId || existing.startedAtMs !== pending.startedAtMs) {
47
49
  transitions.push({ reservation: pending, state: "active" });
48
50
  }
49
51
  }
@@ -63,6 +65,8 @@ export function appendRecordingReservationTransition(pi, transition) {
63
65
  cwd: state === "active" ? reservation.cwd : undefined,
64
66
  namespace: reservation.namespace,
65
67
  path: state === "active" ? reservation.path : undefined,
68
+ recordingId: state === "active" ? reservation.recordingId : undefined,
69
+ startedAtMs: state === "active" ? reservation.startedAtMs : undefined,
66
70
  sessionName: reservation.sessionName,
67
71
  state,
68
72
  version: 1,
@@ -81,6 +85,10 @@ function parseReservationTransition(data) {
81
85
  state: "closed",
82
86
  };
83
87
  }
88
+ if (data.recordingId !== undefined && (typeof data.recordingId !== "string" || !data.recordingId))
89
+ return undefined;
90
+ if (data.startedAtMs !== undefined && (typeof data.startedAtMs !== "number" || !Number.isFinite(data.startedAtMs)))
91
+ return undefined;
84
92
  if (typeof data.absolutePath !== "string" || !isAbsolute(data.absolutePath)
85
93
  || typeof data.cwd !== "string" || !isAbsolute(data.cwd) || typeof data.path !== "string")
86
94
  return undefined;
@@ -90,6 +98,8 @@ function parseReservationTransition(data) {
90
98
  cwd: data.cwd,
91
99
  namespace: data.namespace,
92
100
  path: data.path,
101
+ ...(typeof data.recordingId === "string" ? { recordingId: data.recordingId } : {}),
102
+ ...(typeof data.startedAtMs === "number" ? { startedAtMs: data.startedAtMs } : {}),
93
103
  sessionName: data.sessionName,
94
104
  },
95
105
  state: "active",
@@ -197,6 +197,14 @@ export function buildAgentBrowserNextActions(options) {
197
197
  break;
198
198
  case "timeout":
199
199
  {
200
+ if (options.command === "session" && options.subcommand === "info") {
201
+ actions.push(buildNextToolAction({
202
+ args: ["session", "info"],
203
+ id: "retry-session-info",
204
+ reason: "Retry the same session status check without opening a browser or inspecting its page.",
205
+ }));
206
+ break;
207
+ }
200
208
  const textAssertion = options.command === "wait" && options.args?.includes("--text") === true;
201
209
  const urlAssertion = options.command === "wait" && options.args?.includes("--url") === true;
202
210
  actions.push(buildNextToolAction({
@@ -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";
@@ -5,6 +5,7 @@ import { getExplicitArtifactDestination } from "../../orchestration/browser-run/
5
5
  import { isRecord, parsePositiveInteger } from "../../parsing.js";
6
6
  import { formatSessionArtifactRetentionSummary, getSessionArtifactManifestEntryKey, isPendingRecordingArtifact, isPendingRecordingCommand, mergeSessionArtifactManifest, } from "../artifact-manifest.js";
7
7
  import { classifyAgentBrowserSuccessCategory } from "../categories.js";
8
+ import { formatRecordingReceipt, getRecordingReceipt } from "../recording.js";
8
9
  const PNG_HEADER = Buffer.from("89504e470d0a1a0a0000000d49484452", "hex");
9
10
  const INLINE_IMAGE_MAX_BYTES_ENV = "PI_AGENT_BROWSER_INLINE_IMAGE_MAX_BYTES";
10
11
  const DEFAULT_INLINE_IMAGE_MAX_BYTES = 5 * 1_024 * 1_024;
@@ -159,24 +160,26 @@ async function buildFileArtifactMetadata(options) {
159
160
  const absolutePath = options.artifactRequest?.absolutePath ?? resolve(options.cwd, options.path);
160
161
  const displayPath = options.artifactRequest?.path ?? options.path;
161
162
  const extension = extname(absolutePath || options.path).toLowerCase() || undefined;
162
- 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;
163
166
  let exists;
164
167
  let sizeBytes;
165
168
  let mediaType;
166
169
  let stale = false;
167
170
  let updatedAtMs;
168
- if (!pendingRecording) {
171
+ if (!pendingRecording || options.commandInfo.subcommand === "stop") {
169
172
  try {
170
173
  const fileStats = await stat(absolutePath);
171
- exists = true;
174
+ exists = fileStats.isFile();
172
175
  sizeBytes = fileStats.size;
173
176
  updatedAtMs = fileStats.mtimeMs;
174
177
  mediaType = fileStats.isFile() ? await getFileImageMimeType(absolutePath) : undefined;
175
178
  const commandCreatesArtifact = !(options.commandInfo.command === "wait" && isDownloadWaitSubcommand(options.commandInfo.subcommand));
176
- stale = commandCreatesArtifact && artifactMtimeIsOutsideCommandWindow(updatedAtMs, options.artifactMinUpdatedAtMs, options.artifactMaxUpdatedAtMs);
179
+ stale = commandCreatesArtifact && artifactMtimeIsOutsideCommandWindow(updatedAtMs, kind === "video" ? recordingStartedAtMs : options.artifactMinUpdatedAtMs, options.artifactMaxUpdatedAtMs);
177
180
  }
178
- catch {
179
- exists = false;
181
+ catch (error) {
182
+ exists = ["ENOENT", "ENOTDIR"].includes(error.code ?? "") ? false : undefined;
180
183
  }
181
184
  }
182
185
  return {
@@ -190,11 +193,17 @@ async function buildFileArtifactMetadata(options) {
190
193
  mediaType,
191
194
  namespace: options.namespace,
192
195
  path: displayPath,
196
+ recording: options.recording,
197
+ recordingStartedAtMs: kind === "video" ? recordingStartedAtMs : undefined,
193
198
  recordingState: pendingRecording ? "openRecording" : undefined,
194
199
  requestedPath: options.artifactRequest?.path ?? getExplicitArtifactDestination(options.commandInfo.commandTokens ?? []),
195
200
  session: options.sessionName,
196
201
  sizeBytes,
197
- 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",
198
207
  subcommand: options.commandInfo.subcommand,
199
208
  tempPath: options.artifactRequest?.tempPath,
200
209
  updatedAtMs,
@@ -204,6 +213,10 @@ async function buildFileArtifactMetadata(options) {
204
213
  async function buildPreviousRestartRecordingArtifact(options) {
205
214
  if (options.commandInfo.command !== "record" || options.commandInfo.subcommand !== "restart")
206
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
+ }
207
220
  const sessionKey = options.sessionName ? getAgentBrowserSessionIdentityKey(options.sessionName, options.namespace) : undefined;
208
221
  const previousRecording = options.artifactManifest?.entries.find((entry) => (entry.command === "record" &&
209
222
  (entry.subcommand === "start" || entry.subcommand === "restart") &&
@@ -233,18 +246,21 @@ async function buildPreviousRestartRecordingArtifact(options) {
233
246
  exists: true,
234
247
  mediaType: fileStats.isFile() ? await getFileImageMimeType(absolutePath) : undefined,
235
248
  sizeBytes: fileStats.size,
236
- status: stale ? "stale" : "saved",
249
+ status: stale ? "stale" : "unverified",
237
250
  updatedAtMs: fileStats.mtimeMs,
238
251
  };
239
252
  }
240
- catch {
241
- return { ...base, exists: false, status: "missing" };
253
+ catch (error) {
254
+ const missing = ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
255
+ return { ...base, exists: missing ? false : undefined, status: missing ? "missing" : "unverified" };
242
256
  }
243
257
  }
244
258
  export async function extractFileArtifacts(options) {
245
259
  const candidates = extractPathStrings(options.data);
246
- const currentArtifacts = (await Promise.all(candidates.map((path) => buildFileArtifactMetadata({ ...options, path })))).filter((artifact) => artifact !== undefined);
247
- 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);
248
264
  return previousRestartRecordingArtifact ? [previousRestartRecordingArtifact, ...currentArtifacts] : currentArtifacts;
249
265
  }
250
266
  export function buildManifestEntriesForFileArtifacts(artifacts, nowMs = Date.now()) {
@@ -259,6 +275,10 @@ export function buildManifestEntriesForFileArtifacts(artifacts, nowMs = Date.now
259
275
  mediaType: artifact.mediaType,
260
276
  namespace: artifact.namespace,
261
277
  path: artifact.path,
278
+ recording: artifact.recording,
279
+ recordingStartedAtMs: artifact.recordingStartedAtMs,
280
+ recordingState: artifact.recordingState,
281
+ status: artifact.status,
262
282
  requestedPath: artifact.requestedPath,
263
283
  retentionState: artifact.exists === false || artifact.status === "stale" ? "missing" : "live",
264
284
  session: artifact.session,
@@ -268,10 +288,7 @@ export function buildManifestEntriesForFileArtifacts(artifacts, nowMs = Date.now
268
288
  }));
269
289
  }
270
290
  export function isManifestFileArtifact(artifact) {
271
- if (artifact.status === "stale") {
272
- return artifact.kind === "video" && artifact.command === "record" && artifact.subcommand === "restart-previous";
273
- }
274
- return artifact.kind === "video" && artifact.command === "record" ? true : !isPendingRecordingArtifact(artifact);
291
+ return artifact.kind === "video" && artifact.command === "record" ? true : artifact.status !== "stale" && !isPendingRecordingArtifact(artifact);
275
292
  }
276
293
  function getArtifactVerificationEntry(artifact) {
277
294
  if (isPendingRecordingArtifact(artifact)) {
@@ -279,7 +296,9 @@ function getArtifactVerificationEntry(artifact) {
279
296
  absolutePath: artifact.absolutePath,
280
297
  exists: artifact.exists,
281
298
  kind: artifact.kind,
282
- 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,
283
302
  mediaType: artifact.mediaType,
284
303
  path: artifact.path,
285
304
  recordingState: artifact.recordingState ?? "openRecording",
@@ -292,7 +311,7 @@ function getArtifactVerificationEntry(artifact) {
292
311
  willExistOnStop: artifact.willExistOnStop ?? true,
293
312
  };
294
313
  }
295
- const state = artifact.status === "stale"
314
+ const state = ["failed", "stale", "unverified"].includes(artifact.status ?? "")
296
315
  ? "unverified"
297
316
  : artifact.exists === true
298
317
  ? "verified"
@@ -303,13 +322,17 @@ function getArtifactVerificationEntry(artifact) {
303
322
  absolutePath: artifact.absolutePath,
304
323
  exists: artifact.exists,
305
324
  kind: artifact.kind,
306
- limitation: artifact.status === "stale"
307
- ? "The reported path's modification time fell outside this command's bounded artifact window. Treat the artifact as stale until regenerated."
308
- : state === "missing"
309
- ? "The wrapper did not find the reported artifact at absolutePath. Treat the path as unverified until recovered or regenerated."
310
- : state === "unverified"
311
- ? "The wrapper could not prove local filesystem existence for this artifact."
312
- : 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,
313
336
  mediaType: artifact.mediaType,
314
337
  path: artifact.path,
315
338
  requestedPath: artifact.requestedPath,
@@ -415,6 +438,10 @@ function formatArtifactLabel(artifact) {
415
438
  case "trace":
416
439
  return "Saved trace";
417
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";
418
445
  if (artifact.command === "record" && artifact.subcommand === "restart-previous") {
419
446
  if (artifact.status === "stale")
420
447
  return "Previous recording stale";
@@ -423,7 +450,9 @@ function formatArtifactLabel(artifact) {
423
450
  return "Previous recording saved";
424
451
  }
425
452
  if (!isPendingRecordingArtifact(artifact))
426
- return "Saved recording";
453
+ return artifact.status === "saved" ? "Saved recording" : "Recording reported; file not verified";
454
+ if (artifact.subcommand === "stop")
455
+ return "Recording finalization pending";
427
456
  return artifact.subcommand === "restart" ? "Recording restarted; output will be written on stop" : "Recording started; output will be written on stop";
428
457
  }
429
458
  }
@@ -440,7 +469,7 @@ export function formatArtifactSummary(artifacts) {
440
469
  if (restartArtifact && previousRecordingArtifacts.length > 0) {
441
470
  return [...previousRecordingArtifacts, restartArtifact].map((artifact) => `${formatArtifactLabel(artifact)}: ${artifact.path}`).join("\n");
442
471
  }
443
- 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(", ")}`;
444
473
  }
445
474
  export function formatArtifactMetadataLines(artifacts) {
446
475
  return artifacts.map((artifact, index) => {
@@ -450,12 +479,13 @@ export function formatArtifactMetadataLines(artifacts) {
450
479
  `Artifact type: ${artifact.kind}`,
451
480
  artifact.requestedPath ? `Requested path: ${artifact.requestedPath}` : undefined,
452
481
  `Absolute path: ${artifact.absolutePath}`,
453
- "Exists: pending until record stop",
482
+ `Exists: ${artifact.exists ?? "pending until record stop"}`,
454
483
  `Status: ${artifact.status ?? "pending"}`,
455
484
  `Recording state: ${artifact.recordingState ?? "openRecording"}`,
456
485
  `Will exist on stop: ${artifact.willExistOnStop !== false}`,
457
486
  artifact.session ? `Session: ${artifact.session}` : undefined,
458
487
  artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
488
+ artifact.recording ? formatRecordingReceipt(artifact.recording) : undefined,
459
489
  `Machine data: details.artifacts[${index}]`,
460
490
  ].filter((item) => item !== undefined).join("\n");
461
491
  }
@@ -464,7 +494,7 @@ export function formatArtifactMetadataLines(artifacts) {
464
494
  `Artifact type: ${artifact.kind}`,
465
495
  artifact.requestedPath ? `Requested path: ${artifact.requestedPath}` : undefined,
466
496
  `Absolute path: ${artifact.absolutePath}`,
467
- `Exists: ${artifact.exists === true}`,
497
+ `Exists: ${artifact.exists ?? "unknown"}`,
468
498
  artifact.exists === false ? "not found on disk" : undefined,
469
499
  typeof artifact.sizeBytes === "number" ? `Size: ${formatByteCount(artifact.sizeBytes)}` : undefined,
470
500
  typeof artifact.sizeBytes === "number" ? `Size bytes: ${artifact.sizeBytes}` : undefined,
@@ -473,6 +503,7 @@ export function formatArtifactMetadataLines(artifacts) {
473
503
  artifact.mediaType ? `Media type: ${artifact.mediaType}` : undefined,
474
504
  artifact.session ? `Session: ${artifact.session}` : undefined,
475
505
  artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
506
+ artifact.recording ? formatRecordingReceipt(artifact.recording) : undefined,
476
507
  `Machine data: details.artifacts[${index}]`,
477
508
  ].filter((item) => item !== undefined).join("\n");
478
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
  }