@phnx-labs/agents-cli 1.18.4 → 1.18.5

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.
@@ -3,6 +3,7 @@ import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
6
+ import { insertTask } from '../lib/cloud/store.js';
6
7
  const FACTORY_URL = process.env.FACTORY_FLOOR_URL ?? 'https://agents.427yosemite.com';
7
8
  function die(msg, code = 1) {
8
9
  console.error(chalk.red(msg));
@@ -63,9 +64,20 @@ Examples:
63
64
  console.log(JSON.stringify(result, null, 2));
64
65
  return;
65
66
  }
67
+ // Register locally so `agents cloud logs <id>` can find it.
68
+ const now = new Date().toISOString();
69
+ insertTask({
70
+ id: result.cloud_execution_id,
71
+ provider: 'rush',
72
+ status: 'queued',
73
+ agent: 'claude',
74
+ prompt: result.linear_identifier,
75
+ createdAt: now,
76
+ updatedAt: now,
77
+ });
66
78
  console.log(chalk.green(`Submitted ${result.linear_identifier} (${result.label})`));
67
79
  console.log(` ticket ${result.ticket_id}`);
68
80
  console.log(` execution ${result.cloud_execution_id}`);
69
- console.log(` tail output agents cloud tail ${result.cloud_execution_id}`);
81
+ console.log(` tail output agents cloud logs ${result.cloud_execution_id}`);
70
82
  });
71
83
  }
@@ -41,6 +41,20 @@ When to use:
41
41
  - Team onboarding: share rules via 'agents rules add gh:team/standards'
42
42
  - Project setup: add project-specific rules with '--scope project'
43
43
  - Version testing: install different rule sets per version to A/B test approaches
44
+
45
+ Project rules & @-imports:
46
+ Project rules live in <repo>/.agents/rules/. On every agent launch, the shim compiles
47
+ them into <repo>/AGENTS.md (with CLAUDE.md, GEMINI.md, .cursorrules symlinked to it).
48
+ A hand-authored AGENTS.md is preserved — the compile pipeline only overwrites files
49
+ it owns (those starting with the auto-compile header). Delete the file to migrate.
50
+
51
+ @path imports inside AGENTS.md/CLAUDE.md are resolved at session start by the agent
52
+ itself, not by agents-cli. Support is per-agent:
53
+ Inlined natively: claude, gemini
54
+ Literal text: codex, cursor, opencode, copilot, amp, kiro, goose, roo
55
+
56
+ For rules that need to work across all agents, inline the content rather than using
57
+ @-imports — the second group will load '@path/to/file.md' as a literal string.
44
58
  `);
45
59
  rulesCmd
46
60
  .command('list [agent]')
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import chalk from 'chalk';
9
9
  import * as fs from 'fs';
10
- import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
10
+ import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
11
11
  import { deleteKeychainToken, getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
12
12
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
13
13
  import { registerCommandGroups } from '../lib/help.js';
@@ -314,7 +314,7 @@ function countExpiringSoon(meta) {
314
314
  export function registerSecretsCommands(program) {
315
315
  const cmd = program
316
316
  .command('secrets')
317
- .description('Named bundles of env variables backed by macOS Keychain (with optional iCloud sync). Inject into agents via `agents run --secrets <name>`.')
317
+ .description('Named bundles of env variables backed by macOS Keychain (iCloud-synced by default). Inject into agents via `agents run --secrets <name>`.')
318
318
  .hook('preAction', () => { migrateLegacyBundles(); })
319
319
  .addHelpText('after', `
320
320
  Workflow:
@@ -323,16 +323,24 @@ Workflow:
323
323
  run with --secrets <name>. Keychain-backed values never touch disk in
324
324
  plaintext.
325
325
 
326
- Pass --icloud-sync at create time to store values in the iCloud-synced
327
- keychain so they appear automatically on your other Macs (same iCloud
328
- account, iCloud Keychain enabled). Without the flag, values are device-local.
326
+ New bundles store values in the iCloud-synced keychain by default so they
327
+ appear automatically on your other Macs (same iCloud account, iCloud
328
+ Keychain enabled). Pass --no-icloud-sync at create time to keep values
329
+ device-local instead.
329
330
 
