@phnx-labs/agents-cli 1.22.94 → 1.22.96

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,15 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.96
4
+
5
+ - **`agents browser tabs --all` shows every tab open in the profile browser, yours included (PHNX-4043).** Until now `tabs` listed only the task's own tabs, so an agent sharing an Arc Space, a Comet store, or a Firefox profile with you could not see what else was open. `--all` lists every top-level tab: OWNER names the agents-cli task that owns it, or `you` for a tab no task owns; `*` still marks the task's current tab. Read-only on every backend (Chromium `Target.getTargets`, the Arc Space's tabs, Firefox `browsingContext.getTree`). Source: `cli/src/lib/browser/service.ts` `profileTabs`, `cli/src/commands/browser.ts`.
6
+
7
+ - **Firefox automation over WebDriver BiDi (PHNX-4043).** `agents browser` now drives
8
+ Firefox, which dropped the Chrome DevTools Protocol in 129. Every Firefox profile in
9
+ `profiles.ini` is discovered read-only as a `firefox-<name>` browser profile
10
+ (`firefox-default`, `firefox-default-release`, …) pinned to that profile directory;
11
+ agents launch it headless with a debug port (or attach to a running one, failing loud
12
+ when a portless Firefox already holds the profile). Supported verbs: start, navigate,
13
+ tab add, tabs, evaluate, refs, click, fill/type, scroll, screenshot, done — with the
14
+ same same-task reopen semantics as the rest of the service. A trusted pointer click
15
+ goes through `input.performActions`. Network capture, upload, and PDF fail loud with a
16
+ structured error naming a Chromium-family profile. Source: `apps/cli/src/lib/browser/drivers/firefox.ts`,
17
+ `apps/cli/src/lib/browser/firefox-discovery.ts`, `apps/cli/src/lib/browser/service.ts`.
18
+
19
+ - **Comet's own profiles are browser profiles, and discovered profiles reach the whole fleet (PHNX-4042).** On a Mac, `agents browser profiles list` now shows one profile per entry in Comet's profile menu (`comet-work`, ...), read from Comet's `Local State` and pinned to Comet's real user-data dir and `--profile-directory`, next to the `arc-*` rows for Arc Spaces. Agents and you share one Comet window per profile: agents attach when it runs with remote debugging on the profile's port, launch Comet on your own store when it is not running (over a TCP port, so the instance outlives a daemon restart and the ownership guard can verify it), and fail loud with the relaunch command when it runs without a port. The daemon's fleet-sync tick publishes every discovered profile into this device's declaration, so other boxes list it with `WHERE=<device>` and `--profile arc-gmail` from a worker routes to the Mac that owns it. `AGENTS_COMET_DIR` points discovery at another store. Source: `cli/src/lib/browser/chromium-discovery.ts`, `cli/src/lib/browser/profiles.ts`, `cli/src/lib/browser/chrome.ts`, `cli/src/lib/browser/drivers/local.ts`, `cli/src/lib/daemon/usage-sync-service.ts`.
20
+
3
21
  ## 1.22.94
4
22
 
23
+ - **Agents drive your running Arc, one profile per Arc Space (PHNX-2399, macOS).** On a Mac with Arc, `agents browser profiles list` shows every Arc Space as a profile — `arc-gmail`, `arc-work`, `arc-dev`, named after the Space title — discovered read-only from Arc's `User Data/Local State` and `StorableSidebar.json`. A Space already carries its Arc profile's logins, so it is the browser profile agents pick with `--profile`; there is no separate Space flag and nothing to create. `agents browser use arc-gmail` makes it the default. Tabs are created, navigated, evaluated, and closed through Apple Events against the Arc you already have open — no debug port, no relaunch, never a second Arc. Agents only ever touch the tabs they created: durable task state addresses them by stable window, Space, and tab ids, a crash-safe marker intent guards creation, and closing a task removes only its own tabs. Creating a tab makes Arc select it for about 100 ms before the tab you had selected is restored; navigate, evaluate, and close are silent. Native refs, click, fill/type, scroll, and `tab focus` work; screenshots, promise evaluation, trusted input, network/console capture, uploads/downloads, and PDF fail with `ArcNativeCapabilityError` and point at a Chromium-family profile. Source: `cli/src/lib/browser/arc-discovery.ts`, `cli/src/lib/browser/drivers/arc.ts`, `cli/src/lib/browser/arc-dom.ts`, `cli/src/lib/browser/profiles.ts`, `cli/src/lib/browser/service.ts`, `cli/src/commands/browser.ts`.
24
+
25
+ ## 1.22.93
26
+
5
27
  - **An idle reminder is not a permission request — attention is classified from explicit harness evidence, and time-based signals expire (PHNX-3999).** The fleet feed was rendering Claude's `idle_prompt` Notification ("the turn ended and you have been idle") as a `permission` with Approve/Deny — a session whose whole transcript was "reply with exactly: pong" → "pong" got an approval banner — and the state engine labelled any tool call that had run quietly for two minutes a permission too. Now a `notification` block is a `permission` only when its recorded subtype is `permission_prompt` and the session's transcript corroborates it (no tool result or new turn stamped after the block, process alive); the feed-publish hook no longer publishes `idle_prompt` at all, an `idle_prompt` block left on disk yields no request, an `elicitation_dialog` is a question with no invented buttons, and a pending tool call reads as `working` however long it has run. One unresolved record per `host/session/generation` is resolved by later evidence — a transcript event after the block, or a dead process — never by dismissing the banner. What the CLI cannot confirm (a stale hook block with no transcript cursor to check, a notification with no recorded subtype, an older peer's elapsed-time `permission` claim) is the new `unverified` kind: the banner and the `feed watch` stream say "Could not verify request" with an Open terminal action only, and `agents feed answer` refuses it. `computeLiveSignals` now memoizes only the parsed transcript tail on mtime and re-classifies against the current clock every call, so the 30-minute prose-question decay expires on schedule instead of freezing until someone types; rows carry `lastEventMs`, the harness stamp on the last meaningful event. Source: `cli/src/lib/feed/attention.ts`, `cli/src/lib/session/state.ts`, `cli/src/lib/session/active.ts`, `cli/src/lib/feed/feed.ts`, `cli/src/lib/feed/answer.ts`, `cli/src/lib/daemon/attention-notify-service.ts`.
6
28
 
7
29
  - **AGI Menu 1.1.1: three fixed tabs (PHNX-3999, PHNX-4036).** The `menubar` helper floor moves to 1.1.1, the first release cut from phnx-labs/agi-menu: the status item opens a popover with Sessions (working first, then confirmed requests), Projects (the tracker's projects → milestones with cycle chips → issues, expanding in place, from one `linear projects overview --json` call) and Settings, replacing the long dropdown. Fresh installs and `agents menubar setup` fetch it from the public `menubar/v1.1.1` release. Source: `cli/src/lib/helper-versions.ts`.
8
30
 
9
31
  - **AGI Menu moved out — the menu-bar helper now lives in `phnx-labs/agi-menu` (PHNX-4036).** `cli/menubar/` (the SwiftPM source, its build/test scripts, and the build-text test) is gone from this repo, the same split AGI EXT got in RUSH-3189. Nothing user-facing changes: `agents menubar setup`/`enable` still download and verify the signed, notarized `MenubarHelper.app.zip` from the `menubar/v<floor>` release tag (`src/lib/helper-versions.ts`), at the same URL, with the same bundle identity and designated-requirement pin — that release is now cut by agi-menu's `scripts/release.sh`, which also uploads a `menubar-source.txt` provenance sidecar. Release tooling never builds the helper again: a new `scripts/stage-menubar-helper.sh` downloads the published `menubar/v<floor>` asset, verifies its sha256, and (on macOS) extracts it into `bin/MenubarHelper.app` behind `codesign --verify --deep --strict`, `spctl --assess`, and the DR-pin gate (`scripts/verify-menubar-helper.sh`, no longer a prepack gate); `--fetch-only` downloads and sha-verifies on any OS. `release-attestation-produce.sh --with-helpers` records the menubar helper-manifest row from that published asset (asset digest = sha256 of the zip, `source` = the sidecar's repo/commit/tag when the release carries one) instead of a fresh build, keyed on `src/lib/helper-versions.ts` as its input — a floor bump is what re-records it; a missing or corrupt release fails closed naming the agi-menu publish step. `remote-sign-mac.sh` stages the published helper on the home base instead of running `swift build`. The installer's dead `cli/menubar/dist/` source candidate is removed; `bin/MenubarHelper.app` (staged, or copied from an agi-menu checkout) remains the working-tree source. Source: `cli/scripts/stage-menubar-helper.sh`, `cli/scripts/release-manifest.sh`, `cli/scripts/release-attestation-produce.sh`, `cli/scripts/remote-sign-mac.sh`, `cli/src/lib/menubar/download-menubar.ts`.
10
32
 
11
- - **Agents drive your running Arc, one profile per Arc Space (PHNX-2399, macOS).** On a Mac with Arc, `agents browser profiles list` shows every Arc Space as a profile — `arc-gmail`, `arc-work`, `arc-dev`, named after the Space title — discovered read-only from Arc's `User Data/Local State` and `StorableSidebar.json`. A Space already carries its Arc profile's logins, so it is the browser profile agents pick with `--profile`; there is no separate Space flag and nothing to create. `agents browser use arc-gmail` makes it the default. Tabs are created, navigated, evaluated, and closed through Apple Events against the Arc you already have open — no debug port, no relaunch, never a second Arc. Agents only ever touch the tabs they created: durable task state addresses them by stable window, Space, and tab ids, a crash-safe marker intent guards creation, and closing a task removes only its own tabs. Creating a tab makes Arc select it for about 100 ms before the tab you had selected is restored; navigate, evaluate, and close are silent. Native refs, click, fill/type, scroll, and `tab focus` work; screenshots, promise evaluation, trusted input, network/console capture, uploads/downloads, and PDF fail with `ArcNativeCapabilityError` and point at a Chromium-family profile. Source: `cli/src/lib/browser/arc-discovery.ts`, `cli/src/lib/browser/drivers/arc.ts`, `cli/src/lib/browser/arc-dom.ts`, `cli/src/lib/browser/profiles.ts`, `cli/src/lib/browser/service.ts`, `cli/src/commands/browser.ts`.
12
-
13
33
  - **A Claude account-slot launch now runs in its own slot home, and worker slots come pre-onboarded (PHNX-3940 follow-up).** `agents run claude --device <worker>` picked an account slot and pinned `CLAUDE_CONFIG_DIR` to it, but the versioned alias (`~/.agents/.cache/shims/claude@<version>`) re-exported the shared version home unconditionally, so the run authenticated as the picked account (the setup-token env survived) while reading and writing config, onboarding state and history in whatever home the version carried — on yosemite-m1 that home belonged to another account and had never onboarded, so every dispatched run opened on Claude Code's theme picker. `buildExecEnv` now stamps a slot launch in `AGENTS_EXEC_HOME`; every shim that pins a config-dir env (claude, grok, opencode, kimi, muse, copilot — bare shim and direct alias, schema 32 / alias 21 — the daemon self-heal or `agents doctor --fix` regenerates on-disk copies) yields its pin to that slot through one shared `slotAwareConfigEnvBash` and consumes the marker before `exec`, so a nested launch never inherits its parent's slot and a bare `<agent>@<version>` from a terminal still gets the version home; the same override was live for all six, codex and cursor already yielded. A re-seed that still reads as unseeded (a slot config file that exists but cannot be parsed) is reported as an error each tick instead of a false "provisioned". Separately, `provisionWorkerSlot` seeded only the identity email into a slot's `.claude.json`, never `hasCompletedOnboarding` — a worker has no human to finish onboarding — so every provisioned slot re-onboarded; it now seeds both, and the daemon's `reconcileLocalWorkerSlots` re-provisions a durable claude slot that lacks either instead of skipping it as "already provisioned", so the slots provisioned before this converge on the next tick. Source: `cli/src/lib/harness/adapter.ts`, `cli/src/lib/harness/adapters/*.ts`, `cli/src/lib/exec.ts`, `cli/src/lib/installations/shims.ts`, `cli/src/lib/claude-account-token.ts`, `cli/src/lib/secrets-policy.ts`, `cli/docs/credential-management.md`.
14
34
 
15
35
  ## 1.22.92
@@ -3,7 +3,7 @@ import chalk from 'chalk';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
- import { listProfiles, getProfile, createProfile, deleteProfile, getConfiguredDefaultProfileName, resolveProfileRef, resolveProfileRefForStart, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, formatProfilesTable, editProfile, renameProfile, assertRegistrableProfileName, isProfileLaunchableHere, isAttachOnlyProfile, resolveProfileDataDir, normalizeDataDir, persistDiscoveredArcProfile, } from '../lib/browser/profiles.js';
6
+ import { listProfiles, getProfile, createProfile, deleteProfile, getConfiguredDefaultProfileName, resolveProfileRef, resolveProfileRefForStart, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, formatProfilesTable, editProfile, renameProfile, assertRegistrableProfileName, isProfileLaunchableHere, isAttachOnlyProfile, resolveProfileDataDir, normalizeDataDir, persistDiscoveredProfile, } from '../lib/browser/profiles.js';
7
7
  import { declaringDevices, migrateCentralBrowserProfiles, profileKind } from '../lib/browser/registry.js';
8
8
  import { resolveActor } from '../lib/actor.js';
9
9
  import { loginsForProfile, profilesLoggedInto, serviceForUrl, loginsWithAccountsForProfile, accountsForProfile, credKeysForService, AUTH_SIGNATURES, } from '../lib/browser/login-detection.js';
@@ -442,7 +442,7 @@ export async function runBrowserUse(name, opts, interactive = isInteractiveTermi
442
442
  console.error(`Available profiles: ${all.map((profile) => profile.name).join(', ')}`);
443
443
  return false;
444
444
  }
445
- await persistDiscoveredArcProfile(selectedName);
445
+ await persistDiscoveredProfile(selectedName);
446
446
  setConfigValue('browser.profile', selectedName);
447
447
  const endpoint = Object.values(getEndpointPresets(target))[0]?.target ?? '';
448
448
  console.log(`Default browser profile (this machine) is now "${selectedName}" (${target.browser}${endpoint ? `, ${endpoint}` : ''}).`);
@@ -503,7 +503,7 @@ function registerProfilesCommands(browser) {
503
503
  console.log(JSON.stringify(allProfiles.map((profile) => ({
504
504
  ...profile,
505
505
  devices: profile.devices,
506
- kind: profile.arc ? 'identity' : profileKind(profile.name),
506
+ kind: profile.arc || profile.firefox ? 'identity' : profileKind(profile.name),
507
507
  isConfiguredDefault: profile.name === configuredDefault,
508
508
  })), null, 2));
509
509
  return;
@@ -950,6 +950,11 @@ function registerProfilesCommands(browser) {
950
950
  console.log(`Arc Space: ${profile.arc.spaceTitle} (${profile.arc.spaceId})`);
951
951
  console.log(`Arc profile: ${profile.arc.profileName} (${profile.arc.profileId})`);
952
952
  }
953
+ if (profile.firefox) {
954
+ console.log(`Firefox profile: ${profile.firefox.profileName}${profile.firefox.isDefault ? ' (default)' : ''}`);
955
+ if (profile.userDataDir)
956
+ console.log(`Profile directory: ${profile.userDataDir}`);
957
+ }
953
958
  const presets = getEndpointPresets(profile);
954
959
  const defaultName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
955
960
  ? profile.defaultEndpoint
@@ -975,7 +980,7 @@ function registerProfilesCommands(browser) {
975
980
  console.log(`Headless: true`);
976
981
  // Login state per known service: live session + account identity + whether
977
982
  // login creds are declared in the profile's secrets bundle.
978
- if (profile.arc)
983
+ if (profile.arc || profile.firefox)
979
984
  return;
980
985
  const active = await loginsForProfile(profile.name);
981
986
  const accounts = await accountsForProfile(profile.name);
@@ -1119,11 +1124,17 @@ function registerProfilesCommands(browser) {
1119
1124
  profile that is in use, the configured default, or the \`auto-chrome\`
1120
1125
  profile a setup wizard created.
1121
1126
 
1122
- On a Mac with Arc, every Arc Space is listed as a profile (arc-personal,
1127
+ On a Mac with Arc, every Arc Space is listed as a profile (arc-gmail,
1123
1128
  arc-work, ...) discovered read-only from Arc's own metadata — a Space
1124
1129
  already carries its Arc profile's logins, so it IS the browser profile.
1125
1130
  Agents attach to the running Arc through Apple Events and never launch or
1126
- relaunch it. Point agents at Comet instead when a workflow needs screenshots,
1131
+ relaunch it. Comet's own profiles are listed the same way (comet-work, ...),
1132
+ pinned to Comet's real user-data dir, so agents and you share one Comet
1133
+ window per profile: agents attach when it is running with remote debugging,
1134
+ launch it on your store when it is not running, and fail loud with the
1135
+ relaunch command when it runs without a port. The daemon publishes every
1136
+ discovered profile into this device's declaration, so other fleet boxes list
1137
+ it with WHERE=<this device> and route to it. Point agents at Comet when a workflow needs screenshots,
1127
1138
  downloads, network capture, or trusted input:
1128
1139
  agents browser profiles create agents-comet --browser comet --attach-only
1129
1140
  A CDP \`--attach-only\` profile attaches to a browser you
@@ -1142,7 +1153,7 @@ function registerProfilesCommands(browser) {
1142
1153
  console.error(`Profile "${name}" not found`);
1143
1154
  process.exit(1);
1144
1155
  }
1145
- if (profile.arc && !isFleetRemoteInvocation()) {
1156
+ if ((profile.arc || profile.firefox) && !isFleetRemoteInvocation()) {
1146
1157
  const routed = resolveBrowserTarget(profile.name);
1147
1158
  if (!routed.local && routed.commandDispatch) {
1148
1159
  const result = await dispatchBrowserToDevice(routed.device, ['browser', 'profiles', 'doctor', name], 'capture');
@@ -1767,7 +1778,7 @@ function registerTaskCommands(browser) {
1767
1778
  // Discovery is read-only for list/show/doctor. Starting is the explicit
1768
1779
  // adoption point: persist only the agents-cli alias/native identity, never
1769
1780
  // mutate Arc's own profile or Space data.
1770
- await persistDiscoveredArcProfile(profileName);
1781
+ await persistDiscoveredProfile(profileName);
1771
1782
  // A native endpoint is not CDP and cannot be tunnelled. Re-exec the
1772
1783
  // complete command on the declaring owner, then bind its returned task
1773
1784
  // locally so every later verb follows the same owner from task-index.
@@ -2224,15 +2235,17 @@ function registerTaskCommands(browser) {
2224
2235
  });
2225
2236
  browser
2226
2237
  .command('tabs')
2227
- .description('List tabs open for the current task')
2238
+ .description('List tabs open for the current task; --all shows every tab open in the profile browser, yours included')
2228
2239
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
2229
2240
  .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
2241
+ .option('--all', "Every tab open in the profile browser, not only this task's: OWNER names the owning task, or \"you\" for a tab no task owns")
2230
2242
  .option('--json', 'Output machine-readable JSON')
2231
2243
  .action(async (opts) => {
2232
2244
  const task = resolveTaskName(opts);
2233
2245
  const response = await sendIPCRequest({
2234
2246
  action: 'tab-list',
2235
2247
  task,
2248
+ ...(opts.all ? { all: true } : {}),
2236
2249
  });
2237
2250
  if (!response.ok) {
2238
2251
  if (opts.json) {
@@ -2251,6 +2264,16 @@ function registerTaskCommands(browser) {
2251
2264
  console.log('No tabs open');
2252
2265
  return;
2253
2266
  }
2267
+ if (opts.all) {
2268
+ console.log('TAB'.padEnd(12) + 'OWNER'.padEnd(18) + 'URL');
2269
+ console.log('-'.repeat(88));
2270
+ for (const t of response.tabs) {
2271
+ const owner = t.task ?? 'you';
2272
+ const current = t.current ? ' *' : '';
2273
+ console.log(t.id.slice(0, 11).padEnd(12) + owner.slice(0, 17).padEnd(18) + t.url.slice(0, 55) + current);
2274
+ }
2275
+ return;
2276
+ }
2254
2277
  console.log('TAB'.padEnd(12) + 'URL');
2255
2278
  console.log('-'.repeat(70));
2256
2279
  for (const t of response.tabs) {
@@ -38,6 +38,23 @@ export interface LaunchResult {
38
38
  port: number;
39
39
  wsUrl: string;
40
40
  }
41
+ /**
42
+ * The process that holds a Chromium user-data dir, read from the `SingletonLock`
43
+ * symlink Chromium keeps inside the dir on macOS and Linux (`<host>-<pid>`,
44
+ * PHNX-4042). A live pid means a browser already owns that store: a launch on it
45
+ * would only hand its arguments to the running instance and the requested debug
46
+ * port would never bind. Windows keeps no lock file, so this reports null there
47
+ * and `launchBrowser` catches the hand-off by the child's early exit instead.
48
+ */
49
+ export declare function storeOccupant(userDataDir: string): {
50
+ pid: number;
51
+ } | null;
52
+ /**
53
+ * The relaunch that makes a browser holding `userDataDir` attachable: the
54
+ * ownership guard reads `--user-data-dir` off the running process, so the
55
+ * owner's normal launch (no flag) can never be verified.
56
+ */
57
+ export declare function storeRelaunchCommand(browserType: BrowserType, port: number, userDataDir: string, profileDirectory?: string): string;
41
58
  /**
42
59
  * Resolve a browser-profile secrets bundle into an env map for the child, or an
43
60
  * EMPTY map when the bundle is absent, locked, or otherwise unreadable — never a
@@ -24,6 +24,10 @@ const BROWSER_PATHS = {
24
24
  brave: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
25
25
  edge: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
26
26
  arc: ['/Applications/Arc.app/Contents/MacOS/Arc'],
27
+ firefox: [
28
+ '/Applications/Firefox.app/Contents/MacOS/firefox',
29
+ '/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox',
30
+ ],
27
31
  custom: [],
28
32
  },
29
33
  linux: {
@@ -34,6 +38,10 @@ const BROWSER_PATHS = {
34
38
  edge: ['/usr/bin/microsoft-edge'],
35
39
  // Arc has no Linux build (macOS + Windows only).
36
40
  arc: [],
41
+ // `/usr/bin/firefox` on Ubuntu is the snap wrapper script: it execs the snap
42
+ // in place, so the pid survives and `--remote-debugging-port` works through
43
+ // it. The driver reads the real pid from the BiDi handshake anyway.
44
+ firefox: ['/usr/bin/firefox', '/snap/bin/firefox', '/usr/lib/firefox/firefox', '/opt/firefox/firefox'],
37
45
  custom: [],
38
46
  },
39
47
  win32: {
@@ -63,6 +71,11 @@ const BROWSER_PATHS = {
63
71
  // Arc ships a Windows build, but its install path is not yet verified here;
64
72
  // leave unlisted (undetected) rather than guess a path that false-positives.
65
73
  arc: [],
74
+ firefox: [
75
+ `${WIN_PROGRAMFILES}\\Mozilla Firefox\\firefox.exe`,
76
+ `${WIN_PROGRAMFILES_X86}\\Mozilla Firefox\\firefox.exe`,
77
+ `${WIN_LOCALAPPDATA}\\Mozilla Firefox\\firefox.exe`,
78
+ ],
66
79
  custom: [],
67
80
  },
68
81
  };
@@ -236,6 +249,43 @@ export function listInstalledBrowsers(platform = os.platform()) {
236
249
  }
237
250
  return found;
238
251
  }
252
+ /**
253
+ * The process that holds a Chromium user-data dir, read from the `SingletonLock`
254
+ * symlink Chromium keeps inside the dir on macOS and Linux (`<host>-<pid>`,
255
+ * PHNX-4042). A live pid means a browser already owns that store: a launch on it
256
+ * would only hand its arguments to the running instance and the requested debug
257
+ * port would never bind. Windows keeps no lock file, so this reports null there
258
+ * and `launchBrowser` catches the hand-off by the child's early exit instead.
259
+ */
260
+ export function storeOccupant(userDataDir) {
261
+ let target;
262
+ try {
263
+ target = fs.readlinkSync(path.join(userDataDir, 'SingletonLock'));
264
+ }
265
+ catch {
266
+ return null;
267
+ }
268
+ const pid = Number(/-(\d+)$/.exec(target)?.[1]);
269
+ if (!Number.isInteger(pid) || pid <= 0)
270
+ return null;
271
+ try {
272
+ process.kill(pid, 0);
273
+ }
274
+ catch (error) {
275
+ return error.code === 'EPERM' ? { pid } : null;
276
+ }
277
+ return { pid };
278
+ }
279
+ /**
280
+ * The relaunch that makes a browser holding `userDataDir` attachable: the
281
+ * ownership guard reads `--user-data-dir` off the running process, so the
282
+ * owner's normal launch (no flag) can never be verified.
283
+ */
284
+ export function storeRelaunchCommand(browserType, port, userDataDir, profileDirectory) {
285
+ const app = browserType === 'comet' ? 'Comet' : browserType;
286
+ const profileFlag = profileDirectory ? ` --profile-directory=${profileDirectory}` : '';
287
+ return `open -a ${app} --args --remote-debugging-port=${port} --user-data-dir=${userDataDir}${profileFlag}`;
288
+ }
239
289
  /**
240
290
  * Resolve a browser-profile secrets bundle into an env map for the child, or an
241
291
  * EMPTY map when the bundle is absent, locked, or otherwise unreadable — never a
@@ -265,14 +315,25 @@ export async function launchBrowser(profileName, browserType, port, options = {}
265
315
  // orphan reaper and `agents browser status` can label processes.
266
316
  isElectron = false) {
267
317
  const browserPath = findBrowserPath(browserType, customBinary);
318
+ // A profile discovered from the browser's OWN store (PHNX-4042) launches on
319
+ // that store — the owner's real user-data dir and profile directory — so the
320
+ // window agents open is the window the owner uses. Its Preferences are the
321
+ // owner's and are never rewritten. Anything else runs under a managed dir.
322
+ const ownerStore = options.userDataDir !== undefined;
268
323
  const runtimeDir = getProfileRuntimeDir(profileName);
269
- const userDataDir = path.join(runtimeDir, 'chrome-data');
270
- fs.mkdirSync(userDataDir, { recursive: true });
271
- // Pre-launch Preferences pass: first-launch profile-name stamp, plus (for
272
- // real browsers, not Electron apps) the session-cookie persistence pin.
273
- // Electron apps manage their own storage and don't read Chromium's
274
- // `session.*` prefs, so they get the name stamp only.
275
- ensureProfilePreferences(userDataDir, profileName, !isElectron);
324
+ const userDataDir = options.userDataDir ?? path.join(runtimeDir, 'chrome-data');
325
+ if (!ownerStore) {
326
+ fs.mkdirSync(userDataDir, { recursive: true });
327
+ // Pre-launch Preferences pass: first-launch profile-name stamp, plus (for
328
+ // real browsers, not Electron apps) the session-cookie persistence pin.
329
+ // Electron apps manage their own storage and don't read Chromium's
330
+ // `session.*` prefs, so they get the name stamp only.
331
+ ensureProfilePreferences(userDataDir, profileName, !isElectron);
332
+ }
333
+ // The owner's store is attached over a TCP port rather than the daemon's
334
+ // private pipe: the instance outlives any one daemon and must stay reachable
335
+ // (and verifiable by the ownership guard) after a daemon restart.
336
+ const transport = ownerStore ? 'port' : 'pipe';
276
337
  // Chromium on macOS coordinates instances via the SingletonLock file
277
338
  // *inside* each user-data-dir. Direct binary spawn with a fresh
278
339
  // --user-data-dir creates a fully independent process — the user's
@@ -288,8 +349,9 @@ isElectron = false) {
288
349
  // profile never reaches this launcher (see connectLocal / isAttachOnlyProfile).
289
350
  const viewport = options.viewport ?? { width: 1512, height: 982 };
290
351
  const args = [
291
- '--remote-debugging-pipe',
352
+ transport === 'pipe' ? '--remote-debugging-pipe' : `--remote-debugging-port=${port}`,
292
353
  `--user-data-dir=${userDataDir}`,
354
+ ...(options.profileDirectory ? [`--profile-directory=${options.profileDirectory}`] : []),
293
355
  '--disable-background-timer-throttling',
294
356
  '--disable-backgrounding-occluded-windows',
295
357
  '--disable-renderer-backgrounding',
@@ -311,7 +373,7 @@ isElectron = false) {
311
373
  // cookies survive, no ghost tabs — and the task flow creates its own tab
312
374
  // over CDP anyway. Electron apps need their window to appear (the CDP
313
375
  // driver binds to it), so they skip the flag.
314
- ...(isElectron ? [] : ['--no-startup-window']),
376
+ ...(isElectron || ownerStore ? [] : ['--no-startup-window']),
315
377
  ...(options.headless ? ['--headless=new'] : []),
316
378
  `--window-size=${viewport.width},${viewport.height}`,
317
379
  ...(viewport.x !== undefined && viewport.y !== undefined
@@ -324,26 +386,69 @@ isElectron = false) {
324
386
  const env = { ...process.env, ...(await resolveProfileSecretsEnv(secrets)) };
325
387
  const child = spawn(browserPath, args, {
326
388
  detached: true,
327
- stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
389
+ stdio: transport === 'pipe' ? ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
328
390
  env,
329
391
  });
330
392
  child.unref();
331
393
  child.stdout?.resume();
332
394
  child.stderr?.resume();
333
395
  const pid = child.pid;
334
- const writePipe = child.stdio[3];
335
- const readPipe = child.stdio[4];
336
- if (!writePipe || !readPipe) {
337
- throw new Error('Chrome failed to expose CDP pipe file descriptors');
396
+ let wsUrl;
397
+ if (transport === 'pipe') {
398
+ const writePipe = child.stdio[3];
399
+ const readPipe = child.stdio[4];
400
+ if (!writePipe || !readPipe) {
401
+ throw new Error('Chrome failed to expose CDP pipe file descriptors');
402
+ }
403
+ wsUrl = registerPipeTransport({ read: readPipe, write: writePipe });
404
+ }
405
+ else {
406
+ wsUrl = (await waitForDevToolsPort(port, profileName, child, { browserType, userDataDir, profileDirectory: options.profileDirectory })).wsUrl;
338
407
  }
339
- const wsUrl = registerPipeTransport({ read: readPipe, write: writePipe });
340
408
  writeProfileRuntime(profileName, {
341
409
  pid,
342
410
  command: path.basename(browserPath),
343
411
  userDataDir,
344
412
  kind: isElectron ? 'electron' : 'browser',
345
413
  });
346
- return { pid, port: 0, wsUrl };
414
+ return { pid, port: transport === 'pipe' ? 0 : port, wsUrl };
415
+ }
416
+ /**
417
+ * Poll the DevTools endpoint of a browser just launched with
418
+ * `--remote-debugging-port` until it answers, or fail loud naming the pid.
419
+ * Chromium binds the port only after its profile has loaded, which on a real
420
+ * signed-in store takes a few seconds. A child that exits first never bound
421
+ * it: on an owner's store that is the singleton hand-off to a browser already
422
+ * open there (PHNX-4042), so the failure names that instance's relaunch
423
+ * instead of waiting out the deadline.
424
+ */
425
+ async function waitForDevToolsPort(port, profileName, child, store, timeoutMs = 20_000) {
426
+ const pid = child.pid;
427
+ let exited;
428
+ child.once('exit', (code, signal) => {
429
+ exited = code !== null ? `code ${code}` : `signal ${signal}`;
430
+ });
431
+ const deadline = Date.now() + timeoutMs;
432
+ let lastError = '';
433
+ while (Date.now() < deadline) {
434
+ if (exited) {
435
+ const app = store.browserType === 'comet' ? 'Comet' : store.browserType;
436
+ throw new Error(`${app} (pid ${pid}) exited (${exited}) before serving the DevTools protocol on port ${port} ` +
437
+ `for profile "${profileName}". A ${app} already open on ${store.userDataDir} takes a new launch ` +
438
+ `as an argument hand-off and never binds the port; quit it, then relaunch it with remote debugging:\n` +
439
+ ` ${storeRelaunchCommand(store.browserType, port, store.userDataDir, store.profileDirectory)}\n` +
440
+ `and retry. If none is open, the browser crashed on start.`);
441
+ }
442
+ try {
443
+ return await discoverBrowserWsUrl(port, 'localhost', profileName);
444
+ }
445
+ catch (error) {
446
+ lastError = error instanceof Error ? error.message : String(error);
447
+ }
448
+ await new Promise((resolve) => setTimeout(resolve, 250));
449
+ }
450
+ throw new Error(`Browser for profile "${profileName}" (pid ${pid}) did not serve the DevTools protocol on ` +
451
+ `port ${port} within ${Math.round(timeoutMs / 1000)}s: ${lastError}`);
347
452
  }
348
453
  export async function attachToChrome(port) {
349
454
  const { wsUrl } = await discoverBrowserWsUrl(port);
@@ -0,0 +1,27 @@
1
+ import type { BrowserType } from './types.js';
2
+ export interface ChromiumNativeProfile {
3
+ browser: BrowserType;
4
+ /** The agents-cli profile name: `<browser>-<display-name-slug>`. */
5
+ name: string;
6
+ /** The browser's user-data dir (its own, never a cache dir). */
7
+ userDataDir: string;
8
+ /** Profile directory basename inside the user-data dir. Authoritative id. */
9
+ profileDirectory: string;
10
+ /** Display-only name from Local State. */
11
+ displayName: string;
12
+ }
13
+ export type ChromiumDiscoveryResult = {
14
+ ok: true;
15
+ profiles: ChromiumNativeProfile[];
16
+ userDataDir: string;
17
+ } | {
18
+ ok: false;
19
+ kind: 'unsupported' | 'not-installed' | 'invalid';
20
+ reason: string;
21
+ };
22
+ /** The browser's own user-data dir on this platform, or undefined where it does not ship. */
23
+ export declare function chromiumUserDataDir(browser: BrowserType): string | undefined;
24
+ export declare function discoverChromiumProfilesAt(browser: BrowserType, userDataDir: string): ChromiumDiscoveryResult;
25
+ export declare function discoverChromiumProfiles(browser: BrowserType): ChromiumDiscoveryResult;
26
+ /** Every browser whose native profiles agents-cli discovers on this platform. */
27
+ export declare function discoverableChromiumBrowsers(): BrowserType[];
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Read-only discovery of a Chromium-family browser's OWN profiles (PHNX-4042).
3
+ *
4
+ * Chromium keeps its profiles under one user-data dir: `Local State` lists them
5
+ * in `profile.info_cache` keyed by directory (`Default`, `Profile 1`, ...) with
6
+ * the display name the user sees in the profile menu. Each entry becomes one
7
+ * agents-cli profile (`comet-work`, ...) pinned to that dir and directory, so
8
+ * the owner and agents share ONE window per profile instead of agents spawning
9
+ * a rival instance under a cache dir. Nothing here writes to the browser's
10
+ * files.
11
+ */
12
+ import * as fs from 'node:fs';
13
+ import * as os from 'node:os';
14
+ import * as path from 'node:path';
15
+ /** Browsers whose native profiles are discovered, and the env var that points tests at another user-data dir. */
16
+ const NATIVE_CHROMIUM = {
17
+ comet: { dirName: 'Comet', envOverride: 'AGENTS_COMET_DIR' },
18
+ };
19
+ /** The browser's own user-data dir on this platform, or undefined where it does not ship. */
20
+ export function chromiumUserDataDir(browser) {
21
+ const entry = NATIVE_CHROMIUM[browser];
22
+ if (!entry)
23
+ return undefined;
24
+ const override = process.env[entry.envOverride];
25
+ if (override)
26
+ return path.resolve(override);
27
+ if (process.platform === 'darwin') {
28
+ return path.join(os.homedir(), 'Library', 'Application Support', entry.dirName);
29
+ }
30
+ if (process.platform === 'win32') {
31
+ const local = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
32
+ return path.join(local, entry.dirName, 'User Data');
33
+ }
34
+ return undefined;
35
+ }
36
+ function isRecord(value) {
37
+ return !!value && typeof value === 'object' && !Array.isArray(value);
38
+ }
39
+ function slugify(value) {
40
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
41
+ }
42
+ export function discoverChromiumProfilesAt(browser, userDataDir) {
43
+ const localState = path.join(userDataDir, 'Local State');
44
+ if (!fs.existsSync(localState)) {
45
+ return { ok: false, kind: 'not-installed', reason: `${browser} Local State not found: ${localState}` };
46
+ }
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(fs.readFileSync(localState, 'utf8'));
50
+ }
51
+ catch (error) {
52
+ return {
53
+ ok: false,
54
+ kind: 'invalid',
55
+ reason: error instanceof SyntaxError
56
+ ? `${browser} Local State is not valid JSON`
57
+ : `Cannot read ${browser} Local State: ${error instanceof Error ? error.message : String(error)}`,
58
+ };
59
+ }
60
+ if (!isRecord(parsed) || !isRecord(parsed.profile) || !isRecord(parsed.profile.info_cache)) {
61
+ return { ok: false, kind: 'invalid', reason: `${browser} Local State has no profile.info_cache map` };
62
+ }
63
+ const rows = [];
64
+ for (const [profileDirectory, value] of Object.entries(parsed.profile.info_cache)) {
65
+ if (!isRecord(value) || typeof value.name !== 'string' || value.name.trim() === '') {
66
+ return { ok: false, kind: 'invalid', reason: `${browser} Local State profile ${JSON.stringify(profileDirectory)} has no non-empty name` };
67
+ }
68
+ rows.push({
69
+ browser,
70
+ name: `${browser}-${slugify(value.name) || 'profile'}`,
71
+ userDataDir,
72
+ profileDirectory,
73
+ displayName: value.name,
74
+ });
75
+ }
76
+ // Two profiles with the same display name stay distinct by their directory.
77
+ const counts = new Map();
78
+ for (const row of rows)
79
+ counts.set(row.name, (counts.get(row.name) ?? 0) + 1);
80
+ return {
81
+ ok: true,
82
+ userDataDir,
83
+ profiles: rows.map((row) => (counts.get(row.name) ?? 0) > 1 ? { ...row, name: `${row.name}-${slugify(row.profileDirectory)}` } : row),
84
+ };
85
+ }
86
+ export function discoverChromiumProfiles(browser) {
87
+ const userDataDir = chromiumUserDataDir(browser);
88
+ if (!userDataDir) {
89
+ return { ok: false, kind: 'unsupported', reason: `${browser} profiles are not discovered on this platform` };
90
+ }
91
+ return discoverChromiumProfilesAt(browser, userDataDir);
92
+ }
93
+ /** Every browser whose native profiles agents-cli discovers on this platform. */
94
+ export function discoverableChromiumBrowsers() {
95
+ return Object.keys(NATIVE_CHROMIUM).filter((browser) => chromiumUserDataDir(browser) !== undefined);
96
+ }
@@ -0,0 +1,103 @@
1
+ import type { BrowserProfile, ConnectionKey } from '../types.js';
2
+ /** How agents obtain a live Firefox — matches the CDP driver's launch/attach split. */
3
+ export interface FirefoxConnection {
4
+ bidi: FirefoxBiDiClient;
5
+ port: number;
6
+ /** The Firefox process pid we launched, or 0 when we attached to a running one. */
7
+ pid: number;
8
+ /** The BiDi session id from `session.new`. */
9
+ sessionId: string;
10
+ }
11
+ /**
12
+ * A verb Firefox-over-BiDi cannot serve (network capture, upload, pdf, trusted
13
+ * key input, …). Mirrors `ArcNativeCapabilityError`: the service throws it in
14
+ * the one place the capability is missing, and the message steers the caller to
15
+ * a Chromium-family profile that has it.
16
+ */
17
+ export declare class FirefoxCapabilityError extends Error {
18
+ readonly capability: string;
19
+ constructor(capability: string, message?: string);
20
+ }
21
+ /** A BiDi error frame carrying the protocol error + human message. */
22
+ export declare class FirefoxBiDiError extends Error {
23
+ readonly bidiError: string;
24
+ constructor(bidiError: string, message: string);
25
+ }
26
+ /**
27
+ * Minimal WebDriver BiDi client: request/response keyed by the `id` field, with
28
+ * events drained and ignored. Deliberately shaped like `CDPClient` so the
29
+ * service treats a Firefox connection the same way it treats a CDP one. The
30
+ * large `maxPayload` matches CDPClient's, so a base64 screenshot of a
31
+ * content-rich page never trips the socket's decompressed-size cap.
32
+ */
33
+ export declare class FirefoxBiDiClient {
34
+ private ws;
35
+ private nextId;
36
+ private pending;
37
+ private closed;
38
+ get isOpen(): boolean;
39
+ connect(url: string): Promise<void>;
40
+ private handleMessage;
41
+ private handleClose;
42
+ send<T = Record<string, unknown>>(method: string, params?: Record<string, unknown>): Promise<T>;
43
+ close(): void;
44
+ }
45
+ /**
46
+ * The loud error raised when a Firefox already holds this profile but exposes no
47
+ * BiDi port, so agents cannot attach and must not launch a rival (Firefox is
48
+ * single-instance per profile dir). Mirrors `attachOnlyRequiredError` in
49
+ * `drivers/local.ts`: name the exact relaunch that makes the running instance
50
+ * attachable. Exported so the contract is unit-testable without a real Firefox.
51
+ */
52
+ export declare function firefoxAttachRequiredError(profile: Pick<BrowserProfile, 'name'> & {
53
+ firefox?: {
54
+ profileName: string;
55
+ };
56
+ }, port: number, profileDir: string): Error;
57
+ /**
58
+ * Connect a Firefox profile over BiDi: attach if the port is already served,
59
+ * otherwise launch Firefox and wait for it. Fails loud when a Firefox holds the
60
+ * profile without a port.
61
+ */
62
+ export declare function connectFirefox(profile: BrowserProfile, key: ConnectionKey, port: number, opts?: {
63
+ profileDir: string;
64
+ headless?: boolean;
65
+ }): Promise<FirefoxConnection>;
66
+ export declare function deserializeBidi(remote: unknown): unknown;
67
+ /** One top-level browsing context (a tab), flattened from `browsingContext.getTree`. */
68
+ export interface BiDiContext {
69
+ context: string;
70
+ url: string;
71
+ }
72
+ /** Every top-level tab currently open in this Firefox. */
73
+ export declare function bidiTopLevelContexts(bidi: FirefoxBiDiClient): Promise<BiDiContext[]>;
74
+ /** Open a fresh tab and return its context id. */
75
+ export declare function bidiCreateTab(bidi: FirefoxBiDiClient): Promise<string>;
76
+ /** Navigate a context and wait for the document to finish loading. */
77
+ export declare function bidiNavigate(bidi: FirefoxBiDiClient, context: string, url: string): Promise<void>;
78
+ /** Reload a context in place, waiting for load. */
79
+ export declare function bidiReload(bidi: FirefoxBiDiClient, context: string): Promise<void>;
80
+ /** Close a tab. */
81
+ export declare function bidiCloseTab(bidi: FirefoxBiDiClient, context: string): Promise<void>;
82
+ /** Bring a tab to the foreground (explicit focus only). */
83
+ export declare function bidiActivate(bidi: FirefoxBiDiClient, context: string): Promise<void>;
84
+ /**
85
+ * Evaluate an expression in a context and return the deserialized value.
86
+ * `awaitPromise` mirrors the CDP evaluate contract; a thrown/rejected value
87
+ * surfaces as an Error rather than a silent undefined.
88
+ */
89
+ export declare function bidiEvaluate(bidi: FirefoxBiDiClient, context: string, expression: string): Promise<unknown>;
90
+ /** Capture a screenshot of the viewport as raw bytes. `quality` is 0–1 for JPEG. */
91
+ export declare function bidiScreenshot(bidi: FirefoxBiDiClient, context: string, format: {
92
+ type: 'image/png';
93
+ } | {
94
+ type: 'image/jpeg';
95
+ quality: number;
96
+ }): Promise<Buffer>;
97
+ /**
98
+ * A real, trusted left click at viewport coordinates via `input.performActions`
99
+ * — the pointer path CDP `Input.dispatchMouseEvent` gives Chromium, which Arc
100
+ * (Apple Events) never had. The service resolves a ref to (x, y) first, exactly
101
+ * as the CDP click path does.
102
+ */
103
+ export declare function bidiClickAt(bidi: FirefoxBiDiClient, context: string, x: number, y: number): Promise<void>;