@phnx-labs/agents-cli 1.22.93 → 1.22.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/commands/browser.d.ts +1 -0
  3. package/dist/commands/browser.js +156 -11
  4. package/dist/lib/browser/arc-discovery.d.ts +60 -0
  5. package/dist/lib/browser/arc-discovery.js +187 -0
  6. package/dist/lib/browser/arc-dom.d.ts +14 -0
  7. package/dist/lib/browser/arc-dom.js +121 -0
  8. package/dist/lib/browser/chrome.d.ts +17 -0
  9. package/dist/lib/browser/chrome.js +121 -16
  10. package/dist/lib/browser/chromium-discovery.d.ts +27 -0
  11. package/dist/lib/browser/chromium-discovery.js +96 -0
  12. package/dist/lib/browser/drivers/arc.d.ts +57 -0
  13. package/dist/lib/browser/drivers/arc.js +281 -0
  14. package/dist/lib/browser/drivers/firefox.d.ts +103 -0
  15. package/dist/lib/browser/drivers/firefox.js +377 -0
  16. package/dist/lib/browser/drivers/local.d.ts +8 -0
  17. package/dist/lib/browser/drivers/local.js +38 -3
  18. package/dist/lib/browser/firefox-discovery.d.ts +68 -0
  19. package/dist/lib/browser/firefox-discovery.js +162 -0
  20. package/dist/lib/browser/profiles.d.ts +39 -1
  21. package/dist/lib/browser/profiles.js +239 -9
  22. package/dist/lib/browser/refs.d.ts +2 -0
  23. package/dist/lib/browser/refs.js +2 -1
  24. package/dist/lib/browser/resolve-target.d.ts +2 -0
  25. package/dist/lib/browser/resolve-target.js +14 -0
  26. package/dist/lib/browser/runtime-state.js +9 -1
  27. package/dist/lib/browser/service.d.ts +86 -3
  28. package/dist/lib/browser/service.js +1045 -42
  29. package/dist/lib/browser/types.d.ts +85 -2
  30. package/dist/lib/daemon/usage-sync-service.js +12 -0
  31. package/dist/lib/open-url.js +6 -0
  32. package/dist/lib/types.d.ts +20 -1
  33. package/package.json +1 -1
