pi-agent-browser-native 0.6.8 → 0.6.9

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.9 - 2026-09-08
4
+
5
+ ### Fixed
6
+
7
+ - Include redacted stdout/stderr tails in failed Electron startup diagnostics and visible errors (#128). Capture uses private regular files inside the isolated profile, with the last 4096 bytes read per stream; this is not a lifetime disk limit. Preserve the profile and logs when failed-startup process cleanup cannot finish, without changing normal quit cleanup.
8
+
9
+ - Restore ordinary browser access to a tracked Electron app after Pi reload or resume by checking its live debug endpoint and the named upstream connection. Keep the app, profile, and session intact; unrelated or replaced connections still fail verification.
10
+
11
+ ### Validation
12
+
13
+ - Verified startup output, reload continuity and quit cleanup with genuine Electron through the official Pi SDK on native macOS and Ubuntu. Native Windows was waived and not run; permanent release gates are unchanged.
14
+
3
15
  ## 0.6.8 - 2026-09-07
4
16
 
5
17
  ### Fixed
package/README.md CHANGED
@@ -135,6 +135,8 @@ Then install this Pi package:
135
135
  pi install npm:pi-agent-browser-native
136
136
  ```
137
137
 
138
+ After updating `pi-agent-browser-native`, fully quit and restart Pi before using the updated tools. `/reload` can retain previously loaded compiled JavaScript even after `dist/` is rebuilt, so it is not a reliable way to pick up package updates.
139
+
138
140
  Start Pi and ask for a browser action:
139
141
 
140
142
  ```text
@@ -505,7 +507,7 @@ For desktop Electron apps, use top-level `electron` to avoid hand-building the d
505
507
  { "electron": { "action": "cleanup", "launchId": "electron-…" } }
506
508
  ```
507
509
 
508
- `electron.list` has no configurable timeout. Other Electron actions accept nested `electron.timeoutMs`; `electron.probe.timeoutMs` bounds each underlying read subprocess when dense desktop apps need a shorter or longer probe budget (omit for the normal tool subprocess default). `electron.cleanup.timeoutMs` applies separately to upstream `close` and the initial process-exit wait, not the whole teardown, and defaults to the implicit session close budget unless overridden; if the managed-session close step succeeds but host cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. `electron.status.timeoutMs` only tightens managed-session title/url reads used for mismatch checks. Pass `electron.probe.launchId` when you want the probe tied to a wrapper-tracked launch instead of only the current managed session. Launch/status/probe results show both `launchId` (for status/cleanup/probe) and `sessionName` (for browser `snapshot`/`tab` commands); if the managed session drifts to `about:blank` while wrapper status still sees a live renderer, Electron-specific mismatch warnings and `status`/`probe`/`reattach`/`snapshot` next actions replace generic tab guidance. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches; if cleanup is partial and skips or fails profile removal, the generic temp sweep preserves that `userDataDir` across reload, quit, later temp cleanup, process exit, and stale temp-root pruning after restart. If the app process/debug port dies after a successful-looking mutation, the wrapper reports `details.electronPostCommandHealth` and fails with `tab-drift` instead of quietly continuing on `about:blank`. Launch timeouts expose `details.electron.failure.diagnostics` for PID, profile, DevToolsActivePort, and timing evidence.
510
+ `electron.list` has no configurable timeout. Other Electron actions accept nested `electron.timeoutMs`; `electron.probe.timeoutMs` bounds each underlying read subprocess when dense desktop apps need a shorter or longer probe budget (omit for the normal tool subprocess default). `electron.cleanup.timeoutMs` applies separately to upstream `close` and the initial process-exit wait, not the whole teardown, and defaults to the implicit session close budget unless overridden; if the managed-session close step succeeds but host cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. `electron.status.timeoutMs` applies to managed-session title/URL reads and any `get cdp-url` read needed to verify a restored connection. Pass `electron.probe.launchId` when you want the probe tied to a wrapper-tracked launch instead of only the current managed session. Launch/status/probe results show both `launchId` (for status/cleanup/probe) and `sessionName` (for browser `snapshot`/`tab` commands); if the managed session drifts to `about:blank` while wrapper status still sees a live renderer, Electron-specific mismatch warnings and `status`/`probe`/`reattach`/`snapshot` next actions replace generic tab guidance. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches. First reuse after reload/resume checks the live app's saved debug endpoint and the exact named upstream connection without reconnecting or resetting page refs; if cleanup is partial and skips or fails profile removal, the generic temp sweep preserves that `userDataDir` across reload, quit, later temp cleanup, process exit, and stale temp-root pruning after restart. If the app process/debug port dies after a successful-looking mutation, the wrapper reports `details.electronPostCommandHealth` and fails with `tab-drift` instead of quietly continuing on `about:blank`. Failed startups expose redacted stdout/stderr tails in visible errors and `details.electron.failure.diagnostics`, alongside PID, profile, DevToolsActivePort, and timing evidence. Each tail reads at most 4096 source bytes; private capture files follow the profile lifecycle, not a lifetime disk cap. See [`docs/ELECTRON.md`](docs/ELECTRON.md#failure-categories-and-recovery).
509
511
 
510
512
  Explicit-ID `electron.status` labels historical cleaned launch records; default and `all: true` selection exclude them. Current PID/port liveness stays independent of cleanup history. `details.electron.statuses[].userDataDirState` freshly measures only the tracked profile path: `present` (including dangling symlinks), `absent` (ENOENT), or `unknown` (other `lstat` errors), not whether all app residue is gone.
511
513
 
@@ -24,15 +24,15 @@ function isPidAlive(pid) {
24
24
  return code === "EPERM" ? true : false;
25
25
  }
26
26
  }
27
- async function isPortAlive(port) {
28
- const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version`));
27
+ async function isPortAlive(port, signal) {
28
+ const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version`, signal));
29
29
  if (!version)
30
30
  return { targets: [] };
31
- const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list`));
31
+ const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list`, signal));
32
32
  return { targets, version };
33
33
  }
34
- export async function inspectElectronLaunchStatus(record) {
35
- const cdp = await isPortAlive(record.port);
34
+ export async function inspectElectronLaunchStatus(record, signal) {
35
+ const cdp = await isPortAlive(record.port, signal);
36
36
  let userDataDirState;
37
37
  try {
38
38
  await lstat(record.userDataDir);
@@ -1,10 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { readFile, rm } from "node:fs/promises";
4
- import { dirname } from "node:path";
3
+ import { open, readFile, rm } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
5
  import { fetchCdpJson, parseCdpTargets, parseCdpVersion, } from "./cdp.js";
6
6
  import { discoverElectronApps, inspectElectronAppPath, inspectElectronExecutablePath, } from "./discovery.js";
7
- import { createSecureTempDirectory } from "../temp.js";
7
+ import { createSecureTempDirectory, preserveSecureTempDirectory } from "../temp.js";
8
8
  export const ELECTRON_LAUNCH_RECORD_VERSION = 1;
9
9
  export const ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS = 15_000;
10
10
  export const ELECTRON_LAUNCH_MAX_TIMEOUT_MS = 120_000;
@@ -12,6 +12,8 @@ const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
12
12
  export const ELECTRON_PROFILE_DIR_PREFIX = "electron-profile-";
13
13
  const ELECTRON_DEFAULT_APP_ARGS = ["--disable-extensions", "--no-first-run", "--no-default-browser-check"];
14
14
  const ELECTRON_DEVTOOLS_POLL_INTERVAL_MS = 100;
15
+ // ponytail: bound failure reads, not lifetime log growth; noisy long-lived apps need rotation if that becomes a problem.
16
+ const ELECTRON_OUTPUT_TAIL_BYTES = 4096;
15
17
  function normalizeTimeoutMs(timeoutMs) {
16
18
  if (!Number.isSafeInteger(timeoutMs) || (timeoutMs ?? 0) <= 0)
17
19
  return ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS;
@@ -284,40 +286,90 @@ export async function launchElectronApp(options) {
284
286
  let exitCode = null;
285
287
  let exitSignal = null;
286
288
  const args = buildLaunchArgs(userDataDir, appArgs);
287
- const child = spawn(target.executablePath, args, {
288
- cwd: dirname(target.executablePath),
289
- detached: process.platform !== "win32",
290
- stdio: "ignore",
291
- });
292
- child.once("error", (error) => {
293
- spawnError = error;
294
- });
295
- child.once("exit", (code, signal) => {
296
- exitCode = code;
297
- exitSignal = signal;
298
- });
299
- child.unref();
289
+ let child;
290
+ let outputCaptured = false;
291
+ const outputFiles = [];
292
+ try {
293
+ for (const stream of ["stdout", "stderr"])
294
+ outputFiles.push(await open(join(userDataDir, `${stream}.log`), "wx", 0o600));
295
+ options.signal?.throwIfAborted();
296
+ child = spawn(target.executablePath, args, {
297
+ cwd: dirname(target.executablePath),
298
+ detached: process.platform !== "win32",
299
+ stdio: ["ignore", outputFiles[0].fd, outputFiles[1].fd],
300
+ });
301
+ outputCaptured = true;
302
+ child.once("error", (error) => {
303
+ spawnError = error;
304
+ });
305
+ child.once("exit", (code, signal) => {
306
+ exitCode = code;
307
+ exitSignal = signal;
308
+ });
309
+ child.unref();
310
+ }
311
+ catch (error) {
312
+ spawnError = error instanceof Error ? error : new Error(String(error));
313
+ }
314
+ finally {
315
+ for (const file of outputFiles) {
316
+ await file.close().catch((error) => {
317
+ cleanupError = [cleanupError, `Output handle close failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("; ");
318
+ });
319
+ }
320
+ }
300
321
  const buildFailureDiagnostics = (options = {}) => ({
301
322
  cdpVersionReached: options.cdpVersionReached,
302
323
  devToolsActivePort: options.devToolsActivePort,
303
324
  elapsedMs: Math.max(0, Date.now() - startedAtMs),
304
325
  exitCode,
305
326
  exitSignal,
306
- outputCaptured: false,
307
- pid: child.pid,
308
- pidAlive: isLaunchChildPidAlive(child),
327
+ outputCaptured,
328
+ pid: child?.pid,
329
+ pidAlive: child ? isLaunchChildPidAlive(child) : undefined,
309
330
  port: options.port ?? options.devToolsActivePort?.port,
310
331
  timeoutMs,
311
332
  userDataDir,
312
333
  });