330
331
  Examples:
331
- # Create a bundle for production credentials
332
+ # Create a bundle for production credentials (iCloud-synced by default)
332
333
  agents secrets create prod --description "Production keys for the api stack"
333
334
 
334
- # Create a bundle that syncs to your other Macs via iCloud Keychain
335
- agents secrets create npm-tokens --icloud-sync
335
+ # Create a bundle that never leaves this Mac
336
+ agents secrets create local-only --no-icloud-sync
337
+
338
+ # Rename a bundle (moves metadata + every keychain value)
339
+ agents secrets rename prod production
340
+
341
+ # Edit the description of an existing bundle
342
+ agents secrets describe prod "Production keys for the api stack"
343
+ agents secrets describe prod --clear
336
344
 
337
345
  # Add a keychain-backed secret (prompts for the value)
338
346
  agents secrets add prod STRIPE_API_KEY
@@ -387,7 +395,7 @@ Examples:
387
395
  agents secrets generate 32 --hex
388
396
  `);
389
397
  registerCommandGroups(cmd, [
390
- { title: 'Bundle commands', names: ['list', 'view', 'create', 'delete'] },
398
+ { title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
391
399
  { title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
392
400
  { title: 'Utilities', names: ['exec', 'generate'] },
393
401
  ]);
@@ -484,7 +492,7 @@ Examples:
484
492
  .description('Create an empty bundle')
485
493
  .option('--description <text>', 'Free-form description')
486
494
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
487
- .option('--icloud-sync', 'Store keychain values in iCloud Keychain so they sync across your Macs (requires Xcode CLT)')
495
+ .option('--no-icloud-sync', 'Store keychain values device-local instead of syncing them via iCloud Keychain')
488
496
  .option('--force', 'Overwrite an existing bundle')
489
497
  .action(async (name, opts) => {
490
498
  try {
@@ -498,7 +506,7 @@ Examples:
498
506
  name: resolvedName,
499
507
  description: opts.description,
500
508
  allow_exec: opts.allowExec,
501
- icloud_sync: opts.icloudSync,
509
+ icloud_sync: opts.icloudSync !== false,
502
510
  vars: {},
503
511
  };
504
512
  writeBundle(bundle);
@@ -512,6 +520,53 @@ Examples:
512
520
  process.exit(1);
513
521
  }
514
522
  });
523
+ cmd
524
+ .command('rename <old> <new>')
525
+ .alias('mv')
526
+ .description('Rename a bundle. Moves the metadata and every keychain-backed value to the new name.')
527
+ .option('--force', 'Overwrite the destination bundle if it already exists (purges its keychain items first)')
528
+ .action((oldName, newName, opts) => {
529
+ try {
530
+ renameBundle(oldName, newName, { force: opts.force });
531
+ console.log(chalk.green(`Bundle '${oldName}' renamed to '${newName}'.`));
532
+ }
533
+ catch (err) {
534
+ console.error(chalk.red(err.message));
535
+ process.exit(1);
536
+ }
537
+ });
538
+ cmd
539
+ .command('describe <name> [text...]')
540
+ .description('Update the description of a bundle. Pass --clear to remove it.')
541
+ .option('--clear', 'Remove the existing description')
542
+ .action((name, textParts, opts) => {
543
+ try {
544
+ const bundle = readBundle(name);
545
+ const text = textParts.join(' ').trim();
546
+ if (opts.clear) {
547
+ if (text) {
548
+ console.error(chalk.red('Pass either description text or --clear, not both.'));
549
+ process.exit(1);
550
+ }
551
+ bundle.description = undefined;
552
+ }
553
+ else {
554
+ if (!text) {
555
+ console.error(chalk.red('Description text is required. Pass it as an argument or use --clear.'));
556
+ process.exit(1);
557
+ }
558
+ bundle.description = text;
559
+ }
560
+ writeBundle(bundle);
561
+ console.log(chalk.green(opts.clear
562
+ ? `Bundle '${name}' description cleared.`
563
+ : `Bundle '${name}' description updated.`));
564
+ }
565
+ catch (err) {
566
+ console.error(chalk.red(err.message));
567
+ process.exit(1);
568
+ }
569
+ });
515
570
  cmd
516
571
  .command('add [bundle] [key]')
517
572
  .description('Add a variable to a bundle. Defaults to keychain-backed; pass --value for literal, --env/--file/--exec for refs.')
@@ -14,7 +14,13 @@ export class CDPClient {
14
14
  }
15
15
  async send(method, params, sessionId) {
16
16
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
17
- throw new Error('CDP connection not open');
17
+ // Reached when the underlying browser was killed externally between
18
+ // the daemon establishing the connection and a CDP call going out.
19
+ // The service-layer healthcheck normally catches this on the next
20
+ // `start`, so seeing this in the wild means a request landed against
21
+ // an in-flight conn that just died — tell the user how to recover.
22
+ throw new Error('CDP connection not open — the browser was likely closed externally. ' +
23
+ 'Run `agents browser stop --profile <name>` (or restart the daemon) and try again.');
18
24
  }
19
25
  const id = ++this.messageId;
20
26
  const message = sessionId
@@ -6,7 +6,7 @@ export interface LaunchResult {
6
6
  port: number;
7
7
  wsUrl: string;
8
8
  }
9
- export declare function launchBrowser(profileName: string, browserType: BrowserType, port: number, options?: ChromeOptions, secrets?: string, customBinary?: string): Promise<LaunchResult>;
9
+ export declare function launchBrowser(profileName: string, browserType: BrowserType, port: number, options?: ChromeOptions, secrets?: string, customBinary?: string, isElectron?: boolean): Promise<LaunchResult>;
10
10
  export declare function attachToChrome(port: number): Promise<string>;
11
11
  export declare function killChrome(pid: number): void;
12
12
  export declare function getRunningChromeInfo(profileName: string): {
@@ -5,6 +5,7 @@ import * as os from 'os';
5
5
  import { getProfileRuntimeDir } from './profiles.js';
6
6
  import { discoverBrowserWsUrl } from './cdp.js';
7
7
  import { readBundle, resolveBundleEnv, bundleExists } from '../secrets/bundles.js';
8
+ import { writeProfileRuntime, readProfileRuntime } from './runtime-state.js';
8
9
  const BROWSER_PATHS = {
9
10
  darwin: {
10
11
  chrome: [
@@ -64,11 +65,27 @@ export function findBrowserPath(browserType, customBinary) {
64
65
  }
65
66
  throw new Error(`Browser "${browserType}" not found. Install it first.`);
66
67
  }
67
- export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary) {
68
+ export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary,
69
+ // `electron: true` distinguishes Notion / VS Code-style apps from
70
+ // regular Chrome — purely informational, stored in meta.json so the
71
+ // orphan reaper and `agents browser status` can label processes.
72
+ isElectron = false) {
68
73
  const browserPath = findBrowserPath(browserType, customBinary);
69
74
  const runtimeDir = getProfileRuntimeDir(profileName);
70
75
  const userDataDir = path.join(runtimeDir, 'chrome-data');
71
76
  fs.mkdirSync(userDataDir, { recursive: true });
77
+ // First-launch seed: stamp the user-data-dir's Default/Preferences with
78
+ // the agents-cli profile name so Chromium's UI shows "<profile>" instead
79
+ // of its default "Person 1". Done only when the file doesn't exist —
80
+ // subsequent launches inherit whatever Chrome wrote in the meantime.
81
+ seedDefaultProfileName(userDataDir, profileName);
82
+ // Chromium on macOS coordinates instances via the SingletonLock file
83
+ // *inside* each user-data-dir. Direct binary spawn with a fresh
84
+ // --user-data-dir creates a fully independent process — the user's
85
+ // normal browser (running under their default user-data-dir) and our
86
+ // sandboxed one coexist as two real processes. The macOS Dock collapses
87
+ // them into one icon per .app bundle, which makes it look like a single
88
+ // instance, but `ps -ww` will show both.
72
89
  const viewport = options.viewport ?? { width: 1512, height: 982 };
73
90
  const args = [
74
91
  `--remote-debugging-port=${port}`,
@@ -77,6 +94,12 @@ export async function launchBrowser(profileName, browserType, port, options = {}
77
94
  '--disable-background-timer-throttling',
78
95
  '--disable-backgrounding-occluded-windows',
79
96
  '--disable-renderer-backgrounding',
97
+ // First-run + default-browser modals block automation: when targetFilter
98
+ // matches by URL, the onboarding page (`chrome://welcome/`) isn't a
99
+ // match and start fails with "no page target". Suppress them.
100
+ '--no-first-run',
101
+ '--no-default-browser-check',
102
+ '--disable-features=DefaultBrowserSetting,ChromeWhatsNewUI',
80
103
  ...(options.headless ? ['--headless=new'] : []),