@@ -0,0 +1,121 @@
1
+ import { truncate } from '../format.js';
2
+ const INTERACTIVE_SELECTOR = [
3
+ 'a[href]', 'button', 'input', 'select', 'textarea', '[contenteditable="true"]',
4
+ '[role="button"]', '[role="link"]', '[role="textbox"]', '[role="checkbox"]',
5
+ '[role="radio"]', '[role="combobox"]', '[role="listbox"]', '[role="option"]',
6
+ '[role="menuitem"]', '[role="tab"]', '[role="slider"]', '[role="spinbutton"]',
7
+ '[role="searchbox"]', '[role="switch"]', '[role="treeitem"]',
8
+ ].join(',');
9
+ /** Build one synchronous expression that reads DOM-backed refs from native Arc. */
10
+ export function arcRefsExpression(opts = {}) {
11
+ const interactive = opts.interactive ?? true;
12
+ const limit = opts.limit ?? 500;
13
+ return `(() => {
14
+ const selectorFor = (el) => {
15
+ if (el.id) return '#' + CSS.escape(el.id);
16
+ const parts = [];
17
+ for (let node = el; node && node.nodeType === 1 && node !== document.documentElement; node = node.parentElement) {
18
+ let part = node.localName;
19
+ if (!part) return '';
20
+ const siblings = node.parentElement ? Array.from(node.parentElement.children).filter((s) => s.localName === node.localName) : [];
21
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
22
+ parts.unshift(part);
23
+ }
24
+ return parts.join(' > ');
25
+ };
26
+ const roleFor = (el) => {
27
+ const explicit = el.getAttribute('role');
28
+ if (explicit) return explicit.toLowerCase();
29
+ if (el.matches('button')) return 'button';
30
+ if (el.matches('a[href]')) return 'link';
31
+ if (el.matches('textarea,[contenteditable="true"]')) return 'textbox';
32
+ if (el.matches('select')) return 'combobox';
33
+ if (el.matches('input')) {
34
+ const type = (el.getAttribute('type') || 'text').toLowerCase();
35
+ if (type === 'checkbox') return 'checkbox';
36
+ if (type === 'radio') return 'radio';
37
+ if (type === 'range') return 'slider';
38
+ if (type === 'number') return 'spinbutton';
39
+ return 'textbox';
40
+ }
41
+ return el.localName || 'generic';
42
+ };
43
+ const nameFor = (el) => el.getAttribute('aria-label') || el.getAttribute('title') ||
44
+ (el.labels && el.labels[0] && el.labels[0].innerText) || el.innerText || el.value || el.getAttribute('placeholder') || '';
45
+ const attrsFor = (el) => ['disabled','checked','selected','expanded','required','readonly','invalid']
46
+ .filter((name) => el[name] === true || el.getAttribute('aria-' + name) === 'true');
47
+ const editorFor = (el) => {
48
+ for (let node = el, i = 0; node && i < 6; node = node.parentElement, i++) {
49
+ if (node.hasAttribute('data-lexical-editor')) return 'lexical';
50
+ if (node.classList.contains('ProseMirror')) return 'prosemirror';
51
+ if (node.hasAttribute('data-slate-editor')) return 'slate';
52
+ if (Array.from(node.classList).some((c) => /^DraftEditor-/.test(c))) return 'draft';
53
+ if (node.classList.contains('ql-editor')) return 'quill';
54
+ if (node.classList.contains('ck-editor__editable')) return 'ckeditor5';
55
+ if (node.tagName === 'TRIX-EDITOR') return 'trix';
56
+ }
57
+ return undefined;
58
+ };
59
+ const source = ${interactive ? `document.querySelectorAll(${JSON.stringify(INTERACTIVE_SELECTOR)})` : `document.querySelectorAll('body *')`};
60
+ const rows = [];
61
+ for (const el of source) {
62
+ if (rows.length >= ${JSON.stringify(limit)}) break;
63
+ const selector = selectorFor(el);
64
+ if (!selector) continue;
65
+ const row = { role: roleFor(el), name: String(nameFor(el)).trim(), attrs: attrsFor(el), selector };
66
+ const editor = editorFor(el);
67
+ if (editor) row.editor = editor;
68
+ rows.push(row);
69
+ }
70
+ return JSON.stringify(rows);
71
+ })()`;
72
+ }
73
+ export function parseArcRefsResult(value, opts = {}) {
74
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
75
+ if (!Array.isArray(parsed))
76
+ throw new Error('Arc returned an invalid DOM ref listing.');
77
+ const nodeMap = new Map();
78
+ const lines = [];
79
+ const compact = opts.compact ?? false;
80
+ parsed.forEach((raw, index) => {
81
+ const row = raw;
82
+ if (typeof row.role !== 'string' || typeof row.name !== 'string' ||
83
+ typeof row.selector !== 'string' || !Array.isArray(row.attrs) ||
84
+ !row.attrs.every((attr) => typeof attr === 'string')) {
85
+ throw new Error('Arc returned an invalid DOM ref entry.');
86
+ }
87
+ const ref = index + 1;
88
+ const node = { ref, role: row.role, name: row.name, attrs: row.attrs, selector: row.selector };
89
+ if (typeof row.editor === 'string')
90
+ node.editor = row.editor;
91
+ nodeMap.set(ref, node);
92
+ const name = row.name ? ` "${truncate(row.name, 50)}"` : '';
93
+ const attrs = row.attrs.length ? ` [${row.attrs.join('] [')}]` : '';
94
+ const editor = row.editor ? ` [editor=${row.editor}]` : '';
95
+ lines.push(`${compact ? '' : '- '}${row.role}${name} [ref=${ref}]${attrs}${editor}`);
96
+ });
97
+ return { refs: lines.join('\n'), nodeMap, opts: { interactive: opts.interactive ?? true, limit: opts.limit ?? 500 } };
98
+ }
99
+ export function arcClickExpression(selector) {
100
+ return `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) throw new Error('DOM ref is missing'); el.click(); return true; })()`;
101
+ }
102
+ export function arcFillExpression(selector, text, clear = true) {
103
+ return `(() => {
104
+ const el = document.querySelector(${JSON.stringify(selector)});
105
+ if (!el) throw new Error('DOM ref is missing');
106
+ const next = ${JSON.stringify(text)};
107
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
108
+ const proto = el instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
109
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
110
+ setter.call(el, ${clear ? 'next' : 'el.value + next'});
111
+ } else if (el.isContentEditable) {
112
+ el.textContent = ${clear ? 'next' : '(el.textContent || "") + next'};
113
+ } else throw new Error('DOM ref is not editable');
114
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: next }));
115
+ el.dispatchEvent(new Event('change', { bubbles: true }));
116
+ return true;
117
+ })()`;
118
+ }
119
+ export function arcScrollExpression(deltaX, deltaY) {
120
+ return `(() => { window.scrollBy(${JSON.stringify(deltaX)}, ${JSON.stringify(deltaY)}); return true; })()`;
121
+ }
@@ -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,57 @@
1
+ export interface ArcNativeTabRef {
2
+ windowId: string;
3
+ spaceId: string;
4
+ tabId: string;
5
+ }
6
+ export interface ArcNativeTab extends ArcNativeTabRef {
7
+ url: string;
8
+ title: string;
9
+ }
10
+ export interface ArcEnumeratedSpace {
11
+ windowId: string;
12
+ spaceId: string;
13
+ spaceTitle: string;
14
+ activeTabId?: string;
15
+ tabs: ArcNativeTab[];
16
+ }
17
+ export declare const ARC_NATIVE_CAPABILITIES: Readonly<{
18
+ readonly createTab: true;
19
+ readonly navigate: true;
20
+ readonly evaluateSync: true;
21
+ readonly closeTab: true;
22
+ readonly enumerate: true;
23
+ readonly screenshot: false;
24
+ readonly asyncEvaluate: false;
25
+ readonly networkCapture: false;
26
+ readonly consoleCapture: false;
27
+ readonly upload: false;
28
+ readonly pdf: false;
29
+ readonly background: false;
30
+ }>;
31
+ export declare class ArcNativeCapabilityError extends Error {
32
+ readonly capability: string;
33
+ constructor(capability: string, message?: string);
34
+ }
35
+ /** AppleScript strings do not implement JSON's \uXXXX escape syntax. */
36
+ export declare function escapeAppleScriptString(value: string): string;
37
+ /** No shell, bounded output and lifetime, and no blocking of the shared daemon. */
38
+ export declare function execAppleScript(source: string, timeoutMs?: number): Promise<string>;
39
+ export declare function isArcRunning(): Promise<boolean>;
40
+ export declare function enumerateArcSpaces(): Promise<ArcEnumeratedSpace[]>;
41
+ /** Caller durably records marker intent before calling; never use a real page URL as ownership. */
42
+ export declare function createArcTab(target: Pick<ArcNativeTabRef, 'windowId' | 'spaceId'>, markerUrl: string): Promise<ArcNativeTab>;
43
+ export declare function resolveArcTab(ref: ArcNativeTabRef): Promise<ArcNativeTab | null>;
44
+ export declare function navigateArcTab(ref: ArcNativeTabRef, url: string): Promise<void>;
45
+ /** Arc serializes a returned object once. JSON.stringify at the top level would double-encode. */
46
+ export declare function executeJavaScript(ref: ArcNativeTabRef, expression: string): Promise<unknown>;
47
+ export declare function closeArcTab(ref: ArcNativeTabRef): Promise<'closed' | 'missing'>;
48
+ /** Explicit user-facing focus only. Evaluation and cleanup never invoke this. */
49
+ export declare function selectArcTab(ref: ArcNativeTabRef): Promise<void>;
50
+ /**
51
+ * Select `tabId` wherever it lives in window `windowId` (any Space). Used to put
52
+ * the owner back on their tab after a creation that failed mid-way, when there is
53
+ * no owned tab to check against. Returns false when the tab is gone.
54
+ */
55
+ export declare function selectWindowTab(windowId: string, tabId: string): Promise<boolean>;
56
+ /** Restore only while the task's new tab is still selected; preserve a later human choice. */
57
+ export declare function restoreArcSelection(owned: ArcNativeTabRef, previousTabId: string): Promise<boolean>;