313
334
  const fail = async (reason, detail, diagnosticOptions) => {
314
335
  const diagnostics = buildFailureDiagnostics(diagnosticOptions);
315
- const processCleanupError = await terminateLaunchChild(child);
336
+ const processCleanupError = child ? await terminateLaunchChild(child) : undefined;
337
+ const outputLines = [];
338
+ if (outputCaptured) {
339
+ for (const stream of ["stdout", "stderr"]) {
340
+ let file;
341
+ try {
342
+ file = await open(join(userDataDir, `${stream}.log`), "r");
343
+ const { size } = await file.stat();
344
+ const buffer = Buffer.alloc(Math.min(size, ELECTRON_OUTPUT_TAIL_BYTES));
345
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, Math.max(0, size - buffer.length));
346
+ diagnostics[`${stream}Tail`] = buffer.subarray(0, bytesRead).toString("utf8");
347
+ diagnostics[`${stream}Truncated`] = size > buffer.length;
348
+ }
349
+ catch (error) {
350
+ diagnostics[`${stream}Error`] = error instanceof Error ? error.message : String(error);
351
+ }
352
+ finally {
353
+ await file?.close().catch((error) => {
354
+ diagnostics[`${stream}Error`] = [diagnostics[`${stream}Error`], `Output reader close failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("; ");
355
+ });
356
+ }
357
+ const tail = diagnostics[`${stream}Tail`];
358
+ if (tail !== undefined)
359
+ outputLines.push(`App ${stream}${diagnostics[`${stream}Truncated`] ? ` (last ${ELECTRON_OUTPUT_TAIL_BYTES} bytes)` : ""}: ${tail || "(empty)"}`);
360
+ if (diagnostics[`${stream}Error`])
361
+ outputLines.push(`App ${stream} capture error: ${diagnostics[`${stream}Error`]}`);
362
+ }
363
+ }
316
364
  try {
317
- await rm(userDataDir, { force: true, recursive: true });
365
+ if (processCleanupError)
366
+ await preserveSecureTempDirectory(userDataDir);
367
+ else
368
+ await rm(userDataDir, { force: true, recursive: true });
318
369
  }
319
370
  catch (error) {
320
- cleanupError = error instanceof Error ? error.message : String(error);
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ cleanupError = [cleanupError, processCleanupError ? `Profile preservation failed: ${message}` : message].filter(Boolean).join("; ");
321
373
  }
322
374
  cleanupError = [processCleanupError, cleanupError].filter((value) => value !== undefined).join("; ") || undefined;
323
375
  return {
@@ -326,13 +378,15 @@ export async function launchElectronApp(options) {
326
378
  appArgs,
327
379
  cleanupError,
328
380
  diagnostics,
329
- error: launchFailureMessage(reason, target, detail),
381
+ error: [launchFailureMessage(reason, target, detail), ...outputLines].join("\n"),
330
382
  reason,
331
383
  target,
332
384
  userDataDir,
333
385
  },
334
386
  };
335
387
  };
388
+ if (!child)
389
+ return fail(options.signal?.aborted ? "aborted" : "spawn-error", spawnError?.message);
336
390
  const portResult = await pollDevToolsActivePort({
337
391
  deadlineMs,
338
392
  getChildExit: () => ({ code: exitCode, signal: exitSignal }),
@@ -1,12 +1,15 @@
1
1
  import { rm } from "node:fs/promises";
2
+ import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
3
+ import { inspectElectronLaunchStatus } from "../../electron/cleanup.js";
2
4
  import { acquireManagedSessionPolicyLock } from "../../managed-session-policy-lock.js";
3
- import { pruneOwnedManagedSessionRestoreSnapshots, resolveExplicitAutosaveInterval, } from "../../managed-session-restore.js";
5
+ import { pruneOwnedManagedSessionRestoreSnapshots, resolveExplicitAutosaveInterval, withOwnedManagedSessionContext, } from "../../managed-session-restore.js";
4
6
  import { isManagedSessionRestoreKey } from "../../managed-session-storage.js";
5
7
  import { isRecord } from "../../parsing.js";
6
8
  import { getAgentBrowserProcessEnvironment } from "../../process-environment.js";
7
- import { runAgentBrowserProcess } from "../../process.js";
9
+ import { runAgentBrowserProcess, withAttachedBrowserSessionContext } from "../../process.js";
8
10
  import { getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "../../results/envelope.js";
9
11
  import { redactInvocationArgs } from "../../runtime.js";
12
+ import { runSessionCommandData } from "./session-state.js";
10
13
  const MANAGED_SESSION_DAEMON_INSPECTION_TIMEOUT_MS = 35_000;
11
14
  const RUNNING_HEADED_AUTOSAVE_POLICY_CHANGE_ERROR = "AGENT_BROWSER_AUTOSAVE_INTERVAL_MS cannot change a running wrapper-owned headed session's launch-time periodic autosave interval. Close that session first, then retry with sessionMode: \"fresh\" so the new daemon starts with the requested interval.";
12
15
  export function getRunningHeadedAutosavePolicyChangeError(recordedInterval, closeCommand = false) {
@@ -49,6 +52,23 @@ export async function inspectManagedSessionDaemon(options) {
49
52
  await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
50
53
  }
51
54
  }
55
+ async function verifyRestoredElectronAttachment(context, record, signal, timeoutMs) {
56
+ const cwd = context.cwd;
57
+ if (!cwd || !record?.webSocketDebuggerUrl || !record.sessionName || record.cleanupState === "cleaned"
58
+ || getAgentBrowserSessionIdentityKey(record.sessionName, record.namespace) !== getAgentBrowserSessionIdentityKey(context.sessionName, context.namespace))
59
+ return false;
60
+ const status = await inspectElectronLaunchStatus(record, signal);
61
+ if (signal?.aborted || status.pidAlive !== true || status.userDataDirState !== "present"
62
+ || status.version?.webSocketDebuggerUrl !== record.webSocketDebuggerUrl)
63
+ return false;
64
+ // This metadata probe must not grant daemon provenance just by spawning.
65
+ const connection = await withAttachedBrowserSessionContext(true, () => withOwnedManagedSessionContext(undefined, () => runSessionCommandData({
66
+ args: ["get", "cdp-url"], cwd, env: getHeadedManagedAutosaveEnv(context.headedManagedAutosaveInterval), namespace: context.namespace, pinNamespace: true,
67
+ sessionName: context.sessionName, signal, timeoutMs,
68
+ })));
69
+ return !signal?.aborted && isRecord(connection) && typeof connection.cdpUrl === "string"
70
+ && (connection.cdpUrl === record.webSocketDebuggerUrl || status.targets.some((target) => target.webSocketDebuggerUrl === connection.cdpUrl));
71
+ }
52
72
  export async function acquireOwnedManagedSessionDaemonPolicy(options) {
53
73
  const { context, signal } = options;
54
74
  if (!context.cwd)
@@ -81,7 +101,7 @@ export async function acquireOwnedManagedSessionDaemonPolicy(options) {
81
101
  return { daemonStatus: daemon.status, lock };
82
102
  }
83
103
  const stickyDisabled = context.restoreState.isDisabled(context.sessionName, context.namespace);
84
- const hasKnownDaemonRestoreKey = context.restoreState.hasDaemonRestoreKey(context.sessionName, context.namespace);
104
+ let hasKnownDaemonRestoreKey = context.restoreState.hasDaemonRestoreKey(context.sessionName, context.namespace);
85
105
  const knownDaemonRestoreKey = context.restoreState.getDaemonRestoreKey(context.sessionName, context.namespace);
86
106
  const requestedDaemonRestoreKey = context.restoreDecision === "enabled" && stickyDisabled
87
107
  ? knownDaemonRestoreKey ?? null
@@ -93,6 +113,9 @@ export async function acquireOwnedManagedSessionDaemonPolicy(options) {
93
113
  };
94
114
  }
95
115
  const restoreDisabledPolicyNeedsProvenance = stickyDisabled || context.restoreDecision !== "enabled";
116
+ if (daemon.status === "active" && restoreDisabledPolicyNeedsProvenance && !hasKnownDaemonRestoreKey && daemon.restoreKey === requestedDaemonRestoreKey) {
117
+ hasKnownDaemonRestoreKey = await verifyRestoredElectronAttachment(context, options.electronLaunchRecord, signal, options.electronVerificationTimeoutMs);
118
+ }
96
119
  const activePolicyMatches = daemon.status === "active"
97
120
  && (!restoreDisabledPolicyNeedsProvenance || hasKnownDaemonRestoreKey)
98
121
  && daemon.restoreKey === requestedDaemonRestoreKey;
@@ -22,7 +22,7 @@ import { buildOwnedManagedSessionRestoreContext, resolveExplicitAutosaveInterval
22
22
  import { getAgentBrowserProcessEnvironment } from "../../process-environment.js";
23
23
  import { getExplicitSessionPageVerificationRequirement, getPageTargetValidationError, } from "../../page-target-validation.js";
24
24
  import { acquireOwnedManagedSessionDaemonPolicy, getRunningHeadedAutosavePolicyChangeError } from "./managed-session-daemon-policy.js";
25
- import { buildManagedSessionOutcome, buildSessionDetailFields, buildStaleRefPreflight, getSessionContextKey, extractStringResultField, ensureSessionTabTarget, getGuardedRefUsage, getTraceOwnerGuardMessage, runSessionCommandData, shouldPinSessionTabForCommand, } from "./session-state.js";
25
+ import { buildManagedSessionOutcome, buildSessionDetailFields, buildStaleRefPreflight, getSessionContextKey, findElectronLaunchRecordForSession, extractStringResultField, ensureSessionTabTarget, getGuardedRefUsage, getTraceOwnerGuardMessage, runSessionCommandData, shouldPinSessionTabForCommand, } from "./session-state.js";
26
26
  import { getUpstreamEffectiveBatchSteps, parseBatchStdinJsonArray } from "../batch-stdin.js";
27
27
  import { buildElectronHostFailureResult, formatAgentBrowserNextActionsText, getElectronLaunchFailureCategory, redactRecoveryHint } from "./final-result.js";
28
28
  import { prepareClickDispatchProbe } from "./click-dispatch.js";
@@ -508,6 +508,8 @@ export async function prepareBrowserRun(options) {
508
508
  const closeCommand = isCloseCommand(executionPlan.commandInfo.command);
509
509
  const policy = await acquireOwnedManagedSessionDaemonPolicy({
510
510
  context: ownedManagedSession,
511
+ electronLaunchRecord: findElectronLaunchRecordForSession(executionPlan.sessionName, state.electronLaunchRecords, executionPlan.namespace),
512
+ electronVerificationTimeoutMs: params.timeoutMs,
511
513
  mode: closeCommand ? "close" : "reuse",
512
514
  signal,
513
515
  });
@@ -483,12 +483,13 @@ function selectAnySessionTargetTab(options) {
483
483
  return selection ? { ...selection, ...(targetTitle ? { targetTitle } : {}), targetUrl } : undefined;
484
484
  }
485
485
  export async function runSessionCommandData(options) {
486
- const { args, cwd, namespace, pinNamespace, sessionName, signal, stdin, throwOnFailure, timeoutMs } = options;
486
+ const { args, cwd, env, namespace, pinNamespace, sessionName, signal, stdin, throwOnFailure, timeoutMs } = options;
487
487
  if (!sessionName)
488
488
  return undefined;
489
489
  const processResult = await runAgentBrowserProcess({
490
490
  args: ["--json", ...(namespace !== undefined || pinNamespace ? ["--namespace", namespace ?? ""] : []), "--session", sessionName, ...args],
491
491
  cwd,
492
+ env,
492
493
  signal,
493
494
  stdin,
494
495
  timeoutMs,
@@ -414,7 +414,7 @@ async function withOwnedElectronManagedSessionPolicy(options, run) {
414
414
  throw new ElectronManagedSessionPolicyError("Electron helper could not establish wrapper ownership for its managed session.");
415
415
  let policy;
416
416
  try {
417
- policy = await acquireOwnedManagedSessionDaemonPolicy({ context, signal: options.signal });
417
+ policy = await acquireOwnedManagedSessionDaemonPolicy({ context, electronLaunchRecord: options.electronLaunchRecord, electronVerificationTimeoutMs: options.timeoutMs, signal: options.signal });
418
418
  }
419
419
  catch (error) {
420
420
  throw new ElectronManagedSessionPolicyError(error instanceof Error ? error.message : String(error), { cause: error });
@@ -675,6 +675,7 @@ async function handleElectronHostInputInContext(options) {
675
675
  const sessionKey = getSessionPageStateKey(record.sessionName, record.namespace) ?? record.sessionName;
676
676
  return collectOwnedElectronManagedSessionTarget({
677
677
  cwd,
678
+ electronLaunchRecord: record,
678
679
  headedManagedAutosaveDisabled: ownedManagedSessions.get(sessionKey)?.headedManagedAutosaveDisabled,
679
680
  headedManagedAutosaveInterval: ownedManagedSessions.get(sessionKey)?.headedManagedAutosaveInterval,
680
681
  namespace: record.namespace,
@@ -750,12 +751,14 @@ async function handleElectronHostInputInContext(options) {
750
751
  const probe = await withOwnedElectronManagedSessionPolicy({
751
752
  args: ["snapshot", "-i"],
752
753
  cwd,
754
+ electronLaunchRecord: launchRecord,
753
755
  headedManagedAutosaveDisabled,
754
756
  headedManagedAutosaveInterval,
755
757
  namespace: probeNamespace,
756
758
  restoreState: managedSessionRestoreState,
757
759
  sessionName: probeSessionName,
758
760
  signal,
761
+ timeoutMs: compiledElectron.timeoutMs,
759
762
  }, async () => await collectElectronProbe({ cwd, namespace: probeNamespace, sessionName: probeSessionName, signal, timeoutMs: compiledElectron.timeoutMs }));
760
763
  const managedSession = {
761
764
  sessionName: probe.sessionName,
@@ -319,6 +319,20 @@ async function assertSecureTempRootBudget(tempRoot, additionalBytes) {
319
319
  throw new Error(`pi-agent-browser temp spill budget exceeded (${nextBytes} bytes > ${maxBytes} byte limit).`);
320
320
  }
321
321
  }
322
+ export async function preserveSecureTempDirectory(path) {
323
+ await enqueueTempMutation(async () => {
324
+ const childPath = resolve(path);
325
+ const tempRoot = dirname(childPath);
326
+ if (!ownedTempRoots.has(tempRoot) || !getProtectedTempChildName(tempRoot, childPath) || !(await stat(childPath)).isDirectory()) {
327
+ throw new Error(`Cannot preserve ${path}; expected an existing child directory of a currently owned temp root.`);
328
+ }
329
+ protectedTempChildren.add(childPath);
330
+ await persistProtectedTempChildren(tempRoot, new Set([childPath]));
331
+ if (!getPersistedProtectedChildPaths(tempRoot, await readTempRootOwnershipMarker(tempRoot)).has(childPath)) {
332
+ throw new Error(`Could not persist temp directory preservation for ${path}.`);
333
+ }
334
+ });
335
+ }
322
336
  export async function cleanupSecureTempArtifacts(options = {}) {
323
337
  await enqueueTempMutation(async () => {
324
338
  const tempRoot = await sessionTempRootPromise?.catch(() => undefined);
@@ -169,7 +169,7 @@ Practical policy:
169
169
  - keep process-owned cleanup registries for extension-managed sessions and wrapper-launched Electron records separate from the current branch-visible view; `session_tree` restore and wrapper-owned browser commands are serialized with managed-session work, while caller-owned explicit-session commands are serialized by process-local queues keyed to effective canonical namespace/session across prepare helpers (explicit namespace argv overrides inherited `AGENT_BROWSER_NAMESPACE`, including an explicit empty default) and main execution. macOS and Windows additionally normalize and case-fold namespace and session components to match case-insensitive daemon identity. Different caller-owned identities remain concurrent, except namespace-scoped `close --all` drains and exclusively barriers managed plus matching caller-owned work before clearing global namespace state; nested helpers never re-enter the outer queue, policy/route/artifact deltas merge across unrelated managed-state commits, and a separate branch-restore generation guard prevents stale completions from overwriting newer branch-visible state; aggregate artifact results use monotonic revisions so transcript replay cannot lose a concurrently completed entry. Branch switches still must not drop resources the current Pi process owns and must keep fresh-session allocation monotonic
170
170
  - record successful `connect`, `--cdp`, enabled `--auto-connect`, environment-configured CDP/auto-connect, and wrapper Electron attachment identities in branch-visible state. First-use and later content-bearing calls live-check `get url` because attached targets can drift outside Pi. Caller config, file access, launch arguments, and environment pass through unchanged; only wrapper-injected compatibility launch arguments are omitted on active attachments. A terminal successful close removes the marker; a close followed by a later step whose lifecycle reports a browser launch preserves it, while a non-launching diagnostic such as `stream status` leaves the close terminal
171
171
  - when a successful close targets the current extension-managed session, including an explicit `--session <current> close` or an `electron.cleanup` managed-session step, clear page/ref state, mark that session inactive, untrack cleanup ownership, and rotate the next default auto call to a fresh wrapper-generated session name rather than reusing the closed name
172
- - on non-quit shutdown such as `/reload`, close off-branch owned managed sessions and off-branch owned Electron launches before clearing process-local ownership, but preserve the current branch-visible active managed session and Electron launch plus that launch's isolated `userDataDir` so reload continuity still works from the active transcript branch
172
+ - on non-quit shutdown such as `/reload`, close off-branch owned managed sessions and off-branch owned Electron launches before clearing process-local ownership, but preserve the current branch-visible active managed session and Electron launch plus that launch's isolated `userDataDir` so reload continuity still works from the active transcript branch. First reuse of a restored Electron attachment checks the recorded namespace/session, live PID/profile presence, and exact browser WebSocket identity against current CDP metadata, then uses upstream `get cdp-url` to verify that the named daemon still targets that browser or one of its current pages. The metadata probe cannot commit daemon provenance merely by spawning; only a matching result permits the existing policy to record it. Ordinary calls, status, and probe share this path under the existing lock. Generic restore-disabled sessions, mismatched connections, quit, and off-branch cleanup are unchanged
173
173
  - expose still-owned off-branch Electron launch records to `electron.status { launchId }`, `electron.status { all: true }`, `electron.probe { launchId }`, and `electron.cleanup`, while leaving default `electron.probe` scoped to the current managed session
174
174
  - if an unnamed fresh launch replaces an active extension-managed session, best-effort close the old managed session after the switch succeeds; `managedSessionOutcome.replacedSessionClosed` records whether that cleanup succeeded, and a failed close keeps the older identity wrapper-owned across transcript resume for explicit follow-up or cleanup
175
175
  - expose `details.browserWindow` and one visible login handoff only when a successful first/fresh local wrapper-managed headed result, including `batch`, is not an attachment and has upstream `lifecycle.effectiveLaunch.browserLaunched: true` and a `created`/`replaced` managed-session outcome. Keep `visibility: "unverified"`: this is launch evidence, never a claim about the user's OS desktop
@@ -14,6 +14,8 @@ Provide a local, repo-readable command reference for the native `agent_browser`
14
14
 
15
15
  This project intentionally blocks normal `agent-browser` bash usage in most agent sessions, so the agent still needs an accessible local equivalent of the upstream command surface. This document is the durable reference the agent can read inside the repository without calling the binary directly.
16
16
 
17
+ After updating `pi-agent-browser-native`, fully quit and restart Pi before using the updated tools. `/reload` can retain previously loaded compiled JavaScript even after `dist/` is rebuilt, so it is not a reliable way to pick up package updates.
18
+
17
19
  ## Upstream baseline
18
20
 
19
21
  <!-- agent-browser-capability-baseline:start upstream-baseline -->
@@ -461,13 +463,13 @@ Typical lifecycle:
461
463
  { "electron": { "action": "cleanup", "launchId": "electron-…" } }
462
464
  ```
463
465
 
464
- `electron.status` and `electron.cleanup` take either `launchId`, **`all: true`** (literal boolean) to walk every active wrapper-tracked launch (including dead, failed, or partial records, but excluding cleaned records), or neither when exactly one active launch exists—never both `launchId` and `all`. They can target the current branch-visible launch plus still-owned off-branch launch records by `launchId`; default no-arg calls are intentionally ambiguous when more than one active launch is owned. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches; if cleanup is partial and skips or fails profile removal, the generic temp sweep preserves that `userDataDir` across reload, quit, later temp cleanup, process exit, and stale temp-root pruning after restart. `electron.list` has no configurable timeout and rejects both top-level and nested `timeoutMs`. For `electron.launch`, nested `timeoutMs` sets host CDP readiness polling to a **15s** default and **120s** cap after target discovery; upstream attach and handoff use separate subprocess budgets. Optional `timeoutMs` on **`status`** applies to managed-session `get url`, then `get title` reads (localhost CDP probes stay on a short fixed fetch budget). On **`cleanup`**, it is applied separately to upstream `close` and the initial host process-exit wait, not to the entire teardown; debug-port checks have fixed fetch budgets and profile removal has no configurable deadline; when omitted it follows the implicit session close default (**5s** unless `PI_AGENT_BROWSER_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS` overrides). A successful managed-session close step retires that wrapper-managed session even when host process/profile cleanup remains partial. On **`probe`**, it bounds each underlying upstream read subprocess—omit it to use the normal tool subprocess default, or raise it on slow desktops.
466
+ `electron.status` and `electron.cleanup` take either `launchId`, **`all: true`** (literal boolean) to walk every active wrapper-tracked launch (including dead, failed, or partial records, but excluding cleaned records), or neither when exactly one active launch exists—never both `launchId` and `all`. They can target the current branch-visible launch plus still-owned off-branch launch records by `launchId`; default no-arg calls are intentionally ambiguous when more than one active launch is owned. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches. First reuse after reload/resume checks the live app's saved debug endpoint and the exact named upstream connection with `get cdp-url`, without reconnecting or resetting page refs; if cleanup is partial and skips or fails profile removal, the generic temp sweep preserves that `userDataDir` across reload, quit, later temp cleanup, process exit, and stale temp-root pruning after restart. `electron.list` has no configurable timeout and rejects both top-level and nested `timeoutMs`. For `electron.launch`, nested `timeoutMs` sets host CDP readiness polling to a **15s** default and **120s** cap after target discovery; upstream attach and handoff use separate subprocess budgets. Optional `timeoutMs` on **`status`** applies to each managed-session `get url` / `get title` read and any `get cdp-url` read needed to verify a restored connection (localhost CDP probes stay on a short fixed fetch budget). On **`cleanup`**, it is applied separately to upstream `close` and the initial host process-exit wait, not to the entire teardown; debug-port checks have fixed fetch budgets and profile removal has no configurable deadline; when omitted it follows the implicit session close default (**5s** unless `PI_AGENT_BROWSER_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS` overrides). A successful managed-session close step retires that wrapper-managed session even when host process/profile cleanup remains partial. On **`probe`**, it bounds each underlying upstream read subprocess—omit it to use the normal tool subprocess default, or raise it on slow desktops.
465
467
 
466
468
  Explicit-ID `electron.status` labels a cleaned record as historical while measuring PID/port liveness independently. `details.electron.statuses[].userDataDirState` freshly reports the tracked profile path as `present`, `absent` (only ENOENT), or `unknown` (other native `lstat` errors); dangling symlinks are present. This is not an audit of all app residue and does not change stored launch records or cleanup ownership.
467
469
 
468
470
  `launch.handoff` defaults to `"snapshot"`, which attaches through upstream `connect`, lists targets, and captures a current `snapshot -i` in one call. Snapshot handoff retries briefly when the first Electron snapshot has no refs; if it still reports no refs, run `snapshot -i` once more before assuming the app is blank. Use `handoff: "tabs"` as the safer diagnostic starting point when you only need target discovery and do not want to snapshot app content yet, or `handoff: "connect"` when you want to attach first and run your own follow-up commands. `targetType` defaults to `"page"`; use `"webview"` or `"any"` for apps that expose useful webviews. When a matching CDP target exposes a WebSocket URL, launch connects to that target; otherwise it falls back to the browser port.
469
471
 
470
- After launch, prefer the exact `details.nextActions` payloads when present: `status-electron-launch` checks liveness, `probe-electron-launch` runs compact diagnostics for a tracked launch, `snapshot-electron-session` refreshes current refs, `list-electron-tabs` inspects targets, and `cleanup-electron-launch` removes the wrapper-owned process/profile when the run is done. If launch times out, inspect `details.electron.failure.diagnostics` for PID, wrapper profile, `DevToolsActivePort`, and timing evidence before retrying. If status/probe detects a session or target mismatch, follow `reattach-electron-launch` or a fresh snapshot action before using old refs. If a click/fill/type looks successful but the Electron PID or debug port dies, the wrapper now fails the result with `details.electronPostCommandHealth` and same-launch status/probe/cleanup next actions instead of leaving the agent on `about:blank`. If cleanup is partial (`failureCategory: "cleanup-failed"`), inspect `details.electron.cleanup.results` and use `retry-electron-cleanup` only for the same `launchId`.
472
+ After launch, prefer the exact `details.nextActions` payloads when present: `status-electron-launch` checks liveness, `probe-electron-launch` runs compact diagnostics for a tracked launch, `snapshot-electron-session` refreshes current refs, `list-electron-tabs` inspects targets, and `cleanup-electron-launch` removes the wrapper-owned process/profile when the run is done. If startup fails, inspect the redacted stdout/stderr tails in visible error text and `details.electron.failure.diagnostics`, plus PID, wrapper profile, `DevToolsActivePort`, and timing evidence before retrying. Tails read at most 4096 source bytes per stream; private log files follow profile cleanup/preservation and are not lifetime-size-capped. If status/probe detects a session or target mismatch, follow `reattach-electron-launch` or a fresh snapshot action before using old refs. If a click/fill/type looks successful but the Electron PID or debug port dies, the wrapper now fails the result with `details.electronPostCommandHealth` and same-launch status/probe/cleanup next actions instead of leaving the agent on `about:blank`. If cleanup is partial (`failureCategory: "cleanup-failed"`), inspect `details.electron.cleanup.results` and use `retry-electron-cleanup` only for the same `launchId`.
471
473
 
472
474
  Manual path for externally launched apps: if you started the Electron app yourself with a debug port or DevTools URL, skip the wrapper lifecycle and attach directly with upstream `connect`. In this path you own app shutdown and profile cleanup; do not use `electron.cleanup`. close commands (`close`, `quit`, or `exit`) only close the browser/CDP session and do not quit the manually launched app or remove explicit artifacts.
473
475
 
package/docs/ELECTRON.md CHANGED
@@ -200,7 +200,7 @@ Closes the tracked managed session, stops only the wrapper-tracked process, veri
200
200
 
201
201
  For manual launches, close commands (`close`, `quit`, or `exit`) only close the browser/CDP session. Close the app yourself and clean its profile/temp files with normal host tools.
202
202
 
203
- On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On `/reload`, the current branch-visible active Electron launch and its isolated temp `userDataDir` are preserved for continuity while off-branch owned Electron launches are cleaned before process-local ownership is cleared. If cleanup is partial and skips or fails `user-data-dir` removal because the process or debug port is still live, the generic temp sweep preserves that profile path across reload, quit, repeated temp cleanup, process-exit cleanup, and stale temp-root pruning after restart rather than deleting it out from under the remaining host resource. If `electron.cleanup` closes the attached managed session but host process/profile cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. Stale restored records (PID gone, port dead) are **reported** instead of guessed at or killed.
203
+ On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On `/reload`, the current branch-visible active Electron launch and its isolated temp `userDataDir` are preserved for continuity while off-branch owned Electron launches are cleaned before process-local ownership is cleared. First reuse after reload/resume checks that the PID and profile are present, the saved browser WebSocket endpoint still matches the live app, and upstream `get cdp-url` points to that browser or one of its current targets. Ordinary browser calls, status reads, and probes share this check under the existing session lock; no reconnect or ref reset is needed. The `get cdp-url` read honors the caller's `timeoutMs` and cancellation, while localhost CDP requests keep their short fixed fetch budgets. Missing or mismatched evidence does not grant reuse, and generic restore-disabled sessions keep their existing rules. If cleanup is partial and skips or fails `user-data-dir` removal because the process or debug port is still live, the generic temp sweep preserves that profile path across reload, quit, repeated temp cleanup, process-exit cleanup, and stale temp-root pruning after restart rather than deleting it out from under the remaining host resource. If `electron.cleanup` closes the attached managed session but host process/profile cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. Stale restored records (PID gone, port dead) are **reported** instead of guessed at or killed.
204
204
 
205
205
  ### `timeoutMs` by action (quick reference)
206
206
 
@@ -209,9 +209,9 @@ On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On
209
209
  | Action | What `timeoutMs` covers when set | Typical default when omitted |
210
210
  | --- | --- | --- |
211
211
  | `launch` | Host-side wait for `DevToolsActivePort` and CDP readiness | **15 s**, hard-capped at **120 s** (`normalizeTimeoutMs` in `extensions/agent-browser/lib/electron/launch.ts`) |
212
- | `status` | Each optional managed-session `get url` / `get title` subprocess used for mismatch diagnostics | Normal wrapper subprocess budget (**35 s**, or `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS`); localhost CDP probes use **1000 ms** each (`ELECTRON_CDP_FETCH_TIMEOUT_MS` in `extensions/agent-browser/lib/electron/cdp.ts`) |
212
+ | `status` | Each optional managed-session `get url` / `get title` subprocess, including `get cdp-url` when verifying a restored connection | Normal wrapper subprocess budget (**35 s**, or `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS`); localhost CDP probes use **1000 ms** each (`ELECTRON_CDP_FETCH_TIMEOUT_MS` in `extensions/agent-browser/lib/electron/cdp.ts`) |
213
213
  | `cleanup` | Applied separately to managed-session `close` and the initial tracked-process exit wait; not a deadline for debug-port checks or profile removal | `PI_AGENT_BROWSER_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS` when set, else **5000 ms** (`getImplicitSessionCloseTimeoutMs` in `extensions/agent-browser/lib/runtime.ts`, passed through `cleanupTrackedElectronHostLaunches` in `extensions/agent-browser/lib/orchestration/electron-host/index.ts`) |
214
- | `probe` | **Each** upstream read in the probe chain (`get url`, then `get title`, focused `eval --stdin`, `tab list`, `snapshot -i`) | Same wrapper subprocess default (**35 s**, or `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS`, from `getAgentBrowserProcessTimeoutMs` in `extensions/agent-browser/lib/process.ts`) |
214
+ | `probe` | **Each** upstream read: optional `get cdp-url` verification, then `get url`, `get title`, focused `eval --stdin`, `tab list`, and `snapshot -i` | Same wrapper subprocess default (**35 s**, or `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS`, from `getAgentBrowserProcessTimeoutMs` in `extensions/agent-browser/lib/process.ts`) |
215
215
 
216
216
  ## `qa.attached` — current-session smoke check
217
217
 
@@ -311,6 +311,10 @@ Policy mismatches fail with `failureCategory: "policy-blocked"` and `details.ele
311
311
  | `cleanup-failed` | Cleanup only partially succeeded | Inspect `details.electron.cleanup.results[].steps` for remaining process/port/profile state; `retry-electron-cleanup` references the same `launchId` |
312
312
  | `stale-ref` | `@e…` ref reused after a navigation/rerender | Take a fresh `snapshot -i` (or follow `refresh-electron-refs-after-rerender` when the wrapper appends it) |
313
313
 
314
+ Failed startup diagnostics include `outputCaptured`, `stdoutTail` / `stderrTail`, and `stdoutTruncated` / `stderrTruncated`. Each tail reads at most the last **4096 source bytes** before UTF-8 decoding and normal credential redaction, and appears in both visible failure text and structured details. Empty output is reported explicitly; `stdoutError` / `stderrError` report capture-read or close errors without replacing the original startup reason, exit status, or cleanup warning.
315
+
316
+ The app writes to mode-0600 `stdout.log` and `stderr.log` inside its isolated profile. These are regular files, not pipes to Pi, so retained apps can keep writing after reload or host exit. Logs follow profile preservation and removal; **the read limit is not a lifetime disk limit**. If failed-startup process cleanup cannot finish, the profile and logs are protected from general temp cleanup. Any failure to persist that protection appears alongside the original `failure.cleanupError`; in-memory protection remains. Use the reported PID and profile path to resolve that failed cleanup before removing files.
317
+
314
318
  Single-instance Electron behavior is a common cause of `timeout` and `upstream-error`. Many Electron apps enforce a single running instance and silently drop a second invocation's `--remote-debugging-port` flag. If the app is already running without a debug port, quit it first or use the manual host-launch path against the existing instance instead.
315
319
 
316
320
  ## Troubleshooting
@@ -319,7 +323,7 @@ Single-instance Electron behavior is a common cause of `timeout` and `upstream-e
319
323
  - The app is enforcing single-instance; quit the running copy first, then retry.
320
324
  - The app may have moved its Electron framework directory; pass `executablePath` explicitly.
321
325
  - `timeoutMs` is too short for a heavy app; raise it (`launch.timeoutMs` is bounded but generous).
322
- - Read `details.electron.failure.diagnostics`: presence/absence of `DevToolsActivePort`, port number, PID liveness, and elapsed time usually identify the issue.
326
+ - Read the redacted stdout/stderr tails in the failure text or `details.electron.failure.diagnostics` first; dependency and startup errors often explain the failure. `DevToolsActivePort`, port number, PID liveness, and timing provide the remaining context.
323
327
 
324
328
  ### `electron.list` returns nothing
325
329
  - On Linux, the binary may be a custom rebrand without `chrome_*.pak` siblings, an AppImage without a `.desktop` entry, or a statically linked fork. Pass `executablePath` directly.
package/docs/RELEASE.md CHANGED
@@ -316,6 +316,8 @@ Recommended configured-source lifecycle follow-up:
316
316
 
317
317
  ## Post-publish install validation
318
318
 
319
+ After updating `pi-agent-browser-native`, fully quit and restart Pi before using the updated tools. `/reload` can retain previously loaded compiled JavaScript even after `dist/` is rebuilt, so it is not a reliable way to pick up package updates.
320
+
319
321
  After publishing a release, validate the package-first path in isolation. `npm run verify -- release` includes the deterministic fake-binary packaged execution gate and the pre-publish Crabbox platform matrix, but it does not replace a real-browser installed-package smoke against the published npm package:
320
322
 
321
323
  ```bash
@@ -71,7 +71,7 @@ Current summary:
71
71
 
72
72
  Contributor fixes #133/#152 remove unused prompt suffix entries without changing runtime guidance and diagnose bare `--no-sandbox` only in the command slot or navigation option positions. Native `--args` values and literal operands remain intact; batch checks use raw effective rows without treating row-local `--args` as a launch setting. `test/agent-browser.chromium-args.test.ts` covers pre-dispatch rejection, literal/flag-value controls, inspection, raw/stdin batch precedence and dispatcher outcomes. Existing prompt/grammar checks remain; source checks do not replace native-product gates.
73
73
 
74
- Electron diagnostics (RQ-0096, #128) keep list timeout rejection truthful, label explicit-ID cleaned records as historical without changing active selection or actions, and measure the tracked profile path with native `lstat` (`present` / ENOENT-only `absent` / `unknown`). Existing Electron discovery/lifecycle tests cover current liveness independently of cleanup history, native path errors and dangling symlinks, transcript replay, and unchanged cleanup ownership. Failed-launch stdout/stderr capture remains unresolved; #128 is partial, and these source checks do not replace native-app, Pi lifecycle, package, or live-site gates.
74
+ Electron diagnostics (RQ-0096, #128) keep list timeout rejection truthful, label explicit-ID cleaned records as historical without changing active selection or actions, and measure the tracked profile path with native `lstat` (`present` / ENOENT-only `absent` / `unknown`). Existing Electron discovery/lifecycle tests cover current liveness independently of cleanup history, native path errors and dangling symlinks, transcript replay, and unchanged cleanup ownership. Failed-startup diagnostics include redacted last-4096-byte stdout/stderr tails in visible text and structured details, using private profile-local regular files. Regression tests cover exact tails, empty output, capture/spawn errors and closed native file handles; a real spawned fixture with injected kill denial proves profile/log preservation through temp cleanup and host exit, including persistence failure reporting. The shared daemon policy verifies a restored Electron attachment's live browser endpoint and named upstream `get cdp-url` before ordinary calls, status, or probe reuse; `test/agent-browser.extension-ref-guards.test.ts` covers successful reuse and repeated rejection of replaced app, connection, and namespace identities without weakening generic restore-disabled rules. These checks do not replace native-app, Pi lifecycle, package, or live-site gates.
75
75
 
76
76
  Artifact diagnostics (#124/#127) use a shared pre-dispatch mkdir-error boundary, preserve raw batch argv/precedence, recommend absolute raw artifact paths, recognize image headers rather than filename MIME guesses, retain known requested/reported paths, and warn once for dispatched recording page transitions on success or failure. `test/agent-browser.artifact-diagnostics.test.ts` covers registered filesystem failures, real image bytes and misleading suffixes, the inline bound, native macOS path aliases, recording/ref continuity and unreached-row negatives; `test/agent-browser.presentation-artifacts-batch.test.ts` retains artifact/persistence coverage. These source regressions do not qualify daemon-cwd differences or affected-filesystem timestamp behavior (#118), or replace native/Pi/package/live-site gates.
77
77
 
@@ -461,7 +461,7 @@ Validation and defaults:
461
461
  - `allow` and `deny` are optional caller-owned policy lists. Entries match app name, bundle id, desktop id, app path, or executable path by substring. If `allow` is set, the target must match it; `deny` wins on conflict. With neither list, launch is permitted.
462
462
  - `electron.status` / `electron.cleanup` accept optional `all` only as the boolean literal `true` to include every active wrapper-tracked launch (including dead, failed, or partial records, but excluding cleaned records); `all` and `launchId` cannot both be set. Status and cleanup use the same runtime wrapper-tracked scope: current branch-visible records plus still-owned off-branch records. Default no-argument status/cleanup is intentionally ambiguous when more than one active launch is in that merged scope; pass `launchId` or `all: true`.
463
463
  - `electron.launch.timeoutMs` sets the host CDP readiness polling budget: **15000 ms** by default, capped at **120000 ms** (`normalizeTimeoutMs` in `extensions/agent-browser/lib/electron/launch.ts`). Its clock starts after target discovery and policy checks, before creating the isolated profile. Discovery has no configurable deadline; upstream attach and handoff use separate subprocess budgets.
464
- - `status.timeoutMs` applies to each managed-session `get url` / `get title` subprocess for mismatch diagnostics. `probe.timeoutMs` applies to each upstream read (`get url`, `get title`, `eval --stdin`, `tab list`, `snapshot -i`). Their default wrapper budget is **35000 ms**, overridden by `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS` (`getAgentBrowserProcessTimeoutMs` in `extensions/agent-browser/lib/process.ts`). Localhost CDP HTTP probes use a fixed **1000 ms** each (`ELECTRON_CDP_FETCH_TIMEOUT_MS` in `extensions/agent-browser/lib/electron/cdp.ts`). Profile-path inspection has no configurable timeout.
464
+ - `status.timeoutMs` applies to each managed-session `get url` / `get title` subprocess for mismatch diagnostics, including `get cdp-url` when verifying a restored connection. `probe.timeoutMs` applies to each upstream read (optional `get cdp-url` verification, then `get url`, `get title`, `eval --stdin`, `tab list`, `snapshot -i`). Their default wrapper budget is **35000 ms**, overridden by `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS` (`getAgentBrowserProcessTimeoutMs` in `extensions/agent-browser/lib/process.ts`). Localhost CDP HTTP probes use a fixed **1000 ms** each (`ELECTRON_CDP_FETCH_TIMEOUT_MS` in `extensions/agent-browser/lib/electron/cdp.ts`). Profile-path inspection has no configurable timeout.
465
465
  - `cleanup.timeoutMs` is applied separately to the managed-session `close` subprocess and the initial host process-exit wait, not one combined deadline. It defaults to `PI_AGENT_BROWSER_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS` or **5000 ms** (`getImplicitSessionCloseTimeoutMs` in `extensions/agent-browser/lib/runtime.ts`). Restored-PID verification and the later force-kill wait each have separate **1000 ms** limits; debug-port checks use the fixed CDP fetch budget, and profile removal has no configurable deadline.
466
466
  - Non-Electron targets are rejected as a correctness failure; the wrapper does not blindly launch arbitrary executables as Electron.
467
467
 
@@ -472,7 +472,7 @@ Safety defaults and ownership:
472
472
  - Remote debugging exposes app contents to the attached browser tool. The wrapper gives isolation defaults and optional `allow` / `deny`; the user still owns the decision to launch or attach to a sensitive desktop app.
473
473
  - `electron.list` may annotate apps as likely sensitive (`sensitivity.level: "likely-sensitive"`, categories such as `notes`, `chat`, `mail`, `developer-workspace`, or `passwords-auth`) and print `[likely sensitive: …]`. These annotations are non-blocking hints, not enforcement; caller-owned `allow` / `deny` policy still controls launch decisions.
474
474
  - Cleanup is wrapper-owned **only** for records created by `electron.launch`. `electron.cleanup` never targets manually launched apps, externally supplied debug ports, or arbitrary Electron processes. Explicit screenshots/downloads/HARs/traces remain host-file cleanup, not Electron cleanup. If `electron.cleanup` closes the upstream managed session but process/profile cleanup remains partial, later shutdown cleanup does not close that managed session a second time; retry cleanup focuses on the remaining host resources.
475
- - On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On `/reload`, current branch-visible active Electron launches are preserved for reload continuity, including their isolated `userDataDir` profile directories, while off-branch owned launches are cleaned before process-local ownership is cleared. If cleanup is partial and deliberately skips or fails `user-data-dir` removal because the process or debug port is still live, generic temp cleanup preserves that profile path across reload, quit, later temp sweeps, process-exit cleanup, and stale temp-root pruning after restart instead of deleting it underneath the remaining host resource. Stale restored records are reported instead of guessed/killed when the wrapper lacks a live child process.
475
+ - On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On `/reload`, current branch-visible active Electron launches are preserved for reload continuity, including their isolated `userDataDir` profile directories, while off-branch owned launches are cleaned before process-local ownership is cleared. First reuse after reload/resume verifies the recorded namespace/session, live PID/profile presence, and saved browser WebSocket endpoint; native `get cdp-url` must match that browser or one of its current targets. Ordinary browser calls, status reads, and probes share the existing locked check, without reconnecting or resetting refs. The `get cdp-url` read uses the caller's `timeoutMs` and cancellation; localhost CDP fetch budgets are unchanged. Failed checks do not grant reuse or relax the generic restore-disabled-session policy. If cleanup is partial and deliberately skips or fails `user-data-dir` removal because the process or debug port is still live, generic temp cleanup preserves that profile path across reload, quit, later temp sweeps, process-exit cleanup, and stale temp-root pruning after restart instead of deleting it underneath the remaining host resource. Stale restored records are reported instead of guessed/killed when the wrapper lacks a live child process.
476
476
 
477
477
  Details fields:
478
478
 
@@ -519,7 +519,7 @@ Details fields:
519
519
  Action-specific `details.electron` fields:
520
520
 
521
521
  - `list`: `{ action: "list", status: "succeeded", apps, platform, query?, maxResults, skippedCount, omittedCount?, sensitiveAppCount?, profileIsolation }`. Each app is platform-tagged and may include `name`, `bundleId`, `desktopId`, `appPath`, `executablePath`, `icon`, `packageSource`, and non-blocking `sensitivity` metadata depending on platform/discovery source.
522
- - `launch`: `{ action: "launch", status, launch, targets?, version?, handoff?, cleanup?, identifiers?, profileIsolation }`. `profileIsolation` states that wrapper launches use a new temporary profile, do not reuse existing signed-in app state, and do not attach to already-running authenticated apps; it also includes host debug-launch guidance for the separate normal-app attach path. `identifiers` repeats the launch-scoped `launchId` and attached `sessionName` so agents distinguish Electron lifecycle actions from browser session/tab actions. `launch.cleanupState` is one of `"active"`, `"cleaned"`, `"dead"`, `"failed"`, or `"partial"`. Failed launches expose `details.electron.failure.diagnostics` when available, including `pid` / `pidAlive`, wrapper `userDataDir`, elapsed/timeout timing, `DevToolsActivePort` file state, discovered port, and whether CDP `/json/version` was reached.
522
+ - `launch`: `{ action: "launch", status, launch, targets?, version?, handoff?, cleanup?, identifiers?, profileIsolation }`. `profileIsolation` states that wrapper launches use a new temporary profile, do not reuse existing signed-in app state, and do not attach to already-running authenticated apps; it also includes host debug-launch guidance for the separate normal-app attach path. `identifiers` repeats the launch-scoped `launchId` and attached `sessionName` so agents distinguish Electron lifecycle actions from browser session/tab actions. `launch.cleanupState` is one of `"active"`, `"cleaned"`, `"dead"`, `"failed"`, or `"partial"`. Failed launches expose `details.electron.failure.diagnostics` when available, including `pid` / `pidAlive`, wrapper `userDataDir`, elapsed/timeout timing, `DevToolsActivePort` file state, discovered port, and whether CDP `/json/version` was reached. `outputCaptured` reports whether stdout/stderr capture was configured; readable streams include `stdoutTail` / `stderrTail` (empty strings for empty output) and `stdoutTruncated` / `stderrTruncated`. Each tail reads at most the last 4096 source bytes before UTF-8 decoding and credential redaction; the same tails appear in visible error text. Optional `stdoutError` / `stderrError` report read/close failures without hiding the primary failure. Capture files are mode-0600 `stdout.log` / `stderr.log` inside the isolated profile and follow its lifecycle; file growth is not capped by the tail-read limit. Failed-startup termination errors preserve the profile/logs through generic cleanup and host exit. Persistence failures join `failure.cleanupError` while in-memory protection remains.
523
523
  - `status`: `{ action: "status", status: "succeeded", launches, statuses, targets, identifiers?, identifierList?, managedSession?, managedSessions?, sessionMismatch?, sessionMismatches? }`, where each status includes the tracked `launchId`, `cleanupState`, independently measured port/pid liveness, bounded CDP targets, and fresh `userDataDirState`: `"present"`, `"absent"`, or `"unknown"`. Native `lstat` success means present (including dangling symlinks); only ENOENT means absent, and other filesystem errors mean unknown. This measures the tracked profile path, not all app residue, and is not stored in `ElectronLaunchRecord`. Explicit-ID status labels cleaned records as historical; default and `all: true` exclude them. Mismatch fields explain when the current managed session or tab does not match a live wrapper launch target.
524
524
  - `cleanup`: `{ action: "cleanup", status: "succeeded" | "partial", cleanup: { partial, records, results } }`. Partial cleanup is a failed tool result with `failureCategory: "cleanup-failed"` and retry next actions. Cleanup steps may include `managed-session`, `process`, `debug-port`, and `user-data-dir`; managed-session close failures are reported while host-owned process/profile cleanup still runs.
525
525
  - `probe`: `{ action: "probe", status: "succeeded" | "partial", probe, probeContext, identifiers?, sessionMismatch?, statusTargets?, launchStatus? }`. `probeContext` records whether the probe inspected the current managed session or a specific `launchId`. `probe` includes bounded `title`, `url`, `focusedElement`, `activeTab`, `tabs`, compact `snapshot` metadata (`refCount`, `refIds`, optional text preview and omission counts), `errors?`, and `summary`. When launch status is known, `launchStatus.userDataDirState` carries the same fresh profile-path measurement as `status`, and visible probe output includes debug-port/pid liveness so `about:blank` plus a dead wrapper launch is unmistakable. It also updates the normal session target/ref tracking when a snapshot is collected.
@@ -908,7 +908,7 @@ Additional structured fields can appear when relevant:
908
908
 
909
909
  Recording destinations are reserved within one Pi process, not across processes. Use unique paths for concurrent Pi processes: different explicit sessions can overwrite one file even when both `record stop` results are verified. Upstream’s same-session `record start` guard does not reserve the filename across other sessions.
910
910
  - `savedFilePath` / `savedFile` for direct `download`, `pdf`, and `wait --download` / `wait -d` saved-file workflows when a host file path is reported or wrapper-verified. Batch results preserve the same fields on the relevant `batchSteps` entry. These fields are metadata only until `artifactVerification` verifies the file. For simple loopback `download <selector> <path>` anchors with a non-ref selector, `details.downloadRecovery.method: "direct-anchor-fetch"` means the wrapper resolved the anchor URL in-session and saved the in-page HTTP(S) response directly to the requested path before using upstream's click/download fallback; non-loopback/profile downloads stay upstream-owned so external provider behavior is preserved.
911
- - `batchSteps[].artifacts` for per-step artifacts in `batch` output; top-level `artifacts` and `artifactManifest` coalesce an earlier pending recording into the later saved, missing, or stale terminal result for the same namespace/session identity. `record restart` includes both the previous recording it finalized (or an explicit missing/stale failure) and the new pending recording; missing/stale terminal rows retire the prior pending manifest row. A successful later `close` / `quit` / `exit` represents an earlier unfinalized pending recording as `status: "missing"` / `subcommand: "close-abandoned"`, clears its stop action, and updates aggregate verification/manifest state consistently; a later successful `record stop` replaces that intermediate abandoned row with its saved artifact. Close also resets ref/page/network-route state produced by earlier rows; later lifecycle-proven browser launches, including `record stop`, can rebuild that state without triggering stale pre-close `about:blank` recovery, explicitly non-launching diagnostics cannot, and unknown later rows stay conservatively active. Per-step history remains unchanged. When any later call on the same namespace/session fails while a recording remains pending, `nextActions` combines its normal recovery with exact `stop-pending-recording` args and visible cleanup guidance; the same applies at top level when a later batch step fails. After reload in a non-Git checkout or with managed restore disabled, a live daemon without current-instance provenance cannot accept a stop. That policy refusal includes `managedSessionCleanupOnlyReason: "restore-disabled-daemon-without-provenance"` plus the exact `sessionName`/`namespace`, including on implicit calls. It replaces the impossible stop with `close-pending-recording`, an exact close without `sessionMode: "fresh"`. Close retires the recording as `close-abandoned`; any file it leaves is unverified. Same-instance recordings and supported durable-Git reloads still use stop and normal WebM verification.
911
+ - `batchSteps[].artifacts` for per-step artifacts in `batch` output; top-level `artifacts` and `artifactManifest` coalesce an earlier pending recording into the later saved, missing, or stale terminal result for the same namespace/session identity. `record restart` includes both the previous recording it finalized (or an explicit missing/stale failure) and the new pending recording; missing/stale terminal rows retire the prior pending manifest row. A successful later `close` / `quit` / `exit` represents an earlier unfinalized pending recording as `status: "missing"` / `subcommand: "close-abandoned"`, clears its stop action, and updates aggregate verification/manifest state consistently; a later successful `record stop` replaces that intermediate abandoned row with its saved artifact. Close also resets ref/page/network-route state produced by earlier rows; later lifecycle-proven browser launches, including `record stop`, can rebuild that state without triggering stale pre-close `about:blank` recovery, explicitly non-launching diagnostics cannot, and unknown later rows stay conservatively active. Per-step history remains unchanged. When any later call on the same namespace/session fails while a recording remains pending, `nextActions` combines its normal recovery with exact `stop-pending-recording` args and visible cleanup guidance; the same applies at top level when a later batch step fails. After reload in a non-Git checkout or with managed restore disabled, a live daemon without current-instance provenance cannot accept a stop. A tracked Electron attachment can rebuild that proof through the live debug-endpoint check described above; generic restore-disabled sessions cannot. That policy refusal includes `managedSessionCleanupOnlyReason: "restore-disabled-daemon-without-provenance"` plus the exact `sessionName`/`namespace`, including on implicit calls. It replaces the impossible stop with `close-pending-recording`, an exact close without `sessionMode: "fresh"`. Close retires the recording as `close-abandoned`; any file it leaves is unverified. Same-instance recordings and supported durable-Git reloads still use stop and normal WebM verification.
912
912
  - `artifactVerification` for a normalized verification summary on the unified result and on each successful `batchSteps[]` row (failed batch steps omit artifact rows). Top-level `batch` verification rolls up all step file artifacts; each step’s summary reflects that step’s nested tool presentation (including its spill paths and manifest slice). It reports `verified`, `verifiedCount`, `missingCount`, `pendingCount`, `unverifiedCount`, and `artifacts[]` entries with `path`, optional `absolutePath`, optional `requestedPath`, `kind` (a normal file artifact kind or `"spill"` for manifest-backed rows), optional `mediaType`, optional `exists`, optional `sizeBytes`, optional `updatedAtMs`, optional `status`, optional `retentionState` / `storageScope` on manifest-derived rows, `state` (`verified`, `missing`, `pending`, or `unverified`), and optional `limitation` (human-readable lifecycle or retention context, for example pending `record start` / `record restart`, missing, stale, or otherwise unverified files, ephemeral spill files, or evicted persisted spills). The summary `verified` boolean is true only when every entry is `verified`. `record start` / `record restart` are `pending` until `record stop`; `state load` may mention a path in command output but is not a saved artifact row.
913
913
  - `fullOutputPath` / `fullOutputPaths` when parse-valid large snapshot output or other oversized tool output is compacted and spilled to a private file; persisted sessions keep that path under a private session-scoped artifact directory with a bounded per-session budget so it survives reload/resume without unbounded growth. Malformed oversized upstream output is discarded after parsing, is omitted from `details.stdout`, and reports `fullOutputUnavailable` instead of creating a parse-failure spill.
914
914
  - `artifactManifest` for a bounded, metadata-only inventory of recent session artifacts. Entries include path metadata, optional canonical `namespace` plus `session` lifecycle identity, artifact `kind`, source `command`/`subcommand` when safe, `storageScope` (`persistent-session`, `process-temp`, or `explicit-path`), and `retentionState` (`live`, `ephemeral`, `missing`, or `evicted`). The default recent window is 100 entries and can be configured with `PI_AGENT_BROWSER_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES`. A successful session close retires only that exact namespace/session identity's pending recording rows; the separate active reservation index remains authoritative even if this bounded display inventory evicts them. Only the newest pending recording row per namespace/session identity remains live in the manifest. The manifest must not store command args, output contents, headers, DOM snapshots, or downloaded file contents.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "pi extension that exposes agent-browser as a native tool for browser automation",
5
5
  "type": "module",
6
6
  "author": "Mitch Fultz (https://github.com/fitchmultz)",