@phnx-labs/agents-cli 1.18.3 → 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,4 +1,15 @@
1
1
  import type { DeviceDescriptor } from './types.js';
2
+ /**
3
+ * Default viewport for newly-created profiles. Matches Safari's logical
4
+ * resolution on a 14-inch MacBook Pro (M1/M2/M3 Pro/Max) — the most common
5
+ * shape this CLI sees in practice. Shared with the `MacBook Pro` device
6
+ * preset below so both surfaces agree.
7
+ */
8
+ export declare const DEFAULT_VIEWPORT: {
9
+ readonly width: 1512;
10
+ readonly height: 982;
11
+ readonly deviceScaleFactor: 2;
12
+ };
2
13
  export declare const DEVICES: Record<string, DeviceDescriptor>;
3
14
  export declare function getDevice(name: string): DeviceDescriptor | undefined;
4
15
  export declare function listDevices(): string[];
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Default viewport for newly-created profiles. Matches Safari's logical
3
+ * resolution on a 14-inch MacBook Pro (M1/M2/M3 Pro/Max) — the most common
4
+ * shape this CLI sees in practice. Shared with the `MacBook Pro` device
5
+ * preset below so both surfaces agree.
6
+ */
7
+ export const DEFAULT_VIEWPORT = {
8
+ width: 1512,
9
+ height: 982,
10
+ deviceScaleFactor: 2,
11
+ };
1
12
  export const DEVICES = {
2
13
  'iPhone 14': {
3
14
  width: 390,
@@ -12,9 +23,9 @@ export const DEVICES = {
12
23
  mobile: true,
13
24
  },
14
25
  'MacBook Pro': {
15
- width: 1440,
16
- height: 900,
17
- deviceScaleFactor: 2,
26
+ width: DEFAULT_VIEWPORT.width,
27
+ height: DEFAULT_VIEWPORT.height,
28
+ deviceScaleFactor: DEFAULT_VIEWPORT.deviceScaleFactor,
18
29
  mobile: false,
19
30
  },
20
31
  };
@@ -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
+ }