81
104
  `--window-size=${viewport.width},${viewport.height}`,
82
105
  ...(viewport.x !== undefined && viewport.y !== undefined
@@ -102,8 +125,13 @@ export async function launchBrowser(profileName, browserType, port, options = {}
102
125
  });
103
126
  child.unref();
104
127
  const pid = child.pid;
105
- fs.writeFileSync(path.join(runtimeDir, 'pid'), String(pid));
106
- fs.writeFileSync(path.join(runtimeDir, 'port'), String(port));
128
+ writeProfileRuntime(profileName, {
129
+ pid,
130
+ port,
131
+ command: path.basename(browserPath),
132
+ userDataDir,
133
+ kind: isElectron ? 'electron' : 'browser',
134
+ });
107
135
  let wsUrl = null;
108
136
  for (let i = 0; i < 30; i++) {
109
137
  await sleep(200);
@@ -134,33 +162,31 @@ export function killChrome(pid) {
134
162
  }
135
163
  }
136
164
  export function getRunningChromeInfo(profileName) {
137
- const runtimeDir = getProfileRuntimeDir(profileName);
138
- const pidFile = path.join(runtimeDir, 'pid');
139
- const portFile = path.join(runtimeDir, 'port');
140
- if (!fs.existsSync(pidFile) || !fs.existsSync(portFile)) {
141
- return null;
142
- }
143
- const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
144
- const port = parseInt(fs.readFileSync(portFile, 'utf-8').trim(), 10);
145
- if (!isProcessRunning(pid)) {
146
- fs.unlinkSync(pidFile);
147
- fs.unlinkSync(portFile);
165
+ // Delegate to runtime-state, which auto-cleans stale files and verifies
166
+ // the live pid still runs the command we recorded — so a recycled pid
167
+ // doesn't masquerade as our browser.
168
+ const rt = readProfileRuntime(profileName);
169
+ if (!rt)
148
170
  return null;
149
- }
150
- return { pid, port };
171
+ return { pid: rt.pid, port: rt.port };
151
172
  }
