pi-agent-browser-native 0.6.15 → 0.6.16

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
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.16 - 2026-09-21
6
+
7
+ ### Qualification
8
+
9
+ - The owner waived Windows qualification for this rollout on 2026-09-21; Windows failures remain visible and nonblocking, not passed. Linux/macOS, Node floors, official/fork consumer checks and all three final reviewer approvals remain required before merge, release or activation.
10
+ - Track unresolved Windows policy-lock and restored Electron cleanup failures in [#191](https://github.com/fitchmultz/pi-agent-browser-native/issues/191). The unchanged Electron repeat passed once without a product patch; its original cause remains unknown. Earlier passing focused Windows controls are bounded evidence, not full-suite qualification. Failed private Windows candidates are not integrated.
11
+
12
+ ### Fixed
13
+
14
+ - Validate explicit Windows socket directories using native directory semantics instead of requiring POSIX ownership metadata.
15
+ - Start the config CLI correctly on Windows and encoded or symlinked paths using Node's native entrypoint identity, while keeping module imports silent.
16
+ - Redact bearer credentials followed by Windows path separators in diagnostic text.
17
+ - Await Windows process-tree termination before returning from browser timeouts or cancellation.
18
+ - Preserve literal newlines through agent-browser's recognized global npm Windows CMD launcher by invoking that installation's native executable directly; custom and unrecognized launchers retain their existing behavior.
19
+ - Allow ten seconds for native Windows process-identity queries, including PowerShell startup, while preserving the identity format and five-second POSIX budget.
20
+ - Await tracked Electron process exit before removing its profile and reporting cleanup complete.
21
+ - Inspect native Windows process command lines when verifying ownership of restored Electron launches.
22
+ - Recognize native Windows absolute checkout paths when checking for duplicate extension sources.
23
+ - Recheck released managed-session policy claims after PID inspection so a completed owner does not leave a stale busy result.
24
+ - Refresh transient choosing tickets so a later managed-session contender cannot block its predecessor after publishing its ticket.
25
+ - Retry native Windows open-file rename conflicts during owned policy-claim cleanup, with token revalidation and a fresh one-second retry window even after long browser shutdowns.
26
+
5
27
  ## 0.6.15 - 2026-09-18
6
28
 
7
29
  ### Fixed
package/README.md CHANGED
@@ -4,6 +4,14 @@ A Pi extension that lets coding agents drive real browser sessions with a native
4
4
 
5
5
  It is for Pi users who want agents to browse sites, inspect pages, click through flows, capture screenshots, use persistent profiles, and handle authenticated web apps without spending context on `agent-browser` CLI ceremony.
6
6
 
7
+ ## Pi release qualification
8
+
9
+ Development host dependencies are pinned to official Pi **0.86.1**, with wildcard runtime peers. `npm run check:compat` checks the installed candidate SDK/CLI identity, typechecks, runs the existing offline tests (including the native Pi pipeline with a controlled provider and fake browser), and tests a packed runtime-only installation through both the SDK and the actual bundled RPC CLI. The fork lane additionally requires the native idle checkpoint hook. It does not substitute a fork SDK while leaving official types or child CLI installed.
10
+
11
+ Run in an empty HOME with a **short** TMPDIR outside your real home. Long temporary paths can exceed macOS's 103-byte Unix socket limit; keep the fixture's socket safety checks intact. Real-browser/checkpoint controls, live upstream help sampling, lifecycle dogfood, and platform qualification remain the separate gates documented below. A Pi-only compatibility run uses no live browser profiles, credentials, or agent-browser fork, and does not certify those external integrations.
12
+
13
+ For the 0.6.16 compatibility rollout, the owner explicitly waived Windows qualification on 2026-09-21. Windows diagnostics and failures remain visible but nonblocking; this is **not** a Windows full-suite pass. Linux/macOS, declared Node floors, official/fork host and consumer-artifact checks remain required. Known Windows failures and follow-up are tracked in [#191](https://github.com/fitchmultz/pi-agent-browser-native/issues/191); see the [current support evidence](docs/SUPPORT_MATRIX.md#current-0616-rollout).
14
+
7
15
  ## Source-of-truth map
8
16
 
9
17
  Start here for install and common usage. For deeper work, use the active docs by purpose:
@@ -639,6 +647,10 @@ Policy-required URL reads keep the two-call read → confirm/deny flow. Returned
639
647
 
640
648
  Full live identity, browser-independent native reads/confirmations and detailed recording receipts require the companion upstream fixes; the current recommended release does not yet supply all of them. Older supported versions remain accepted with unavailable evidence marked unknown. The extension does not upgrade or restart your installed browser to obtain these fields.
641
649
 
650
+ ### Native working-session checkpoints
651
+
652
+ On Pi hosts with `session_checkpoint`, an unused or cleanly closed browser integration can qualify for sleep. Live browsers, attachments, scripts, unfinished recordings/cleanup leases, and dirty recording persistence keep compute alive. Pi core owns native journal flush/repair and strict restore; an ongoing journal I/O failure rejects checkpoint acquisition. Checkpointing never closes browsers or changes cleanup ownership. Native save-on-close and per-root restore remain the persistence path; page JavaScript, unsaved forms, and live recordings are not serialized. See [checkpoint boundaries](docs/ARCHITECTURE.md#native-checkpoint-boundary).
653
+
642
654
  ### Profile selection
643
655
 
644
656
  The wrapper does not clone profiles or hide what upstream Chrome/Chromium profile or executable you chose. Passing `--profile` or `--executable-path` is an explicit upstream `agent-browser` choice. Visible page content from real profiles is model-visible and may persist in transcripts or saved artifacts; redaction protects credential-like cookie/storage/auth values, not ordinary page text you asked the browser to read. Redactions use `[REDACTED]` (URL-encoded in parsed URLs); ordinary technical phrases such as `bearer token` stay intact. URL redaction includes `code`, `authorization_session_id`, and auth-context `state` / `nonce` across visible text, details, spills, and `outputPath` exports. URLs needing no redaction keep their original spelling.
@@ -9,11 +9,12 @@ import { buildExecutionPlan, canUseHeadlessCompatibilityUserAgent, createEphemer
9
9
  import { deleteIdentityKeysInNamespace, extractExplicitNamespace, extractExplicitSessionName, getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace, isUpstreamEnvFlagEnabled, resolveAgentBrowserNamespace } from "./lib/argv-grammar.js";
10
10
  import { parseArgvDescriptor } from "./lib/argv-descriptor.js";
11
11
  import { needsManagedSession } from "./lib/command-policy.js";
12
- import { ManagedSessionRestoreState } from "./lib/managed-session-restore.js";
12
+ import { ManagedSessionRestoreState, resolveOwnedManagedSessionContext, withOwnedManagedSessionContext } from "./lib/managed-session-restore.js";
13
13
  import { isRecord } from "./lib/parsing.js";
14
14
  import { runAgentBrowserProcess } from "./lib/process.js";
15
15
  import { getAgentBrowserProcessEnvironment, withIsolatedAgentBrowserEnvironment } from "./lib/process-environment.js";
16
16
  import { withNativeSessionDefaults } from "./lib/orchestration/native-session-defaults.js";
17
+ import { closeManagedSession, inspectManagedSessionDaemon } from "./lib/orchestration/browser-run/managed-session-daemon-policy.js";
17
18
  import { MINIMUM_AGENT_BROWSER_VERSION, SUPPORTED_AGENT_BROWSER_VERSION_LABEL, TARGET_AGENT_BROWSER_VERSION, getAgentBrowserVersionValidationError, parseAgentBrowserVersionOutput, } from "./lib/upstream-version.js";
18
19
  import { buildPromptPolicy, getLatestUserPrompt, shouldAppendBrowserSystemPrompt } from "./lib/prompt-policy.js";
19
20
  import { isCloseAllCommand, isCloseCommand } from "./lib/command-taxonomy.js";
@@ -21,9 +22,8 @@ import { hasLaunchScopedFlagToken } from "./lib/launch-scoped-flags.js";
21
22
  import { cleanupSecureTempArtifacts } from "./lib/temp.js";
22
23
  import { AGENT_BROWSER_PARAMS } from "./lib/input-modes/params.js";
23
24
  import { AGENT_BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS, AGENT_BROWSER_SCRIPT_NAMESPACE, createAgentBrowserScriptSessionName, isAgentBrowserScriptSessionName, runAgentBrowserScript, } from "./lib/input-modes/script.js";
24
- import { closeManagedSession, getSessionContextKey, runAgentBrowserTool } from "./lib/orchestration/browser-run/index.js";
25
25
  import { canonicalizeExplicitArtifactDestination, getExplicitArtifactDestination, getRecordContactSheetDestination } from "./lib/orchestration/browser-run/artifact-paths.js";
26
- import { findElectronLaunchRecordForSession, getActiveElectronRecords } from "./lib/orchestration/browser-run/session-state.js";
26
+ import { findElectronLaunchRecordForSession, getActiveElectronRecords, getSessionContextKey } from "./lib/orchestration/browser-run/session-state.js";
27
27
  import { parseBatchCommandArgument, parseUserBatchStdin } from "./lib/orchestration/batch-stdin.js";
28
28
  import { ELECTRON_POST_COMMAND_STATUS_SETTLE_MS, ELECTRON_PROFILE_ISOLATION_DETAILS, cleanupActiveElectronHostLaunches, handleElectronHostInput, restoreElectronLaunchRecordsFromBranch, } from "./lib/orchestration/electron-host/index.js";
29
29
  import { buildValidationFailureResult, resolveAgentBrowserInput } from "./lib/orchestration/input-plan.js";
@@ -571,7 +571,7 @@ function collectBranchManagedResourceEvents(branch) {
571
571
  const replacedSessionNamespace = typeof outcome.replacedSessionNamespace === "string" ? outcome.replacedSessionNamespace : namespace;
572
572
  setBranchRankForString(events.managedSessionCloseRanks, getSessionContextKey(typeof outcome.replacedSessionName === "string" ? outcome.replacedSessionName : undefined, replacedSessionNamespace), eventRank);
573
573
  }
574
- if (succeeded && !isCloseCommand(command) && sessionName && (usedImplicitSession || sessionMode === "fresh" || details.managedSessionHeadedAutosaveDisabled === true || typeof details.managedSessionHeadedAutosaveInterval === "string")) {
574
+ if (succeeded && !isCloseCommand(command) && sessionName && ((!explicitSessionName && (usedImplicitSession || sessionMode === "fresh")) || details.managedSessionHeadedAutosaveDisabled === true || typeof details.managedSessionHeadedAutosaveInterval === "string")) {
575
575
  setBranchManagedSessionActive(events, sessionName, namespace, eventRank);
576
576
  }
577
577
  if (succeeded && isCloseCommand(command)) {
@@ -1243,6 +1243,78 @@ export default function agentBrowserExtension(pi, { beforeExecute } = {}) {
1243
1243
  notifyRecordingPersistence(ctx);
1244
1244
  }));
1245
1245
  });
1246
+ // Additive native checkpoint event; the package's 0.84 validation types predate it.
1247
+ // Pi owns awaited tools/events (including their execution queues). Only script
1248
+ // children and retained browser/recording resources need extension-side checks.
1249
+ const checkpointPi = pi;
1250
+ checkpointPi.on("session_checkpoint", async (event, ctx) => {
1251
+ const blocked = (reason) => ({ sleepReady: false, reason });
1252
+ if (activeScriptControllers.size || activeScriptExecutions.size)
1253
+ return blocked("Browser script execution or cleanup is pending");
1254
+ flushRecordingReservations();
1255
+ if (recordingReservationsDirty)
1256
+ return blocked("Browser recording journal persistence is dirty");
1257
+ if (activeRecordingReservations.size)
1258
+ return blocked("Browser recording is pending; finish it explicitly before sleep");
1259
+ const entries = ctx.sessionManager.getEntries();
1260
+ if ([...getScriptSessionLeasesFromBranch(entries).values()].some(lease => lease.cleanup !== "closed")
1261
+ || [...ownedManagedSessions.values()].some(owner => isAgentBrowserScriptSessionName(owner.sessionName))) {
1262
+ return blocked("Browser script cleanup lease is unresolved");
1263
+ }
1264
+ if (getActiveElectronRecords(ownedElectronLaunchRecords).length || getActiveElectronRecords(electronLaunchRecords).length
1265
+ || [...electronChildProcesses.values()].some(child => child.exitCode === null && child.signalCode === null)) {
1266
+ return blocked("Electron launch is still active");
1267
+ }
1268
+ if (attachedSessionKeys.size || restoreAttachedSessionKeysFromBranch(entries).size)
1269
+ return blocked("Attached browser state is caller-owned and not checkpointed");
1270
+ if (traceOwners.size || [...networkRoutesBySession.values()].some(routes => routes.length))
1271
+ return blocked("Browser trace or network routes are still active");
1272
+ // Include off-branch and caller-owned/root identities, without acquiring
1273
+ // cleanup ownership. Transcript page/ref details do not serialize a browser.
1274
+ const historicalResources = collectBranchManagedResourceEvents(entries);
1275
+ const sessions = new Map(ownedManagedSessions);
1276
+ if (managedSessionActive)
1277
+ trackOwnedManagedSession(sessions, managedSessionName, managedSessionCwd, { namespace: managedSessionNamespace });
1278
+ for (const entry of entries) {
1279
+ if (entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.toolName !== "agent_browser")
1280
+ continue;
1281
+ const details = isRecord(entry.message.details) ? entry.message.details : undefined;
1282
+ // Helpers can launch before a main-process failure. Conversely, native
1283
+ // launch flags on sessionless reads can open the unnamed default browser.
1284
+ const sessionName = typeof details?.sessionName === "string" ? details.sessionName
1285
+ : details?.agentBrowserStarted === true ? "default" : undefined;
1286
+ if (!sessionName)
1287
+ continue;
1288
+ const namespace = typeof details?.namespace === "string" ? details.namespace : undefined;
1289
+ const key = getSessionContextKey(sessionName, namespace) ?? sessionName;
1290
+ if (!sessions.has(key))
1291
+ trackOwnedManagedSession(sessions, sessionName, ctx.cwd, { namespace });
1292
+ }
1293
+ for (const [key, owner] of sessions) {
1294
+ event.signal.throwIfAborted();
1295
+ // An abnormal restart can leave a wrapper-created browser only off-branch.
1296
+ // Reuse native ownership events for inspection, never cleanup ownership.
1297
+ // A later close can refer to the same identity in a different socket root.
1298
+ const historicalIdentity = historicalResources.managedSessionActiveIdentities.get(key);
1299
+ const historicallyOwned = historicalIdentity && isRestorableManagedSessionName(historicalIdentity.sessionName, managedSessionBaseName);
1300
+ const context = resolveOwnedManagedSessionContext({
1301
+ ...owner,
1302
+ currentManagedSessionName: managedSessionActive ? managedSessionName : undefined,
1303
+ currentManagedSessionNamespace: managedSessionNamespace,
1304
+ recordedOwnedSession: ownedManagedSessions.get(key) ?? (historicallyOwned ? { ...historicalIdentity, cwd: ctx.cwd } : undefined),
1305
+ restoreState: managedSessionRestoreState,
1306
+ });
1307
+ // Owned routing must not mask an explicit caller's same-name ambient daemon.
1308
+ for (const inspectionContext of context ? [{ ...context, reuseOnly: true }, undefined] : [undefined]) {
1309
+ const daemon = await withOwnedManagedSessionContext(inspectionContext, () => inspectManagedSessionDaemon({ ...owner, signal: event.signal, timeoutMs: 2_000 }));
1310
+ if (daemon.status !== "inactive")
1311
+ return blocked("Browser daemon is live or unverified; finish browser work explicitly before sleep");
1312
+ }
1313
+ }
1314
+ // No detached writer remains to pause/resume. Native ingress stays held;
1315
+ // checkpoint never closes a browser or changes ordinary shutdown ownership.
1316
+ return { sleepReady: true };
1317
+ });
1246
1318
  pi.on("session_shutdown", async (event, ctx) => {
1247
1319
  for (const controller of activeScriptControllers)
1248
1320
  controller.abort();
@@ -1623,6 +1695,9 @@ export default function agentBrowserExtension(pi, { beforeExecute } = {}) {
1623
1695
  ? getSessionContextKey(explicitSessionName, callerOwnedSessionNamespace) ?? explicitSessionName
1624
1696
  : undefined;
1625
1697
  const runBrowserCommand = async (daemonInactive) => {
1698
+ // Load execution-only preparation/output code inside the existing session queue,
1699
+ // before capturing state. Registration, rendering, and restore stay synchronous/eager.
1700
+ const { runAgentBrowserTool } = await import("./lib/orchestration/browser-run/index.js");
1626
1701
  flushRecordingReservations();
1627
1702
  const branchRestoreGenerationAtStart = branchRestoreGeneration;
1628
1703
  const generationAtStart = branchStateGeneration;
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { lstat, rm } from "node:fs/promises";
3
+ import { win32 } from "node:path";
3
4
  import { promisify } from "node:util";
4
5
  import { fetchCdpJson, parseCdpTargets, parseCdpVersion } from "./cdp.js";
5
6
  import { ELECTRON_PROFILE_DIR_PREFIX } from "./launch.js";
@@ -53,21 +54,35 @@ export async function inspectElectronLaunchStatus(record, signal) {
53
54
  version: cdp.version,
54
55
  };
55
56
  }
57
+ function hasProcessExited(child, pid) {
58
+ // A Windows PID probe can report ESRCH during termination, before the
59
+ // tracked process has exited and released its executable/resources.
60
+ return child ? child.exitCode !== null || child.signalCode !== null : isPidAlive(pid) === false;
61
+ }
56
62
  async function waitForProcessExit(child, pid, deadlineMs) {
57
63
  while (Date.now() <= deadlineMs) {
58
- if (child && (child.exitCode !== null || child.signalCode !== null))
59
- return true;
60
- if (isPidAlive(pid) === false)
64
+ if (hasProcessExited(child, pid))
61
65
  return true;
62
66
  await sleep(ELECTRON_CLEANUP_POLL_INTERVAL_MS);
63
67
  }
64
- return isPidAlive(pid) === false;
68
+ return hasProcessExited(child, pid);
65
69
  }
66
70
  async function readPidCommandLine(pid) {
67
71
  if (!pid || !Number.isSafeInteger(pid) || pid <= 0)
68
72
  return undefined;
69
73
  try {
70
- const { stdout } = await execFileAsync("ps", ["-ww", "-p", String(pid), "-o", "command="], {
74
+ // Win32_Process.CommandLine is the native ownership evidence; process
75
+ // start time (used by daemon-policy locks) cannot prove profile ownership.
76
+ const systemRoot = process.env.SystemRoot;
77
+ const windowsRoot = systemRoot && win32.isAbsolute(systemRoot) ? systemRoot : "C:\\Windows";
78
+ const file = process.platform === "win32"
79
+ ? win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
80
+ : "ps";
81
+ const args = process.platform === "win32"
82
+ ? ["-NoProfile", "-NonInteractive", "-Command",
83
+ `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); $p = [wmi]"Win32_Process.Handle='${pid}'"; [Console]::WriteLine($p.CommandLine)`]
84
+ : ["-ww", "-p", String(pid), "-o", "command="];
85
+ const { stdout } = await execFileAsync(file, args, {
71
86
  timeout: RESTORED_PROCESS_COMMAND_TIMEOUT_MS,
72
87
  });
73
88
  return stdout.trim() || undefined;
@@ -117,7 +132,7 @@ function signalRestoredLaunchProcess(record, signal) {
117
132
  async function cleanupProcess(record, child, deadlineMs) {
118
133
  if (!record.pid)
119
134
  return { resource: "process", state: "skipped" };
120
- if (isPidAlive(record.pid) === false)
135
+ if (hasProcessExited(child, record.pid))
121
136
  return { resource: "process", state: "already-gone" };
122
137
  if (!child) {
123
138
  const verificationError = await getRestoredProcessVerificationError(record);
@@ -164,16 +164,29 @@ async function ownerAlive(owner) {
164
164
  const current = await readProcessStartIdentity(owner.pid);
165
165
  return current === undefined ? undefined : processStartIdentitiesMatch(owner.startIdentity, current);
166
166
  }
167
- async function removeClaimOwnedBy(path, token) {
168
- const current = await readClaim(path);
169
- if (current?.owner.token !== token)
170
- return false;
167
+ async function removeClaimOwnedBy(path, token, deadline = 0) {
171
168
  const movedPath = join(dirname(path), `.pi-agent-browser-policy-remove-${token}-${randomUUID()}`);
172
- try {
173
- await rename(path, movedPath);
174
- }
175
- catch (error) {
176
- return error.code === "ENOENT";
169
+ while (true) {
170
+ const current = await readClaim(path);
171
+ if (current?.owner.token !== token)
172
+ return false;
173
+ try {
174
+ await rename(path, movedPath);
175
+ break;
176
+ }
177
+ catch (error) {
178
+ const code = error.code;
179
+ if (code === "ENOENT")
180
+ return true;
181
+ // Windows may refuse a directory move while a contender reads its
182
+ // metadata. Revalidate before retrying. Owned-claim cleanup gets
183
+ // its own bounded window, independent of the acquisition wait.
184
+ if (process.platform !== "win32" || code !== "EPERM" || Date.now() >= deadline)
185
+ return false;
186
+ await waitForRetry();
187
+ if (Date.now() >= deadline)
188
+ return false;
189
+ }
177
190
  }
178
191
  const moved = await readClaim(movedPath);
179
192
  if (moved?.owner.token !== token) {
@@ -209,6 +222,12 @@ function claimPrecedes(left, right) {
209
222
  return false;
210
223
  return left.ticket < right.ticket || (left.ticket === right.ticket && left.owner.token < right.owner.token);
211
224
  }
225
+ async function hasPublishedLaterTicket(claim, ownClaim) {
226
+ if (claim.ticket !== null)
227
+ return false;
228
+ const current = await readClaim(claim.path);
229
+ return current?.owner.token === claim.owner.token && !claimPrecedes(current, ownClaim);
230
+ }
212
231
  function waitForRetry(signal) {
213
232
  return new Promise((resolve) => {
214
233
  if (signal?.aborted)
@@ -266,18 +285,35 @@ export async function acquireManagedSessionPolicyLock(options) {
266
285
  for (const claim of claims) {
267
286
  if (claim.owner.token === token || !claimPrecedes(claim, ownClaim))
268
287
  continue;
288
+ // Choosing is transient: a later published ticket must not make
289
+ // its predecessor wait on it using an earlier null snapshot.
290
+ if (await hasPublishedLaterTicket(claim, ownClaim))
291
+ continue;
269
292
  const alive = await ownerAlive(claim.owner);
270
293
  if (alive === false) {
271
294
  await removeClaimOwnedBy(claim.path, claim.owner.token);
272
295
  continue;
273
296
  }
297
+ // A native identity query can outlive the owner's entire critical
298
+ // section. Its result (including unknown after exit) does not make
299
+ // an already released immutable claim a current blocker.
300
+ try {
301
+ await lstat(claim.path);
302
+ }
303
+ catch (error) {
304
+ if (error.code === "ENOENT")
305
+ continue;
306
+ }
307
+ // Publication can also complete during the native identity query.
308
+ if (await hasPublishedLaterTicket(claim, ownClaim))
309
+ continue;
274
310
  blocked = true;
275
311
  break;
276
312
  }
277
313
  if (!blocked) {
278
314
  await cleanDeadPolicyArtifacts(directory);
279
315
  lockAcquired = true;
280
- return { release: async () => { await removeClaimOwnedBy(claimPath, token); } };
316
+ return { release: async () => { await removeClaimOwnedBy(claimPath, token, Date.now() + POLICY_LOCK_WAIT_MS); } };
281
317
  }
282
318
  if (Date.now() >= deadline)
283
319
  return undefined;
@@ -291,6 +327,6 @@ export async function acquireManagedSessionPolicyLock(options) {
291
327
  finally {
292
328
  await rm(candidatePath, { force: true, recursive: true }).catch(() => undefined);
293
329
  if (claimPublished && !lockAcquired)
294
- await removeClaimOwnedBy(claimPath, token);
330
+ await removeClaimOwnedBy(claimPath, token, Date.now() + POLICY_LOCK_WAIT_MS);
295
331
  }
296
332
  }
@@ -1,7 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { dirname, join, win32 } from "node:path";
3
3
  const WINDOWS_PROCESS_START_IDENTITY_PREFIX = "win32-powershell-ticks-v1:";
4
- const PROCESS_START_IDENTITY_TIMEOUT_MS = 5_000;
4
+ // Native Windows PowerShell startup can exhaust the former five-second probe budget.
5
+ const PROCESS_START_IDENTITY_TIMEOUT_MS = process.platform === "win32" ? 10_000 : 5_000;
5
6
  const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows";
6
7
  export function buildProcessStartIdentityCommand(pid, platform = process.platform) {
7
8
  if (!Number.isSafeInteger(pid) || pid <= 0)
@@ -11,6 +11,7 @@ import { getPageTargetValidationError, } from "./page-target-validation.js";
11
11
  import { getImplicitSessionIdleTimeoutMs } from "./runtime.js";
12
12
  import { getAgentBrowserProcessEnvironment } from "./process-environment.js";
13
13
  import { openSecureTempFile, writeSecureTempChunk } from "./temp.js";
14
+ import { resolveWindowsStockLauncher } from "./windows-stock-launcher.js";
14
15
  const MAX_BUFFERED_STDOUT_BYTES = 512 * 1_024;
15
16
  const MAX_BUFFERED_STDERR_CHARS = 32_000;
16
17
  const MAX_BUFFERED_STDOUT_TAIL_CHARS = 32_000;
@@ -52,11 +53,17 @@ export function prepareAgentBrowserSpawnArgs(args, wrapperCompatibilityUserAgent
52
53
  normalized[occurrence.index + 1] = customArgs;
53
54
  return normalized;
54
55
  }
55
- function terminateSpawnedChild(child, signal) {
56
+ async function terminateSpawnedChild(child, signal) {
56
57
  if (processPlatform === "win32" && child.pid) {
57
- const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
58
- killer.on("error", () => undefined);
59
- killer.unref();
58
+ // Keep the shell alive until taskkill has traversed its descendants, and
59
+ // observe the killer before allowing the browser call to finish.
60
+ const killed = await new Promise((resolve) => {
61
+ const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
62
+ killer.once("error", () => resolve(false));
63
+ killer.once("close", (code) => resolve(code === 0));
64
+ });
65
+ if (killed)
66
+ return;
60
67
  }
61
68
  child.kill(signal);
62
69
  }
@@ -224,9 +231,29 @@ async function socketDirEntriesAreOwned(socketDir, uid, visited = { count: 0 })
224
231
  }
225
232
  return true;
226
233
  }
227
- export async function getAgentBrowserSocketDirValidationError(socketDir, uid = typeof process.getuid === "function" ? process.getuid() : undefined) {
234
+ export async function getAgentBrowserSocketDirValidationError(socketDir, uid = typeof process.getuid === "function" ? process.getuid() : undefined, platform = processPlatform) {
228
235
  if (!isAbsolute(socketDir))
229
236
  return "the path is not absolute";
237
+ // Windows uses native ACLs and named pipes, not POSIX uid/mode metadata.
238
+ // Still require a real directory; never accept a file or a redirected root.
239
+ if (platform === "win32") {
240
+ try {
241
+ try {
242
+ await mkdir(socketDir);
243
+ }
244
+ catch (error) {
245
+ if (error.code !== "EEXIST")
246
+ throw error;
247
+ }
248
+ const metadata = await lstat(socketDir);
249
+ if (metadata.isSymbolicLink())
250
+ return "the directory is a symlink";
251
+ return metadata.isDirectory() ? undefined : "the path is not a directory";
252
+ }
253
+ catch (error) {
254
+ return `the directory could not be inspected (${error.code ?? "unknown error"})`;
255
+ }
256
+ }
230
257
  if (typeof uid !== "number")
231
258
  return "POSIX ownership metadata is unavailable";
232
259
  try {
@@ -378,6 +405,8 @@ export async function runAgentBrowserProcess(options) {
378
405
  }
379
406
  effectiveEnv = { ...effectiveEnv, [AGENT_BROWSER_SOCKET_DIR_ENV]: requestedSocketDir };
380
407
  }
408
+ const childEnv = buildAgentBrowserProcessEnv(parentEnv, effectiveEnv);
409
+ const stockLauncher = resolveWindowsStockLauncher(cwd, childEnv);
381
410
  if (signal?.aborted) {
382
411
  return { aborted: true, agentBrowserStarted: false, exitCode: 1, stderr: "", stdout: "", timedOut: false };
383
412
  }
@@ -395,6 +424,7 @@ export async function runAgentBrowserProcess(options) {
395
424
  let stdoutSpillPending = false;
396
425
  let pendingStdoutWrite = Promise.resolve();
397
426
  let stdoutSpillError;
427
+ let pendingTermination;
398
428
  let killTimer;
399
429
  let timeoutTimer;
400
430
  let abortListener;
@@ -444,15 +474,16 @@ export async function runAgentBrowserProcess(options) {
444
474
  if (settled)
445
475
  return;
446
476
  settled = true;
477
+ removeAbortListener();
478
+ if (killTimer) {
479
+ clearTimeout(killTimer);
480
+ }
481
+ if (timeoutTimer) {
482
+ clearTimeout(timeoutTimer);
483
+ }
484
+ completionWatcher?.clear();
447
485
  void pendingStdoutWrite.finally(async () => {
448
- removeAbortListener();
449
- if (killTimer) {
450
- clearTimeout(killTimer);
451
- }
452
- if (timeoutTimer) {
453
- clearTimeout(timeoutTimer);
454
- }
455
- completionWatcher?.clear();
486
+ await pendingTermination;
456
487
  if (stdoutSpillHandle) {
457
488
  await stdoutSpillHandle.close().catch(() => undefined);
458
489
  }
@@ -478,14 +509,13 @@ export async function runAgentBrowserProcess(options) {
478
509
  });
479
510
  });
480
511
  };
481
- const childEnv = buildAgentBrowserProcessEnv(parentEnv, effectiveEnv);
482
512
  const spawnPolicyError = getManagedPreSpawnPolicyError(managedSessionRestoreOptions, managedStateCurrentPageUrl, managedStatePageUrlUnknown, options.browserIndependentReadConfirmation);
483
513
  if (spawnPolicyError) {
484
514
  resolve({ aborted: false, agentBrowserStarted: false, exitCode: 1, spawnError: new Error(spawnPolicyError), stderr: "", stdout: "", timedOut: false });
485
515
  return;
486
516
  }
487
- const spawnBrowser = processPlatform === "win32" ? crossSpawn : spawn;
488
- const child = spawnBrowser("agent-browser", prepareAgentBrowserSpawnArgs(args, ownedManagedSessionCompatibilityEnv.AGENT_BROWSER_USER_AGENT, preserveAttachedBrowserSession, chromeStartupArgsContext.getStore()), {
517
+ const spawnBrowser = processPlatform === "win32" && !stockLauncher ? crossSpawn : spawn;
518
+ const child = spawnBrowser(stockLauncher ?? "agent-browser", prepareAgentBrowserSpawnArgs(args, ownedManagedSessionCompatibilityEnv.AGENT_BROWSER_USER_AGENT, preserveAttachedBrowserSession, chromeStartupArgsContext.getStore()), {
489
519
  cwd,
490
520
  env: childEnv,
491
521
  stdio: ["pipe", "pipe", "pipe"],
@@ -505,10 +535,16 @@ export async function runAgentBrowserProcess(options) {
505
535
  else {
506
536
  timedOut = true;
507
537
  }
508
- terminateSpawnedChild(child, "SIGTERM");
509
- killTimer = setTimeout(() => {
510
- terminateSpawnedChild(child, "SIGKILL");
511
- }, 2_000);
538
+ if (pendingTermination)
539
+ return;
540
+ pendingTermination = terminateSpawnedChild(child, "SIGTERM");
541
+ // Windows taskkill already forces the entire tree. A concurrent direct
542
+ // kill would remove its root before traversal finishes.
543
+ if (processPlatform !== "win32") {
544
+ killTimer = setTimeout(() => {
545
+ pendingTermination = terminateSpawnedChild(child, "SIGKILL");
546
+ }, 2_000);
547
+ }
512
548
  };
513
549
  const recordStdinError = (error) => {
514
550
  const stdinError = error instanceof Error ? error : new Error(String(error));
@@ -251,7 +251,7 @@ function redactBearerCredentials(text) {
251
251
  return match;
252
252
  return formatRedactedCredential(label, credential, trailing);
253
253
  })
254
- .replace(/\b(Bearer)\s+([^\s"',)\[\]]+)([),.]?)/gi, (match, label, credential, trailing) => {
254
+ .replace(/\b(Bearer)\s+([^\s\\"',)\[\]]+)([),.]?)/gi, (match, label, credential, trailing) => {
255
255
  // Without a credential field/header, require a bearer-token shape, not prose, HTML or a URL.
256
256
  const token = credential.slice(0, credential.length - credentialTrailingPunctuation(credential).length);
257
257
  if (!/^[A-Za-z0-9._~+/-]+=*$/.test(token) || !/[0-9._~+/=-]/.test(token))
@@ -0,0 +1,70 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { isMainThread } from "node:worker_threads";
4
+ import pathKey from "path-key";
5
+ import which from "which";
6
+ /** Bypass only upstream's optimized npm CMD shim: cmd.exe loses literal CR/LF.
7
+ * Keep which/path-key aligned with cross-spawn's public dependency versions.
8
+ * Unknown/custom launchers (including other npm layouts) stay on cross-spawn.
9
+ */
10
+ export function resolveWindowsStockLauncher(cwd, env) {
11
+ if (process.platform !== "win32" || !isMainThread)
12
+ return undefined;
13
+ // which reads parent PATHEXT; cmd.exe reads the child's. Node also handles
14
+ // duplicate case-insensitive environment keys differently from path-key.
15
+ // In those ambiguous cases, keep the original transport and its selection.
16
+ const values = (name) => Object.entries(env)
17
+ .filter(([key, value]) => key.toUpperCase() === name && value !== undefined).map(([, value]) => value);
18
+ const paths = values("PATH"), extensions = values("PATHEXT");
19
+ if (paths.length !== 1 || !paths[0] || extensions.length !== 1 || extensions[0] !== process.env.PATHEXT
20
+ || values("NODEFAULTCURRENTDIRECTORYINEXEPATH").length)
21
+ return undefined;
22
+ // A custom COMSPEC can implement different command selection/semantics.
23
+ // Compare filesystem identity, not short/long Windows path spellings.
24
+ try {
25
+ if (!process.env.comspec || !isAbsolute(process.env.comspec) || !process.env.SystemRoot)
26
+ return undefined;
27
+ const shell = statSync(process.env.comspec, { bigint: true });
28
+ const native = statSync(join(process.env.SystemRoot, "System32", "cmd.exe"), { bigint: true });
29
+ if (shell.dev !== native.dev || shell.ino !== native.ino)
30
+ return undefined;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ const previousCwd = process.cwd();
36
+ let selected;
37
+ try {
38
+ // which 2 has no cwd option. Like cross-spawn, change cwd only during
39
+ // synchronous resolution, restoring it before any caller can await.
40
+ process.chdir(cwd);
41
+ selected = which.sync("agent-browser", { path: env[pathKey({ env })], nothrow: true });
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ finally {
47
+ process.chdir(previousCwd);
48
+ }
49
+ // cross-spawn's second (extensionless) lookup cannot select this CMD shim.
50
+ if (!selected || !selected.toLowerCase().endsWith(".cmd"))
51
+ return undefined;
52
+ try {
53
+ // which can return a relative PATH result; interpret it in the child cwd.
54
+ const absoluteShim = resolve(cwd, selected);
55
+ if (statSync(absoluteShim).size > 512)
56
+ return undefined;
57
+ const text = readFileSync(absoluteShim, "utf8");
58
+ if (!/^@ECHO off\r?\n"%~dp0node_modules\\agent-browser\\bin\\agent-browser-win32-x64\.exe" %\*(?:\r?\n)?(?![\s\S])/.test(text))
59
+ return undefined;
60
+ const packageRoot = join(dirname(absoluteShim), "node_modules", "agent-browser");
61
+ const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
62
+ if (manifest.name !== "agent-browser" || !["bin/agent-browser.js", "./bin/agent-browser.js"].includes(manifest.bin?.["agent-browser"]))
63
+ return undefined;
64
+ const binary = join(packageRoot, "bin", "agent-browser-win32-x64.exe");
65
+ return statSync(binary).isFile() ? binary : undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
@@ -36,7 +36,8 @@ Why:
36
36
 
37
37
  The extension should:
38
38
  - resolve `agent-browser` from `PATH`
39
- - invoke it with native Node `spawn` on POSIX and `cross-spawn` on Windows. The latter handles Windows `PATH`/`PATHEXT`, `.cmd` shims and argument escaping without PowerShell or wrapper-owned command reordering. Caller commands and helper probes retain empty operands, literal doublequotes, command/subcommand adjacency and explicit default `--namespace ""`; the selected shim still owns upstream architecture selection. Keep piped stdin/stdout/stderr and terminate the full Windows shell/agent-browser process tree with `taskkill /T /F` on timeout or abort before falling back to the direct child signal. Missing commands use the spawner's `ENOENT`, not a parsed shell-error string
39
+ - invoke it with native Node `spawn` on POSIX and `cross-spawn` on Windows. For the recognized global npm CMD template, public `which`/`path-key` resolution selects the same launcher and Node invokes its exact adjacent native executable directly, preserving literal CR/LF. Recognition requires the stock template, package identity and executable; custom launchers, other layouts, ambiguous search environments and worker-thread calls retain `cross-spawn`. No later installation is substituted. Custom or unrecognized batch files can still truncate literal newlines. Caller commands and helper probes retain empty operands, literal doublequotes, command/subcommand adjacency and explicit default `--namespace ""`. Keep piped stdin/stdout/stderr and await full Windows process-tree termination with `taskkill /T /F` on timeout or abort before falling back to the direct child signal. Missing commands use the spawner's `ENOENT`
40
+ - bound native process-identity queries at ten seconds on Windows, including PowerShell startup, and five seconds on POSIX. These probes preserve PID/start-time comparison and fail closed when identity is unavailable; their budget is separate from lock contention and browser command watchdogs
40
41
  - inject `--json`; caller-requested JSON also uses the existing `buildJsonVisibleContent` envelope for early snapshot/network filters, scroll results, and preparation failures, retaining metadata in `details` and keeping output-file notices out of visible JSON. Help/version stays native text
41
42
  - complete each upstream invocation when the direct `agent-browser` child exits even if Node delays `"close"`: piped stdio can stay referenced by longer-lived descendant processes, so `runAgentBrowserProcess` watches `exit` and `close` together, leaves stdio intact during a short post-`exit` grace so normal `close` can still win, destroys streams only when the post-`exit` fallback fires, and prefers `close` codes then wrapper timeout (`124`) over signal-shaped `exit` codes (`watchSpawnedChildCompletion` / `resolveSpawnedChildExitCode` in `extensions/agent-browser/lib/process.ts`) so the tool cannot hang after the CLI process has already terminated
42
43
  - support optional stdin only for `eval --stdin`, `batch`, `auth save --password-stdin`, and wrapper-generated `batch` stdin from top-level `job`, `qa`, `sourceLookup`, or `networkSourceLookup`, rejecting other command/stdin combinations before launch; top-level `electron` never accepts caller `stdin` (see [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md#electron))
@@ -214,6 +215,16 @@ Practical policy:
214
215
 
215
216
  This is primarily about ownership clarity and avoiding surprise, not adding a heavy safety wrapper. If the extension invented the session, the extension should own its lifecycle without breaking reload, resume, or branch-tree semantics. If the caller explicitly chose the upstream session model, the extension should stay out of the way.
216
217
 
218
+ ### Native checkpoint boundary
219
+
220
+ The additive native `session_checkpoint` hook qualifies only the no-active-resource path. Pi already awaits tools, commands and lifecycle callbacks, including their returned execution queues. The hook checks the existing script controllers/completion promises, recording reservations and dirty retry state, cleanup leases, Electron records/children, attachments, trace owners and routes. It adds no work queue or state journal, and never invokes shutdown, stops recording, or closes a browser during capture.
221
+
222
+ Known native identities come from existing ownership and all native tool-result entries, including off-branch caller-owned/root sessions and the unnamed native default (launch flags can open it even for an explicit URL read). Read-only `session info` must prove each daemon inactive; active, missing-binary and unknown results remain blockers. These probes reuse the existing owned-session context for runtime-owned identities and transcript-proven wrapper-created identities, including off-branch browsers after an abnormal restart, so an ambient `AGENT_BROWSER_SOCKET_DIR` cannot redirect their inspection. Historical routing uses the existing managed-resource events and namespace/session identity, not a name pattern alone, and does not acquire cleanup ownership. For owned or historically owned targets, inspection also checks native ambient routing: the same namespace/name can identify different daemons in different socket roots. A later transcript close does not suppress the historical route's read-only probe, since it may have closed only the ambient caller. Explicit caller choices (even the exact historical name with `sessionMode: "fresh"`), unrelated namespaces and root identities retain native ambient routing. An unused extension needs no browser executable or profile scan. Independent hosts/group members must still coordinate their own writers and resources; a Pi receipt is not a cross-process browser lock.
223
+
224
+ Recording retries still republish existing reservation transitions and retain a blocker while that extension state is dirty. Native journal persistence is Pi-owned: after extension barriers, core flushes accepted entries before returning a checkpoint; ongoing I/O failures reject acquisition. Repair filesystem permissions and retry capture after ordinary browser close/cleanup. The extension does not scan, compare or repair Pi's journal. Native strict restore rejects a journal/artifact mismatch, and native materialization supports a not-yet-created journal.
225
+
226
+ With no detached work left, native ingress provides the hold and release; there is no extension observer to suspend. Normal ownership-aware shutdown remains unchanged. Native close/save followed by explicit URL reopen can restore synthetic cookies/localStorage/sessionStorage with the same `pi-root-*` identity on fresh filesystem inodes. This does not qualify live DOM/JS, forms, history, IndexedDB, or the separate inode-scoped `piab-r2-*` managed restore key for cold filesystem relocation. See [native qualification](RELEASE.md#native-checkpoint-qualification).
227
+
217
228
  ### Launch flags
218
229
 
219
230
  `native-session-defaults.ts` reads launch-argument/engine fields through its existing native-validated config discovery. Local Chrome composes `--no-startup-window` with the highest-priority caller arguments. Root bootstrap retains its existing helper scope; owned/fresh preparation reuses daemon-policy inspection, while explicit local callers use the same session-info probe. `process.ts` applies the scoped arguments after managed restore-policy checks, composing the existing compatibility user agent without overriding caller Chrome arguments. Configured environment/file arguments remain consistent across helpers; ordinary active calls do not receive a new launch configuration. This adds no browser launcher, profile mutation, tab cleanup, binary distribution, or persistent launch-settings state. Older custom-argument browsers can undergo native's normal hash-change restart on first use of the new default.
package/docs/ELECTRON.md CHANGED
@@ -184,7 +184,9 @@ Output appears under `details.electron.probe`: `title`, `url`, `focusedElement`,
184
184
 
185
185
  ### `electron.cleanup` — wrapper-owned only
186
186
 
187
- Closes the tracked managed session, stops only the wrapper-tracked process, verifies that the debug port no longer serves `/json/version`, and removes the wrapper-created `userDataDir`. Cleanup partial failures fail the tool result with `failureCategory: "cleanup-failed"` and the `retry-electron-cleanup` next action references the same `launchId` so retries are bounded.
187
+ Closes the tracked managed session, stops only the wrapper-tracked process, verifies that the debug port no longer serves `/json/version`, and removes the wrapper-created `userDataDir`. Cleanup waits for actual exit when it has a tracked child handle. For a live restored PID without that handle, it first checks the native process command line for the recorded profile: `Win32_Process.CommandLine` through Windows PowerShell on Windows, or `ps` on POSIX. If inspection fails or the profile does not match, cleanup leaves the process and profile intact.
188
+
189
+ Cleanup partial failures fail the tool result with `failureCategory: "cleanup-failed"` and the `retry-electron-cleanup` next action references the same `launchId` so retries are bounded.
188
190
 
189
191
  ```json
190
192
  { "electron": { "action": "cleanup", "launchId": "electron-…" } }
@@ -313,7 +315,7 @@ Policy mismatches fail with `failureCategory: "policy-blocked"` and `details.ele
313
315
 
314
316
  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
317
 
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.
318
+ The app writes to mode-0600 `stdout.log` and `stderr.log` inside its isolated profile. Regular-file capture lets a retained app keep writing independently of Pi's output streams. POSIX launches are detached and can outlive the host; Windows launches remain host-owned and stop when the host exits. Preserved logs survive either outcome. 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
319
 
318
320
  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.
319
321
 
package/docs/RELEASE.md CHANGED
@@ -17,6 +17,14 @@ Related docs:
17
17
 
18
18
  Provide one concrete maintainer workflow for validating repo state, package contents, and install guidance before publishing `pi-agent-browser-native`.
19
19
 
20
+ ## Current 0.6.16 rollout waiver
21
+
22
+ The owner's 2026-09-21 **“waive windows”** instruction makes Windows qualification nonblocking for this rollout, even with available runners, and supersedes older mandatory-Windows/no-waiver wording below and in platform guidance. Preserve failed diagnostics; do not claim a Windows full-suite pass or continue Windows repair experiments. Known policy-lock/restored Electron failures and bounded repeat results are tracked in [#191](https://github.com/fitchmultz/pi-agent-browser-native/issues/191) and the [current support matrix](SUPPORT_MATRIX.md#current-0616-rollout).
23
+
24
+ Linux/macOS, declared Node floors, official/fork host and consumer-artifact checks, and Native checkpoint Linux remain required. All three final reviewers (reviewer-gpt, reviewer-ponytail, reviewer-claude) must approve before merge, release or activation. This waiver changes neither runtime nor the composed `release`/`prepublishOnly` scripts: record individually satisfied gates and the explicit Windows waiver, never label the full composition passed.
25
+
26
+ Docs/workflow-only changes may reuse prior live/platform evidence when the complete compiled runtime, runtime dependencies and relevant tests are unchanged. Changed published files require a new retained tarball checked by `verify-package.mjs --smoke-pi`, including runtime-only SDK and actual CLI loading. Publish those exact checked bytes without repacking.
27
+
20
28
  ## Pre-release checks
21
29
 
22
30
  From the repository root:
@@ -47,7 +55,7 @@ npm run verify -- pre-pr
47
55
  3. `npm run verify -- package-pi`, which first validates package contents via `npm pack --json --dry-run` and then smoke-loads the packed package in Pi isolation
48
56
  4. `npm run smoke:platform:doctor` and the full Crabbox matrix from [`platform-smoke.md`](platform-smoke.md): macOS SSH, Ubuntu local-container, and native Windows Parallels targets running fast target-local `platform-build` plus `browser-dogfood-smoke`
49
57
 
50
- The 0.6.15 release uses the documented [local macOS and hosted native-Windows alternatives](platform-smoke.md#alternate-native-transports) instead of SSH and Parallels. Run and inspect those target-local suites separately alongside the remaining release checks. The default composed gate is unchanged; alternate evidence must not be labeled a passing Crabbox matrix.
58
+ When using the documented, maintainer-approved [local macOS and hosted native-Windows alternatives](platform-smoke.md#alternate-native-transports), run and inspect those target-local suites separately alongside the remaining release checks. The default composed gate is unchanged; alternate evidence must not be labeled a passing Crabbox matrix.
51
59
 
52
60
  `npm publish` runs npm’s `prepublishOnly` script from `package.json`, which executes the same `npm run verify -- release` gate and then `npm pack --dry-run`. That concatenated gate is everything in the default `npm run verify` step (generated playbook drift, clean `dist/` build, TypeScript, the unit/fake suite, generated command-reference blocks, and live upstream command-reference sampling against the targeted `agent-browser` on `PATH`), the configured-source lifecycle harness, the packaged Pi smoke in `package-pi`, and the release-blocking Crabbox platform matrix. Using `npm publish --ignore-scripts` skips that contract intentionally.
53
61
 
@@ -282,6 +290,32 @@ This suite requires the installed stable `agent-browser --version` to meet the m
282
290
 
283
291
  The default unit suite also runs `agentBrowserExtension passes through core command coverage fallback matrix` in [`test/agent-browser.extension-passthrough-validation.test.ts`](https://github.com/fitchmultz/pi-agent-browser-native/blob/main/test/agent-browser.extension-passthrough-validation.test.ts): a fake upstream records argv so explicit `--session connector connect 9222`, plus `download` with a selector and path, `get url`, `snapshot --compact`, and `tab new` / `tab t1` / `tab close` on implicit managed sessions, still prove `--json` and session ordering without a browser. A second fake-upstream matrix in that file (`agentBrowserExtension passes through non-core network debug diff stream dashboard and chat families`) pins representative `network`, `diff`, `trace` / `profiler` / `record`, `console` / `errors` / `highlight` / `inspect` / `clipboard`, `stream`, `dashboard`, and `chat` JSON shapes plus redacted `details.data` and argv echoes without a browser. A third matrix (`agentBrowserExtension passes through provider and specialized skill workflows`) asserts provider `open` argv shapes still receive `--json` plus implicit `--session` while read-only `skills get …` stays stateless (no managed session fields) and provider credential env vars are forwarded into the fake upstream log. Extend those matrices when adding passthrough coverage that should stay out of the slow real-upstream loop.
284
292
 
293
+ ### Native checkpoint qualification
294
+
295
+ `test/agent-browser.checkpoint-native.test.mjs` is an opt-in, model-free test for a Pi SDK exposing `acquireCheckpoint` with native failed-journal repair/flush (qualified against core `c401cada69172dfd683e6d569b2cd9e5a45b4fcd`). It loads the compiled extension through native resource discovery, dispatches real tools through an awaited native command, and records real results with SessionManager. It checks idle/release/reload, live root and managed browsers (including a stable ambient socket override without a Pi override), off-branch state, detached and native-owned scripts, recording, failed journal appends/cleanup leases, routes, trace, caller-owned CDP attachment, and native checkpoint selection/entries plus root restore after replacing checkout/state directories with fresh inodes. No provider or fake readiness classifier is used.
296
+
297
+ Run as a non-root user in an isolated environment with stock `agent-browser`, Chrome for Testing and ffmpeg. Clear inherited credentials/configuration; do not use a personal browser. The test creates its own HOME/socket directory, copies only the supplied browser binary directory, serves a loopback synthetic page, explicitly closes its sessions, and removes its fixtures. `PI_CHECKPOINT_TEST_BROWSER_DIR` must name a binary installation directory such as `.../.agent-browser/browsers/chrome-<version>`, never a browser profile.
298
+
299
+ ```bash
300
+ npm run build
301
+ PI_CHECKPOINT_TEST_REQUIRED=1 \
302
+ PI_CHECKPOINT_TEST_SDK=/path/to/native/pi-coding-agent/dist/index.js \
303
+ PI_CHECKPOINT_TEST_BROWSER_DIR=/path/to/browsers/chrome-<version> \
304
+ node --test test/agent-browser.checkpoint-native.test.mjs
305
+ ```
306
+
307
+ `PI_CHECKPOINT_TEST_EXTENSION` can select a separate baseline checkout. With `PI_CHECKPOINT_TEST_BASELINE_RESTORE=1`, only its existing root-restore path runs and every receipt must retain the old shutdown blocker; this is not positive sleep qualification. The SDK selector is test-only; it does not change package peers or activate an extension. This gate is separate from the default 0.84 unit fixtures and release/platform/model-based gates. Qualification is for ordinary native save-on-close and explicit URL reopen, not a live browser snapshot. Journal fault injection checks that ongoing native I/O errors reject acquisition and preserve prior bytes/accepted entries. Permission repair must recover through core capture without repeating the failed append; recording retry and cleanup leases remain extension-owned. The test also checks exact repaired entries/selection in a separate-process strict cold restore and rejection of a mismatched artifact. After explicit close it observes native daemon exit before asserting idle readiness; an acknowledgement alone is not proof of inactivity. The abnormal-restart regression kills only its exact SDK fixture PID after ordinary branch navigation, then reopens the real journal in a fresh SDK process. It verifies that the off-branch wrapper-created daemon blocks capture without acquiring cleanup ownership, and that explicit managed-looking names and a nonowned namespace retain ambient routing. Same-namespace/same-name controls check distinct live daemons in owned and ambient socket roots, continued blocking after closing only the caller, ambient-only blocking after closing the historical daemon, and positive readiness after both are explicitly closed. All browser ownership evidence comes from executed native tools, never injected ownership records.
308
+
309
+ #### Required Linux PR gate
310
+
311
+ [`.github/workflows/native-checkpoint.yml`](https://github.com/fitchmultz/pi-agent-browser-native/blob/main/.github/workflows/native-checkpoint.yml) runs **Native checkpoint Linux** on every PR to `main`, pushes to `main`, and manual dispatch. Require this check in branch protection before merging checkpoint changes (repository settings are separate from the workflow). It does not replace release/manual-publication gates or full Linux personal-workspace sleep/resume qualification.
312
+
313
+ The CI-only recipe in [`scripts/ci-native-checkpoint.sh`](https://github.com/fitchmultz/pi-agent-browser-native/blob/main/scripts/ci-native-checkpoint.sh) builds the public fork target selected by `host-targets.json` in the workflow's pinned `fitchmultz/.github` automation revision, using the fork's lockfile and documented `npm ci --ignore-scripts`, `hydrate:model-data`, `build:offline` sequence. It uses explicit Node **26.9.0**, the extension's unchanged lockfile, stock **agent-browser 0.38.1**, a fresh stock Chrome-for-Testing download, and Ubuntu ffmpeg. No browser fork, personal profile, credentials, or model calls are involved.
314
+
315
+ Sources live under a private `/tmp` root, outside HOME ancestry. Setup and tests use `env -i`; verification runs non-root in a loopback-only network namespace after downloads finish. Shared compatibility checks select independent official/fork graphs for types, tests and consumer installation. This workflow additionally runs the locked official graph on Node 26 before network isolation, then generated-document checks and live command-reference sampling inside the loopback namespace. The separate native gate requires **13 passed, zero failed/cancelled/skipped/todo** in its TAP output. `PI_CHECKPOINT_TEST_REQUIRED=1` turns a missing SDK into a failure and rejects root/baseline-only runs; missing browser parameters, binaries, or checkpoint APIs fail normally. Without the flag or SDK, direct local invocation remains opt-in and skips.
316
+
317
+ CI uploads raw setup/build/provenance logs, Node 26 and document verification output, and native TAP (including receipts), even on failure. Chrome is downloaded through the pinned stock installer, not separately version-pinned; the actual browser version is logged. These checks prove synthetic native behavior on that runner and sourcehost, not real-site authentication, Electron-app behavior, managed-key cold portability, or complete personal qualification. Passing this gate does not replace code review or full personal-workspace acceptance.
318
+
285
319
  ### Native Linux socket-root regression
286
320
 
287
321
  `test/agent-browser.socket-namespace.test.ts` is opt-in (`PI_AGENT_BROWSER_SOCKET_NAMESPACE=1`). Run it with the installed `tsx` test runner inside a disposable bubblewrap user/mount namespace: a read-only mode-`0755` `/` whose owner is unmapped, and a current-user-owned mode-`0700` tmpfs at `/tmp`. Use a cleared environment with only the required executable PATH, private HOME and the opt-in variable. The test verifies those identities, exchanges data over a real Unix socket, dispatches native `agent-browser --version` through the shared subprocess wrapper, and inspects a disposable managed session with automatic restore enabled. It does not launch a browser or call a model.
@@ -77,7 +77,7 @@ Browser evidence (RQ-0152) is covered by `test/agent-browser.browser-evidence.te
77
77
 
78
78
  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.
79
79
 
80
- 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.
80
+ 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 live output through temp cleanup, retained logs through native host-exit behavior, and persistence failure reporting. Tracked-child cleanup regressions require actual exit before profile removal and immediate executable deletion. Restored-PID cleanup uses native process command-line evidence on Windows and POSIX; real-process regressions verify matching profiles with spaces/Unicode, refusal of unrelated or mismatched profiles, and shutdown after transcript restoration. 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.
81
81
 
82
82
  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 about conservative ref invalidation after dispatched recording starts and URL-bearing restarts, on success or failure, without claiming an observed page change. `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.
83
83
 
@@ -97,6 +97,16 @@ Shared native defaults are covered by `test/agent-browser.shared-defaults.test.t
97
97
 
98
98
  Browser correctness regressions cover credential-bearing filtered network details/exports, native generated-Blob downloads with actual saved bytes, accessible-name click target identity and abstention, smooth CSS-container movement, and parseable early-result JSON. Native fixture coverage complements deterministic dispatch/cleanup and output-shape checks; release qualification remains separate.
99
99
 
100
+ ## Current 0.6.16 rollout
101
+
102
+ On 2026-09-21 the owner explicitly instructed **“waive windows”** for this rollout, superseding older required-Windows/no-waiver guidance even where runners are available. Windows diagnostics remain visible but nonblocking; do not interpret a successful compatibility aggregate as a Windows pass. Follow-up: [#191](https://github.com/fitchmultz/pi-agent-browser-native/issues/191).
103
+
104
+ At runtime source `3a9adf490883149e7f79ee070d1900877e17b891`, PR compatibility passed seven of eight lanes, including official Windows with its full consumer contract; fork Windows failed one restored Electron cleanup case. The private combined Windows run had two policy-lock failures (1,162 passed, 37 skipped, zero cancelled out of 1,201). One unchanged Electron repeat passed without a product patch; the original cause is unknown. Historical focused Windows passes below retain only their stated scope, not full Windows qualification.
105
+
106
+ Fresh precommit evidence for that runtime passed real Pi checkout/live-site and supported disposable Electron smoke, configured-source lifecycle, packaged runtime-only SDK/CLI loading, real-upstream checks, native macOS and the canonical named Ubuntu target (Debian 12). Local macOS is the documented alternate, not an SSH pass. Docs/workflow-only delivery may reuse this runtime evidence only after all 104 rebuilt runtime files match the checked artifact; changed published docs require a newly checked retained tarball. No additional paid/live gate is implied by an otherwise byte-identical runtime.
107
+
108
+ Linux/macOS, declared Node floors, official/fork host identities, required consumer artifacts and Native checkpoint Linux remain required. Required current-head CI and all three final reviewer approvals precede merge/release/activation. The unchanged composed `release`/`prepublishOnly` command still includes Windows and is not reported as passed under this waiver; see [release guidance](RELEASE.md#current-0616-rollout-waiver).
109
+
100
110
  ## Verification evidence
101
111
 
102
112
  **0.38.1 PR evidence (2026-09-17, local macOS):** the full unit/fake suite passed 1,146 tests with 27 opt-in skips; the final review fixes then passed typecheck and 64 affected tests. Generated docs, live command-reference sampling, and the 132-file packaged Pi smoke passed. The real-upstream contract and 75 native batch/fidelity checks passed, as did all eight deterministic dogfood flows. Interactive compiled-checkout Pi smoke covered example.com and react.dev, delta/ref follow-ups, conditional screenshots, and a recording batch whose persisted result had two verified artifacts and no pending artifacts. Separate native probes confirmed helper-consumed WebMCP catalog updates remain visible. These checks do not qualify release-only platform/lifecycle gates; the hosted native-Windows result is recorded on the PR.
@@ -107,7 +117,7 @@ Re-run the gates below before each release. Dated outcomes in this table are his
107
117
  | --- | --- | --- |
108
118
  | Default local gate | `npm run verify` checks generated playbook drift, clean-builds generated `dist/`, runs `tsc --noEmit`, unit/fake tests, generated command-reference blocks, and live command-reference sampling. | **Earlier 0.36.0:** pass on 2026-09-01; 779 tests passed, two opt-in real-upstream tests skipped, and build/typecheck/docs/live command-reference gates passed. |
109
119
  | Pre-PR local gate | `npm run verify -- pre-pr` composes the default gate with package-content verification. Use before larger local handoffs or PR-ready claims when lifecycle/platform/live dogfood cost is not warranted. | **Earlier 0.36.0 / package 0.6.2:** pass on 2026-09-01; 779 tests passed, two opt-in tests skipped, and the 127-file tarball verified. |
110
- | Windows argument transport (#102/#131/#141) | `node --import tsx --test test/agent-browser.windows-argv.test.ts` exercises the actual host subprocess with independent expected argv, empty fill/launch/namespace values, literal quotes, Unicode/spaces, stdin, child-`PATH` custom shims and missing-command/nonzero-exit truth. It also runs in the default and `platform-target` gates. Windows uses `cross-spawn`; POSIX keeps native Node `spawn`. | The old PowerShell Legacy path dropped empty operands and split ordinary doublequoted fill text; the quoted-empty-only proposal fixed the former but left the latter broken. Native macOS and Ubuntu validate the unchanged POSIX subprocess path. Native Windows/`cmd.exe` and affected-host managed open → snapshot → close retests remain unrun under the task-specific waiver; neither Linux PowerShell diagnostics nor dependency tests are reported as native Windows proof. |
120
+ | Windows argument transport (#102/#131/#141) | `test/agent-browser.windows-argv.test.ts` exercises actual host argv, empty values, quotes, Unicode/spaces, stdin, custom shims and failure outcomes. `test/agent-browser.windows-stock-launcher.test.ts` covers the recognized global npm CMD template's direct-native path, command selection, fallback, literal LF/CRLF and cancellation. Custom or unrecognized batch launchers retain their existing newline limitation. POSIX keeps Node `spawn`. | Native Windows focused qualification on 2026-09-21 passed all seven stock-launcher controls, all 29 Chromium cases and 33 process/argv cases (two existing platform skips). Actual upstream Rust received all 12 expected operands; the original CMD path lost all four newline-bearing operands. These focused results do not replace full-suite, managed-browser, package or release gates. |
111
121
  | Real upstream contract | `npm run verify -- real-upstream` runs the localhost fixture matrix against a real stable `agent-browser` at or above the configured floor. | **Earlier recommended 0.36.0:** pass on 2026-09-01 (2/2 tests), including WebMCP and `--no-webmcp`; the 0.35.0 floor last passed on 2026-08-26 (2/2 tests). |
112
122
  | Packaged Pi smoke | `npm run verify -- package-pi` validates package contents, installs the extracted tarball's runtime dependencies without lifecycle scripts or host peers, loads the packaged `agent_browser` tool without requiring optional Brave config, and executes fake-upstream `--version`. | **Earlier package 0.6.2 / Pi 0.84.4:** pass on 2026-09-01; the 127-file tarball loaded exactly one packaged `agent_browser` and executed `--version`. |
113
123
  | Startup profile | `npm run verify -- startup-profile --samples <n>` clean-builds generated `dist/`, records direct package entrypoint import/factory timing in fresh Node processes, and writes `.artifacts/startup-profile/latest.json`. It must not launch Pi, tmux, mise, npm, browsers, or `agent-browser`; full Pi TUI ready-prompt profiling is intentionally excluded after it proved too invasive for routine verification. Run this opt-in evidence when package layout, the compiled entrypoint, top-level imports, schema registration, or prompt/config startup logic changes. | **Earlier compiled 0.6.2 entrypoint:** pass on 2026-09-01 with three samples; median 104.9 ms and max 114.5 ms, below the 250 ms budget. |
@@ -820,6 +820,8 @@ For `script`, the top-level category describes the whole orchestration and clean
820
820
 
821
821
  These categories are intentionally bounded and stable so agents can branch on them instead of parsing prose. They do not replace raw diagnostics: `details.error`, `details.stderr`, `details.parseError`, `details.validationError`, and visible content still preserve the specific upstream or wrapper message after normal redaction.
822
822
 
823
+ `details.exitCode` preserves the native child close code when available, so it is not a portable timeout indicator: a Windows process stopped with `taskkill` can report `1`. The wrapper uses `124` for its timeout when no native close code is available. Use `failureCategory`, `timedOut`, and `aborted` to distinguish these outcomes.
824
+
823
825
  For argv-supplied `--allowed-domains`, the wrapper treats domain containment as launch-scoped. Upstream owns request, worker, popup, and WebRTC containment plus incompatible-mode rejection; the wrapper passes the setting and upstream result through unchanged.
824
826
 
825
827
  Real Pi custom tools only mark a tool result failed when the tool throws during `execute`; returned `isError` fields are not authoritative. The extension therefore also registers a `tool_result` handler that treats any `agent_browser` result with `details.resultCategory: "failure"` as a real Pi tool error. For normal prose output, it appends `Result category: failure; failureCategory: …; Pi tool isError: true.` to model-visible text. For caller-requested `--json` output, it only patches `isError` and preserves visible parseable JSON content unchanged. The TUI renderer also repeats that category line at the top of failed rendered results so collapsed failed rows keep the outcome visible. The hook treats `--json` as requested when echoed `details.args` or the original tool `input.args` includes that flag; it skips appending the prose notice when any non-empty text content item is parseable JSON, even if other text items are not parseable. Invalid or non-JSON text still gets the visible prose notice. Implementation: `buildAgentBrowserToolResultPatch` in `extensions/agent-browser/lib/pi-tool-rendering.ts`; `extensions/agent-browser/index.ts` registers the handler. This keeps Pi transcript semantics aligned with the machine-readable result contract, including wrapper-side reclassifications such as `qa-failure` after an upstream-successful batch and `artifact-missing` after an upstream-successful artifact command whose requested file is absent.
@@ -2,7 +2,9 @@
2
2
 
3
3
  `pi-agent-browser-native` uses a Crabbox-backed local platform smoke gate to prove the package on macOS, Ubuntu Linux, and native Windows before release.
4
4
 
5
- This is a release-blocking gate. Missing setup is not a skipped pass. When a maintainer approves an [alternate native transport](#alternate-native-transports), it must prove the same target-local suites; missing upstream `agent-browser` or browser dependencies still blocks that target.
5
+ **Current 0.6.16 rollout exception:** the owner explicitly waived Windows qualification on 2026-09-21, including available runners. Windows failures remain visible but nonblocking, not passed; no further Windows repair experiments are part of this rollout. Linux/macOS and all other required checks remain required. See [release waiver and artifact requirements](RELEASE.md#current-0616-rollout-waiver) and [follow-up #191](https://github.com/fitchmultz/pi-agent-browser-native/issues/191). The commands below are unchanged and a waived Windows result does not make the full composition a passing matrix.
6
+
7
+ Outside that rollout exception, this is a release-blocking gate. Missing setup is not a skipped pass. When a maintainer approves an [alternate native transport](#alternate-native-transports), it must prove the same target-local suites; missing upstream `agent-browser` or browser dependencies still blocks that target.
6
8
 
7
9
  ## Required release gate
8
10
 
@@ -34,11 +36,11 @@ npm run verify -- platform-smoke run --target ubuntu --suite platform-build
34
36
  | --- | --- | --- | --- |
35
37
  | `macos` | `ssh` static localhost | POSIX shell on macOS | Required |
36
38
  | `ubuntu` | `local-container` | POSIX shell in a Docker-compatible local container | Required |
37
- | `windows-native` | `parallels` | native Windows PowerShell over OpenSSH | Required |
39
+ | `windows-native` | `parallels` | native Windows PowerShell over OpenSSH | Nonblocking for the owner-waived 0.6.16 rollout; otherwise required |
38
40
 
39
41
  ## Alternate native transports
40
42
 
41
- For the 0.6.15 release, local macOS execution replaces localhost SSH, and the [Native Windows workflow](https://github.com/fitchmultz/pi-agent-browser-native/blob/main/.github/workflows/windows-native.yml) runs the existing Windows suites on a GitHub-hosted Windows runner. These are native platform checks, not Crabbox SSH or Parallels passes. The default `release` and `prepublishOnly` commands still select the Crabbox matrix; record alternate suite evidence separately rather than reporting those commands as passed.
43
+ With maintainer approval, local macOS execution can replace localhost SSH, and the [Native Windows workflow](https://github.com/fitchmultz/pi-agent-browser-native/blob/main/.github/workflows/windows-native.yml) runs the existing Windows suites on a GitHub-hosted Windows runner. Record these as native platform checks, separately from Crabbox SSH or Parallels results. The default `release` and `prepublishOnly` commands still select the Crabbox matrix; record alternate suite evidence separately rather than reporting those commands as passed.
42
44
 
43
45
  On macOS, run the unchanged commands returned by `buildPlatformBuildCommand('macos', 'pi-agent-browser-native', 24)` and `buildBrowserDogfoodCommand('macos', '0.38.1', true)` in [`scripts/platform-smoke/targets.mjs`](../scripts/platform-smoke/targets.mjs), serially in a clean private source copy. Use private HOME, npm and Pi settings, and headless browser profiles. No Remote Login or host security change is required.
44
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.6.15",
3
+ "version": "0.6.16",
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)",
@@ -65,11 +65,12 @@
65
65
  "typebox": "*"
66
66
  },
67
67
  "devDependencies": {
68
- "@earendil-works/pi-ai": "0.84.0",
69
- "@earendil-works/pi-coding-agent": "0.84.0",
70
- "@earendil-works/pi-tui": "0.84.0",
68
+ "@earendil-works/pi-ai": "0.86.1",
69
+ "@earendil-works/pi-coding-agent": "0.86.1",
70
+ "@earendil-works/pi-tui": "0.86.1",
71
71
  "@types/cross-spawn": "6.0.6",
72
72
  "@types/node": "^25.9.3",
73
+ "@types/which": "2.0.2",
73
74
  "tsx": "^4.21.0",
74
75
  "typebox": "^1.1.38",
75
76
  "typescript": "^6.0.3"
@@ -90,8 +91,9 @@
90
91
  "smoke:platform:ubuntu": "node scripts/platform-smoke.mjs run --target ubuntu",
91
92
  "smoke:platform:windows-native": "node scripts/platform-smoke.mjs run --target windows-native",
92
93
  "smoke:platform:all": "npm run smoke:platform:doctor && node scripts/platform-smoke.mjs run --target macos,ubuntu,windows-native",
94
+ "check:compat": "node scripts/compat-host.mjs && npm run typecheck && npm test && node scripts/verify-package.mjs --smoke-pi",
93
95
  "typecheck": "node ./scripts/project.mjs verify typecheck",
94
- "test": "node ./scripts/build.mjs && tsx --test --test-concurrency=1 test/**/*.test.ts",
96
+ "test": "node ./scripts/build.mjs && tsx --test --test-reporter=tap --test-concurrency=1 test/**/*.test.ts",
95
97
  "verify": "node ./scripts/project.mjs verify",
96
98
  "prepublishOnly": "npm run verify -- release && npm pack --dry-run",
97
99
  "build": "node ./scripts/build.mjs",
@@ -114,6 +116,8 @@
114
116
  }
115
117
  },
116
118
  "dependencies": {
117
- "cross-spawn": "7.0.6"
119
+ "cross-spawn": "7.0.6",
120
+ "path-key": "3.1.1",
121
+ "which": "2.0.2"
118
122
  }
119
123
  }
@@ -363,7 +363,7 @@ export async function main(argv = process.argv.slice(2)) {
363
363
  throw new UsageError(`Unknown command: ${command}`);
364
364
  }
365
365
 
366
- if (import.meta.url === `file://${process.argv[1]}`) {
366
+ if (import.meta.main) {
367
367
  main().catch((error) => {
368
368
  if (error instanceof UsageError) {
369
369
  console.error(error.message);
@@ -11,7 +11,7 @@ import { execFile as execFileCallback } from "node:child_process";
11
11
  import { realpathSync } from "node:fs";
12
12
  import { access, readFile } from "node:fs/promises";
13
13
  import { homedir } from "node:os";
14
- import { dirname, resolve, sep } from "node:path";
14
+ import { dirname, isAbsolute, resolve, sep } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { promisify } from "node:util";
17
17
 
@@ -155,7 +155,7 @@ function expandUserPath(path) {
155
155
  }
156
156
 
157
157
  function isPathLikeSource(source) {
158
- return source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.startsWith("~");
158
+ return isAbsolute(source) || source.startsWith("./") || source.startsWith("../") || source.startsWith("~");
159
159
  }
160
160
 
161
161
  function sourceLooksLikeThisPackage(source, cwd, sourceBaseDir = cwd) {