152
- function isProcessRunning(pid) {
173
+ /**
174
+ * Stamp `<userDataDir>/Default/Preferences` with our profile name so
175
+ * Chrome's UI labels the window with the agents-cli name rather than the
176
+ * default "Person 1". Only writes when the file is absent (first launch).
177
+ * Best-effort: any I/O hiccup is silently ignored; missing the rename is
178
+ * cosmetic, not functional.
179
+ */
180
+ function seedDefaultProfileName(userDataDir, profileName) {
181
+ const defaultDir = path.join(userDataDir, 'Default');
182
+ const prefsPath = path.join(defaultDir, 'Preferences');
183
+ if (fs.existsSync(prefsPath))
184
+ return;
153
185
  try {
154
- process.kill(pid, 0);
155
- return true;
156
- }
157
- catch (err) {
158
- // EPERM means the process exists but we lack permission to signal it —
159
- // treat as alive. ESRCH means the process does not exist.
160
- if (err && err.code === 'EPERM')
161
- return true;
162
- return false;
186
+ fs.mkdirSync(defaultDir, { recursive: true });
187
+ fs.writeFileSync(prefsPath, JSON.stringify({ profile: { name: profileName } }));
163
188
  }
189
+ catch { /* not critical */ }
164
190
  }
165
191
  function sleep(ms) {
166
192
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -1,11 +1,38 @@
1
1
  import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
2
2
  import { launchBrowser, getPortOccupant } from '../chrome.js';
3
+ import { parseEndpointUrl } from '../profiles.js';
4
+ /**
5
+ * Local-port listeners we refuse to attach through. These forward CDP traffic
6
+ * to a remote host — silently using them would let a `cdp://127.0.0.1:N`
7
+ * profile drive a browser on a different machine without the caller realizing.
8
+ */
9
+ const TUNNEL_PROCESS_NAMES = new Set(['ssh', 'autossh', 'mosh-client', 'socat']);
10
+ function isTunnelProcess(command) {
11
+ return TUNNEL_PROCESS_NAMES.has(command.toLowerCase());
12
+ }
3
13
  export async function connectLocal(endpoint, profile) {
4
14
  const url = new URL(endpoint);
5
15
  if (url.protocol !== 'cdp:') {
6
16
  throw new Error(`Invalid local endpoint: ${endpoint}`);
7
17
  }
8
- const port = parseInt(url.port, 10) || 9222;
18
+ // Share the parser with the SSH driver and the collision-detection code
19
+ // path so `cdp://host:N` and `cdp://host?port=N` behave identically.
20
+ const parsed = parseEndpointUrl(endpoint);
21
+ const port = parsed?.port ?? 9222;
22
+ // Refuse to attach through an SSH tunnel before we even try to speak CDP.
23
+ // `verifyBrowserIdentity` only inspects what comes back over the wire — it
24
+ // can't tell whether the browser actually lives on this machine or on the
25
+ // far end of an `ssh -L` tunnel. A stale tunnel from a prior session
26
+ // (common when an SSH-driven profile is deleted before the daemon exits)
27
+ // will silently hijack any "local" profile bound to the same port.
28
+ const preOccupant = getPortOccupant(port);
29
+ if (preOccupant && isTunnelProcess(preOccupant.command)) {
30
+ throw new Error(`Port ${port} is held by ${preOccupant.command} (pid ${preOccupant.pid}), an SSH ` +
31
+ `tunnel forwarding to a remote host. Profile "${profile.name}" is configured as ` +
32
+ `local (${endpoint}) but traffic would round-trip to another machine. Either kill ` +
33
+ `the tunnel (\`kill ${preOccupant.pid}\`) and retry, or switch the profile to an ` +
34
+ `ssh:// endpoint to drive the remote browser explicitly.`);
35
+ }
9
36
  try {
10
37
  const { wsUrl, browser } = await discoverBrowserWsUrl(port);
11
38
  verifyBrowserIdentity(browser, profile.browser, port);
@@ -30,7 +57,7 @@ export async function connectLocal(endpoint, profile) {
30
57
  }
31
58
  const newPort = port;
32
59
  const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
33
- const { pid, wsUrl } = await launchBrowser(profile.name, profile.browser, newPort, chromeOpts, profile.secrets, profile.binary);
60
+ const { pid, wsUrl } = await launchBrowser(profile.name, profile.browser, newPort, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
34
61
  const cdp = new CDPClient();
35
62
  await cdp.connect(wsUrl);
36
63
  return { cdp, port: newPort, pid };
@@ -1,23 +1,56 @@
1
- import { spawn } from 'child_process';
1
+ import { spawn, execSync } from 'child_process';
2
2
  import * as net from 'net';
3
3
  import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
4
- import { allocatePort } from '../chrome.js';
4
+ import { getPortOccupant } from '../chrome.js';
5
+ import { parseEndpointUrl } from '../profiles.js';
6
+ import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
5
7
  export async function connectSSH(endpoint, profile) {
6
8
  const url = new URL(endpoint);
7
9
  if (url.protocol !== 'ssh:') {
8
10
  throw new Error(`Invalid SSH endpoint: ${endpoint}`);
9
11
  }
10
12
  const user = url.username || process.env.USER || 'root';
11
- const host = url.hostname;
12
- const remotePort = url.port ? parseInt(url.port, 10) : 9222;
13
- const localPort = allocatePort();
13
+ // Use the shared parser so the documented `ssh://host?port=N` form works
14
+ // identically to `ssh://host:N`. Previously `url.port` alone meant every
15
+ // `?port=`-style profile silently fell back to 9222.
16
+ const parsed = parseEndpointUrl(endpoint);
17
+ if (!parsed) {
18
+ throw new Error(`Could not extract host:port from SSH endpoint: ${endpoint}`);
19
+ }
20
+ const host = parsed.host;
21
+ const remotePort = parsed.port;
22
+ // Bind the tunnel to the SAME local port the user configured. Using an
23
+ // allocated port instead made `status` print confusing rows like
24
+ // `port 9200 (configured 10005)` and made it impossible to predict which
25
+ // local port a profile would land on. Now `ssh://host?port=N` => local N.
26
+ const localPort = remotePort;
27
+ // Preflight: if the local port is busy with something that isn't our
28
+ // own SSH tunnel for this very target, bail with a clear error. Letting
29
+ // ssh -L race ahead would either silently succeed (binding to a second
30
+ // port via fail-safe) or fail with cryptic stderr.
31
+ const occupant = getPortOccupant(localPort);
32
+ if (occupant && !isOwnTunnel(occupant.pid, host, remotePort)) {
33
+ throw new Error(`Local port ${localPort} (needed for SSH tunnel to ${host}:${remotePort}) ` +
34
+ `is already in use by ${occupant.command} (pid ${occupant.pid}). ` +
35
+ `Either kill that process (\`kill ${occupant.pid}\`) or change the profile's port.`);
36
+ }
14
37
  try {
15
38
  await ensureRemoteBrowser(user, host, profile.browser, remotePort, profile.binary);
16
39
  }
17
40
  catch {
18
41
  // Browser may already be running, continue
19
42
  }
20
- let tunnel = await startSSHTunnel(user, host, localPort, remotePort);
43
+ let tunnel;
44
+ if (occupant) {
45
+ // Reuse the existing tunnel rather than spawning a duplicate.
46
+ tunnel = { pid: occupant.pid, kill: () => { try {
47
+ process.kill(occupant.pid);
48
+ }
49
+ catch { /* gone */ } } };
50
+ }
51
+ else {
52
+ tunnel = await startSSHTunnel(user, host, localPort, remotePort);
53
+ }
21
54
  try {
22
55
  await waitForPort(localPort, 8000);
23
56
  }
@@ -35,13 +68,28 @@ export async function connectSSH(endpoint, profile) {
35
68
  }
36
69
  const cdp = new CDPClient();
37
70
  await cdp.connect(wsUrl);
71
+ // Record the tunnel in the profile's runtime so a future daemon — or
72
+ // the orphan reaper after a crash — can find and clean it up. The
73
+ // `kind: 'tunnel'` flag distinguishes it from a locally-launched
74
+ // browser process.
75
+ const tunnelPid = tunnel.pid ?? 0;
76
+ if (tunnelPid > 0) {
77
+ writeProfileRuntime(profile.name, {
78
+ pid: 0,
79
+ port: localPort,
80
+ command: 'ssh',
81
+ kind: 'tunnel',
82
+ tunnelPid,
83
+ });
84
+ }
38
85
  return {
39
86
  cdp,
40
87
  port: localPort,
41
- pid: tunnel.pid || 0,
88
+ pid: tunnelPid,
42
89
  cleanup: () => {
43
90
  cdp.close();
44
91
  tunnel.kill();
92
+ clearProfileRuntime(profile.name);
45
93
  },
46
94
  };
47
95
  }
@@ -164,3 +212,30 @@ function runSSHCommand(user, host, cmd) {
164
212
  function sleep(ms) {
165
213
  return new Promise((resolve) => setTimeout(resolve, ms));
166
214
  }
215
+ /**
216
+ * Identify whether a pid listening on our target local port is an SSH
217
+ * tunnel WE would have spawned for `host:remotePort`. Used so that two
218
+ * agents-browser invocations of the same SSH profile share a tunnel
219
+ * rather than failing the second one with "port in use".
220
+ *
221
+ * Best-effort match against the ssh -L command line via `ps`. If we
222
+ * can't read the cmd or the args don't look like ours, treat as not-ours.
223
+ */
224
+ function isOwnTunnel(pid, host, remotePort) {
225
+ try {
226
+ const out = execSync(`ps -p ${pid} -o command=`, {
227
+ encoding: 'utf-8',
228
+ stdio: ['ignore', 'pipe', 'ignore'],
229
+ }).toString().trim();
230
+ if (!out.startsWith('ssh'))
231
+ return false;
232
+ if (!out.includes(host))
233
+ return false;
234
+ if (!out.includes(`:${remotePort}`) && !out.includes(`:127.0.0.1:${remotePort}`))
235
+ return false;
236
+ return true;
237
+ }
238
+ catch {
239
+ return false;
240
+ }
241
+ }
@@ -3,6 +3,7 @@ import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { getHelpersDir } from '../state.js';
5
5
  import { startDaemon } from '../daemon.js';
6
+ import { getCliVersion } from '../version.js';
6
7
  const SOCKET_NAME = 'browser.sock';
7
8
  export function getSocketPath() {
8
9
  return path.join(getHelpersDir(), SOCKET_NAME);
@@ -63,6 +64,9 @@ export class BrowserIPCServer {
63
64
  }
64
65
  async handleRequest(request) {
65
66
  switch (request.action) {
67
+ case 'version': {
68
+ return { ok: true, version: getCliVersion() };
69
+ }
66
70
  case 'start': {
67
71
  if (!request.profile) {
68
72
  return { ok: false, error: 'Profile required' };
@@ -316,6 +320,21 @@ export class BrowserIPCServer {
316
320
  const downloadPath = await this.service.waitForDownload(request.task, request.timeout);
317
321
  return { ok: true, downloadPath };
318
322
  }
323
+ case 'getAppLogs': {
324
+ if (!request.task) {
325
+ return { ok: false, error: 'Task required' };
326
+ }
327
+ const appLogs = await this.service.getAppLogs(request.task, {
328
+ lines: request.lines,
329
+ level: request.appLevel,
330
+ filter: request.filter,
331
+ message: request.message,
332
+ source: request.source,
333
+ since: request.since,
334
+ until: request.until,
335
+ });
336
+ return { ok: true, appLogs };
337
+ }
319
338
  case 'upload': {
320
339
  if (!request.task || !request.files || request.files.length === 0) {
321
340
  return { ok: false, error: 'Task and at least one file required' };
@@ -334,7 +353,43 @@ export class BrowserIPCServer {
334
353
  }
335
354
  }
336
355
  }
356
+ let versionCheckedThisProcess = false;
357
+ /**
358
+ * Check the daemon's version against ours and warn loudly when they
359
+ * differ. Fires at most once per CLI process — successive calls in the
360
+ * same `agents browser ...` invocation are cheap. The whole reason this
361
+ * code exists: a launchd-managed registry daemon kept serving stale code
362
+ * to a dev-build CLI for an entire session and nothing surfaced it.
363
+ */
364
+ async function maybeWarnVersionMismatch() {
365
+ if (versionCheckedThisProcess)
366
+ return;
367
+ versionCheckedThisProcess = true;
368
+ try {
369
+ const resp = await sendRawIPCRequest({ action: 'version' });
370
+ const daemon = resp.version;
371
+ const client = getCliVersion();
372
+ if (!daemon || daemon === 'unknown' || daemon === client)
373
+ return;
374
+ process.stderr.write(`\nwarning: browser daemon is on ${daemon} but this CLI is on ${client}.\n` +
375
+ ` Run \`agents daemon restart\` to load the current code.\n\n`);
376
+ }
377
+ catch {
378
+ // daemon might be an older build that doesn't speak 'version' — that's
379
+ // itself a hint, but a noisy one. Stay silent on this path.
380
+ }
381
+ }
337
382
  export async function sendIPCRequest(request) {
383
+ const result = await sendRawIPCRequest(request);
384
+ // Run the version check after the user's request returns — keeps the
385
+ // critical path zero-overhead and ensures `start` doesn't get blocked
386
+ // on a daemon-restart warning that the user hasn't read yet.
387
+ if (request.action !== 'version') {
388
+ maybeWarnVersionMismatch().catch(() => { });
389
+ }
390
+ return result;
391
+ }
392
+ async function sendRawIPCRequest(request) {
338
393
  const socketPath = getSocketPath();
339
394
  if (!fs.existsSync(socketPath)) {
340
395
  await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });
@@ -5,8 +5,21 @@ export declare function getProfileRuntimeDir(name: string): string;
5
5
  export declare function listProfiles(): Promise<BrowserProfile[]>;
6
6
  export declare function getProfile(name: string): Promise<BrowserProfile | null>;
7
7
  /**
8
- * Find a port in 9222–9399 that is not already claimed by another profile
9
- * and is not currently in use by any OS process.
8
+ * Compute the LOCAL port a profile will occupy at runtime:
9
+ * - `cdp://127.0.0.1:N` N (we listen on N directly)
10
+ * - `ssh://host?port=N` → N (the SSH tunnel binds local N → remote N now)
11
+ * - `ws[s]://`, `http[s]://` → undefined (we don't claim a local port)
12
+ *
13
+ * This is what callers should compare to detect collisions; the (host,
14
+ * port) tuple is no longer enough because SSH profiles do compete with
15
+ * cdp:// profiles for local ports under the new tunnel scheme.
16
+ */
17
+ export declare function effectiveLocalPort(profile: BrowserProfile): number | undefined;
18
+ /**
19
+ * Find a port in 9222–9399 that is not already claimed by ANY existing
20
+ * profile (cdp:// or ssh://) and is not in use by any OS process. The
21
+ * SSH change to bind locally on `?port=N` means we no longer get to
22
+ * skip remote profiles in this scan.
10
23
  */
11
24
  export declare function findFreeProfilePort(): Promise<number>;
12
25
  export declare function createProfile(profile: BrowserProfile): Promise<void>;
@@ -34,8 +47,39 @@ export declare function resolveEndpoint(profile: BrowserProfile, endpointName?:
34
47
  binary?: string;
35
48
  targetFilter?: string;
36
49
  };
50
+ /**
51
+ * Extract the (host, port) pair intended by the profile's default endpoint.
52
+ * Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
53
+ *
54
+ * Ports are scoped by host: a `cdp://127.0.0.1:9222` profile (local Chrome on
55
+ * this machine) and an `ssh://mac-mini:9222` profile (Comet on mac-mini)
56
+ * point at different physical ports — the host disambiguates them.
57
+ *
58
+ * Accepts both `scheme://host:port` and `scheme://host?port=N` shapes (the
59
+ * latter is the documented form in `types.ts` for `ssh://`). Without this,
60
+ * `ssh://mac-mini?port=18805` would silently fall back to 9222 and every
61
+ * `?port=`-style SSH profile would collide on creation.
62
+ */
63
+ export declare function extractConfiguredEndpoint(profile: BrowserProfile): {
64
+ host: string;
65
+ port: number;
66
+ } | undefined;
67
+ /**
68
+ * Shared endpoint parser used by both the collision-detection code path and
69
+ * the connection drivers. Returning a single normalized `(host, port)` here
70
+ * keeps `extractConfiguredEndpoint` and the SSH driver from drifting on URL
71
+ * conventions (which is how `?port=N` ended up being silently ignored).
72
+ */
73
+ export declare function parseEndpointUrl(endpoint: string): {
74
+ host: string;
75
+ port: number;
76
+ } | undefined;
37
77
  /**
38
78
  * Extract the port intended by the profile's default endpoint.
39
79
  * Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
80
+ *
81
+ * Note: this loses the host dimension — for collision detection use
82
+ * `extractConfiguredEndpoint` instead, which returns the (host, port) pair.
40
83
  */
41
84
  export declare function extractConfiguredPort(profile: BrowserProfile): number | undefined;
85
+ export declare function isLocalHost(host: string): boolean;