@phnx-labs/agents-cli 1.22.50 → 1.22.52

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 (85) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/bootstrap.js +1 -1
  3. package/dist/commands/attach.js +7 -0
  4. package/dist/commands/browser.d.ts +10 -0
  5. package/dist/commands/browser.js +191 -54
  6. package/dist/commands/config.js +20 -0
  7. package/dist/commands/daemon.d.ts +2 -0
  8. package/dist/commands/daemon.js +8 -4
  9. package/dist/commands/detach.js +1 -1
  10. package/dist/commands/feedback.js +1 -1
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +14 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +63 -5
  15. package/dist/commands/menubar.js +6 -4
  16. package/dist/commands/monitors.js +1 -1
  17. package/dist/commands/repo.js +31 -3
  18. package/dist/commands/sessions-resume.d.ts +1 -0
  19. package/dist/commands/sessions-resume.js +13 -2
  20. package/dist/commands/sessions-stop.js +1 -1
  21. package/dist/commands/sessions.d.ts +23 -13
  22. package/dist/commands/sessions.js +40 -20
  23. package/dist/commands/setup-browser.d.ts +5 -2
  24. package/dist/commands/setup-browser.js +14 -29
  25. package/dist/commands/setup-preferences.d.ts +22 -3
  26. package/dist/commands/setup-preferences.js +25 -8
  27. package/dist/commands/share.js +12 -8
  28. package/dist/commands/status.js +5 -0
  29. package/dist/commands/sync.js +58 -2
  30. package/dist/commands/tmux.d.ts +8 -1
  31. package/dist/commands/tmux.js +167 -17
  32. package/dist/commands/traces.js +32 -4
  33. package/dist/lib/browser/ipc.d.ts +44 -0
  34. package/dist/lib/browser/ipc.js +120 -8
  35. package/dist/lib/browser/profiles.d.ts +39 -17
  36. package/dist/lib/browser/profiles.js +51 -52
  37. package/dist/lib/browser/runtime-state.d.ts +4 -2
  38. package/dist/lib/browser/runtime-state.js +4 -2
  39. package/dist/lib/browser/service.js +4 -3
  40. package/dist/lib/channels/owner-forward.d.ts +88 -0
  41. package/dist/lib/channels/owner-forward.js +116 -0
  42. package/dist/lib/channels/owner-sink.js +7 -0
  43. package/dist/lib/claude-statusline.d.ts +9 -0
  44. package/dist/lib/claude-statusline.js +45 -4
  45. package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
  46. package/dist/lib/computer/ssh-tunnel.js +13 -8
  47. package/dist/lib/config-keys.d.ts +4 -3
  48. package/dist/lib/config-keys.js +9 -2
  49. package/dist/lib/device-config.js +23 -0
  50. package/dist/lib/exec.d.ts +8 -0
  51. package/dist/lib/exec.js +7 -0
  52. package/dist/lib/factory/snapshot.d.ts +1 -1
  53. package/dist/lib/factory/snapshot.js +1 -1
  54. package/dist/lib/feed-broadcast.js +15 -1
  55. package/dist/lib/git.d.ts +93 -0
  56. package/dist/lib/git.js +232 -0
  57. package/dist/lib/helper-download.d.ts +12 -2
  58. package/dist/lib/helper-download.js +12 -2
  59. package/dist/lib/installations/migrate.js +4 -4
  60. package/dist/lib/menubar/install-menubar.d.ts +71 -8
  61. package/dist/lib/menubar/install-menubar.js +183 -24
  62. package/dist/lib/monitors/remote.d.ts +18 -1
  63. package/dist/lib/monitors/remote.js +15 -2
  64. package/dist/lib/notify.d.ts +7 -0
  65. package/dist/lib/notify.js +15 -1
  66. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  67. package/dist/lib/session/local-tmux-attach.js +164 -0
  68. package/dist/lib/session/remote-active.d.ts +8 -0
  69. package/dist/lib/session/remote-active.js +1 -0
  70. package/dist/lib/share/publish.d.ts +8 -11
  71. package/dist/lib/share/publish.js +16 -20
  72. package/dist/lib/share/worker-template.js +99 -12
  73. package/dist/lib/star-nudge.d.ts +2 -2
  74. package/dist/lib/star-nudge.js +2 -2
  75. package/dist/lib/state.d.ts +1 -1
  76. package/dist/lib/state.js +2 -2
  77. package/dist/lib/sync-status.d.ts +17 -0
  78. package/dist/lib/sync-status.js +21 -2
  79. package/dist/lib/tmux/index.d.ts +1 -1
  80. package/dist/lib/tmux/index.js +1 -1
  81. package/dist/lib/tmux/session.d.ts +10 -0
  82. package/dist/lib/tmux/session.js +29 -0
  83. package/dist/lib/traces/sync.d.ts +30 -0
  84. package/dist/lib/traces/sync.js +91 -10
  85. package/package.json +3 -3
@@ -14,7 +14,7 @@ import { DEFAULT_VIEWPORT, parseWindowSize, parseWindowPosition } from '../lib/b
14
14
  import { runBrowserSessionsCommand } from './browser-sessions-picker.js';
15
15
  import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
16
16
  import { parseTargetFilter } from '../lib/browser/service.js';
17
- import { BrowserDaemonNotRunningError, formatBrowserDaemonNotRunningError, sendIPCRequest as sendRawIPCRequest, } from '../lib/browser/ipc.js';
17
+ import { BrowserDaemonNotRunningError, formatBrowserDaemonNotRunningError, sendIPCRequest as sendRawIPCRequest, resetBrowserDaemon, } from '../lib/browser/ipc.js';
18
18
  import { bindTask, getTaskBinding, honorScreenshotOutput, REJECT_DEVICE_MESSAGE, resolveTaskRoute, unbindTask, unbindTasksForProfile, updateTaskBinding, } from '../lib/browser/task-index.js';
19
19
  import { isSelfHost } from '../lib/devices/self-host.js';
20
20
  import { resolveHost } from '../lib/hosts/registry.js';
@@ -248,6 +248,23 @@ function assertDeviceDeclaresProfile(device, profileName) {
248
248
  throw new Error(`Device "${device}" does not declare browser profile "${profileName}". ${where}\n` +
249
249
  `Next: run \`agents browser profiles add ${profileName} --browser <b>\` on ${device}.`);
250
250
  }
251
+ /**
252
+ * The fleet browser hub this box drives by default (`browser.device`), or
253
+ * undefined to drive locally. It is one fleet-synced value in the central
254
+ * agents.yaml, so every box reads the same hub name — but the hub itself resolves
255
+ * to a self-host, and a fleet-dispatched invocation is already ON its target, so
256
+ * both return undefined. That self short-circuit is what lets a single synced
257
+ * value be safe: only the boxes that are NOT the hub forward to it, and the hub
258
+ * never forwards to itself.
259
+ */
260
+ export function defaultBrowserHub() {
261
+ if (isFleetRemoteInvocation())
262
+ return undefined;
263
+ const hub = getConfigValue('browser.device').value;
264
+ if (!hub || isSelfHost(hub))
265
+ return undefined;
266
+ return hub;
267
+ }
251
268
  // Help groups — surfaces the actual mental model an agent follows
252
269
  // ("open a session / drive the page / capture evidence / rare extras")
253
270
  // instead of an alphabetical dump. Everything not listed falls into a
@@ -277,7 +294,7 @@ export function registerBrowserCommand(program) {
277
294
  # Create a Chrome profile pointed at a CDP endpoint
278
295
  agents browser profiles create work --browser chrome --endpoint http://localhost:9222
279
296
 
280
- # Start a session — auto-picks the first installed Chromium-family browser
297
+ # Start a session on this machine's default browser (set it once with: agents browser use)
281
298
  agents browser start
282
299
 
283
300
  # Or pin to a specific profile
@@ -306,6 +323,11 @@ export function registerBrowserCommand(program) {
306
323
 
307
324
  # Close tabs the daemon's own reaper would have caught on its next 5-min tick
308
325
  agents browser prune --dry-run
326
+
327
+ # Recover a wedged daemon ("Timeout waiting for browser daemon socket"):
328
+ # stop it and clear a stale socket, then start clean
329
+ agents browser stop --daemon
330
+ agents browser start
309
331
  `,
310
332
  notes: `
311
333
  Most agent workflows should use the 'browser' skill instead of raw subcommands.
@@ -346,7 +368,7 @@ export function buildBrowserUseChoices(profiles, installed, current) {
346
368
  export async function runBrowserUse(name, opts, interactive = isInteractiveTerminal()) {
347
369
  if (opts.unset || name === 'auto') {
348
370
  unsetConfigValue('browser.profile');
349
- console.log('Default browser profile cleared. `agents browser start` will auto-detect an installed Chromium-family browser.');
371
+ console.log('Default browser profile cleared. Pick one with `agents browser use <name>` (or `agents setup`); until then `agents browser start` has no default to launch here.');
350
372
  return true;
351
373
  }
352
374
  let selectedName = name;
@@ -355,8 +377,15 @@ export async function runBrowserUse(name, opts, interactive = isInteractiveTermi
355
377
  if (!interactive) {
356
378
  console.log(current
357
379
  ? `Default browser profile (this machine): ${current}`
358
- : 'Default browser profile (this machine): auto-detect');
380
+ : 'Default browser profile (this machine): none — pick one with `agents browser use <name>`');
381
+ const hub = getConfigValue('browser.device').value;
382
+ if (hub) {
383
+ console.log(isSelfHost(hub)
384
+ ? `Browser hub (browser.device): ${hub} — this machine, so drives locally`
385
+ : `Browser hub (browser.device): ${hub} — bare \`agents browser start\` drives ${hub}'s browser`);
386
+ }
359
387
  console.log('Usage: agents browser use <name> (or --unset)');
388
+ console.log('Fleet hub: agents config set browser.device <device> (drive that box\'s browser from every machine)');
360
389
  return true;
361
390
  }
362
391
  const choices = buildBrowserUseChoices(await listProfiles(), listInstalledBrowsers(), current);
@@ -397,7 +426,7 @@ export async function runBrowserUse(name, opts, interactive = isInteractiveTermi
397
426
  function configureBrowserUseCommand(command, deprecated = false) {
398
427
  command
399
428
  .description('Pick the profile `agents browser start` uses when no --profile is passed. No name opens a picker on a TTY or prints the current default headlessly.')
400
- .option('--unset', 'Clear the configured default (revert to auto-detecting an installed browser)')
429
+ .option('--unset', 'Clear the configured default (leaves this machine with no default until you pick one)')
401
430
  .action(async (name, opts) => {
402
431
  if (deprecated) {
403
432
  console.warn(chalk.yellow('Deprecation: `agents browser profiles set-default` is replaced by `agents browser use`.'));
@@ -918,7 +947,7 @@ function registerProfilesCommands(browser) {
918
947
  (removed > 0 ? ` (and ${removed} cache dir${removed === 1 ? '' : 's'})` : ''));
919
948
  if (getConfiguredDefaultProfileName() === name) {
920
949
  console.error(`warning: "${name}" was this machine's default browser profile; ` +
921
- `\`agents browser start\` will auto-detect until you run: agents browser use <name>`);
950
+ `\`agents browser start\` has no default here until you run: agents browser use <name>`);
922
951
  }
923
952
  });
924
953
  profiles
@@ -1001,13 +1030,13 @@ function registerProfilesCommands(browser) {
1001
1030
  with \`agents browser profiles claim\` on the machine that hosts the browser.
1002
1031
 
1003
1032
  In \`list\`, the \`*\` marker means "this machine's configured default"
1004
- (\`agents browser start\` with no --profile). The auto-detected profile is
1005
- named \`auto-chrome\`; \`default\` is only an ALIAS for whichever profile
1033
+ (\`agents browser start\` with no --profile). A profile a setup wizard pins
1034
+ is named \`auto-chrome\`; \`default\` is only an ALIAS for whichever profile
1006
1035
  this machine is configured to use, resolved the same way by every command.
1007
1036
 
1008
1037
  \`prune\` only considers profiles this device declares. It never removes a
1009
- profile that is in use, the configured default, or the auto-detected
1010
- \`auto-chrome\`.
1038
+ profile that is in use, the configured default, or the \`auto-chrome\`
1039
+ profile a setup wizard created.
1011
1040
  `,
1012
1041
  });
1013
1042
  profiles
@@ -1347,13 +1376,13 @@ function registerTaskCommands(browser) {
1347
1376
  });
1348
1377
  browser
1349
1378
  .command('start')
1350
- .description('Start a browser task. Pass --profile <name>; omit to use your configured default (`agents browser use <name>`), else auto-pick an installed Chromium-family browser. Page verbs (navigate/screenshot/…) create a task implicitly when none exists — start is for --profile/--url/--record/--title.')
1351
- .option('-p, --profile <name>', 'Browser profile to use (omit to use the configured default, else auto-pick an installed Chromium-family browser)')
1379
+ .description('Start a browser task. Pass --profile <name>; omit to use your configured default (set it with `agents browser use <name>` or `agents setup`). Page verbs (navigate/screenshot/…) create a task implicitly when none exists — start is for --profile/--url/--record/--title.')
1380
+ .option('-p, --profile <name>', 'Browser profile to use (omit to use this machine\'s configured default; set it with `agents browser use <name>`)')
1352
1381
  .option(TASK_OPTION_FLAG, 'Task name (auto-generated short id if omitted)')
1353
1382
  .option('--title <label>', 'Human label shown in `browser status` (defaults to first navigated host)')
1354
1383
  .option('-e, --endpoint <name>', 'Endpoint preset (defaults to the profile\'s default)')
1355
1384
  .option('-u, --url <url>', 'Open URL in first tab')
1356
- .option('--device <name>', 'Device that hosts this task; later verbs resolve it from --task (not valid on page verbs)')
1385
+ .option('--device <name>', 'Device that hosts this task (defaults to the browser.device hub when set; use `local` to force this machine). Later verbs resolve it from --task; not valid on page verbs')
1357
1386
  .option('--fresh', 'Always open a new tab, skipping the reclaim of a tab an abandoned task is holding on that URL')
1358
1387
  .option('--no-skills', 'Skip auto-discovery of site-specific SKILL.md from ~/.agents/skills/browser/domain-skills/')
1359
1388
  .option('--record', 'Start recording right after the tab opens (shorthand for `agents browser record start` as a follow-up)')
@@ -1373,13 +1402,133 @@ function registerTaskCommands(browser) {
1373
1402
  console.error(err instanceof Error ? err.message : String(err));
1374
1403
  process.exit(1);
1375
1404
  }
1405
+ // Thin-client hub (PHNX-2010): when this box has a fleet browser hub
1406
+ // (`browser.device`) and the caller gave no explicit --device, forward the
1407
+ // WHOLE start there — bare OR --profile — and skip the local profile/browser
1408
+ // resolution below. The hub owns its profiles, browser, and consent, so this
1409
+ // box needs no local browser and need not carry the named profile; forwarding
1410
+ // the local pre-checks here would reject a profile that lives only on the hub.
1411
+ // Later page verbs follow the task to the hub via the task→device index bound
1412
+ // here. `--device local`/`--device self` (below) force a local run instead;
1413
+ // `defaultBrowserHub()` is undefined on the hub itself and on a fleet-remote
1414
+ // re-exec, so neither forwards to itself.
1415
+ if (!opts.device) {
1416
+ const hub = defaultBrowserHub();
1417
+ if (hub) {
1418
+ // No local profile validation: the hub is the authority on its own
1419
+ // profiles (including fleet-scoped ones this box can't see in
1420
+ // `declaringDevices`), so a bad `--profile` is caught by the hub's own
1421
+ // start and streamed back with its exit code, rather than second-guessed
1422
+ // here against a registry that doesn't carry the hub's profiles.
1423
+ try {
1424
+ const result = await dispatchBrowserToDevice(hub, browserForwardedArgv(), 'capture');
1425
+ process.stdout.write(result.stdout);
1426
+ process.stderr.write(result.stderr);
1427
+ if (result.code !== 0)
1428
+ process.exit(result.code);
1429
+ const taskName = opts.task || result.stdout.trim().split('\n').find((line) => line.length > 0);
1430
+ if (!taskName) {
1431
+ console.error(`Remote start on ${hub} produced no task name.`);
1432
+ process.exit(1);
1433
+ }
1434
+ bindTask(taskName, {
1435
+ device: hub,
1436
+ // Only what the caller named; the hub's own default (bare start) is
1437
+ // opaque here. Name-based `done`/`stop --task` and the post-stop
1438
+ // unbind GC these regardless.
1439
+ profile: opts.profile,
1440
+ url: opts.url,
1441
+ sessionId: callerSessionId(),
1442
+ launchId: callerLaunchId(),
1443
+ createdAt: Date.now(),
1444
+ });
1445
+ }
1446
+ catch (err) {
1447
+ console.error(err instanceof Error ? err.message : String(err));
1448
+ process.exit(1);
1449
+ }
1450
+ return;
1451
+ }
1452
+ }
1453
+ // Resolve an EXPLICIT --device BEFORE any local profile/browser
1454
+ // resolution (PHNX-3289). The task lives on the TARGET device, so its
1455
+ // browser binary and profile must resolve THERE. Resolving locally first
1456
+ // made a browserless box fail with a misleading "No supported browser
1457
+ // found" (bare start) or "Profile <x> not found" (a profile that lives
1458
+ // only on the target) before the start ever routed to --device. This
1459
+ // mirrors the hub path above: the target is the authority on its own
1460
+ // profiles, so forward the whole start and let it validate. `local`/`self`
1461
+ // force a local run; a fleet-remote re-exec is already ON the target and
1462
+ // falls through to the local launch below.
1463
+ const deviceName = opts.device === 'local' || opts.device === 'self' ? undefined : opts.device;
1464
+ if (deviceName) {
1465
+ const lowered = deviceName.toLowerCase();
1466
+ if (lowered === 'all' || lowered === 'auto') {
1467
+ console.error(`--device ${deviceName} is not valid on \`agents browser start\`: a task lives on one device.\n` +
1468
+ `Next: agents browser start --task <name> --device <device>`);
1469
+ process.exit(1);
1470
+ }
1471
+ if (!isSelfHost(deviceName) && !isFleetRemoteInvocation()) {
1472
+ // Resolve the profile NAME (never a local browser auto-pick) so a
1473
+ // named profile can still be validated against the target's
1474
+ // declarations before we round-trip. A bare start carries no name to
1475
+ // validate — the target picks its own default — so it forwards
1476
+ // straight through, which is exactly what unblocks a browserless box.
1477
+ // Only resolve when the caller actually named a profile: passing an
1478
+ // undefined ref would let `resolveProfileRef` fall back to THIS box's
1479
+ // configured/auto-detected default (matching the ~4 other call sites
1480
+ // in this file), and validating that local name against the target
1481
+ // re-introduces the exact false failure this reorder fixes.
1482
+ const forwardProfile = opts.profile ? await resolveProfileRef(opts.profile) : undefined;
1483
+ if (forwardProfile) {
1484
+ try {
1485
+ assertDeviceDeclaresProfile(deviceName, forwardProfile);
1486
+ }
1487
+ catch (err) {
1488
+ console.error(err instanceof Error ? err.message : String(err));
1489
+ process.exit(1);
1490
+ }
1491
+ }
1492
+ try {
1493
+ const result = await dispatchBrowserToDevice(deviceName, browserForwardedArgv(), 'capture');
1494
+ process.stdout.write(result.stdout);
1495
+ process.stderr.write(result.stderr);
1496
+ if (result.code !== 0)
1497
+ process.exit(result.code);
1498
+ const taskName = opts.task || result.stdout.trim().split('\n').find((line) => line.length > 0);
1499
+ if (!taskName) {
1500
+ console.error(`Remote start on ${deviceName} produced no task name.`);
1501
+ process.exit(1);
1502
+ }
1503
+ bindTask(taskName, {
1504
+ device: deviceName,
1505
+ // Only the caller-named profile; the target's own default (bare
1506
+ // start) is opaque here, exactly as in the hub path.
1507
+ profile: forwardProfile ?? opts.profile,
1508
+ url: opts.url,
1509
+ sessionId: callerSessionId(),
1510
+ launchId: callerLaunchId(),
1511
+ createdAt: Date.now(),
1512
+ });
1513
+ if (!result.stderr.includes(`started on ${deviceName}`)) {
1514
+ console.error(`Task "${taskName}" started on ${deviceName}.`);
1515
+ }
1516
+ }
1517
+ catch (err) {
1518
+ console.error(err instanceof Error ? err.message : String(err));
1519
+ process.exit(1);
1520
+ }
1521
+ return;
1522
+ }
1523
+ }
1376
1524
  // One resolution order for every command (RUSH-2709): `--profile default`
1377
1525
  // means the same profile here as it does in stop / status / navigate.
1378
1526
  // `start` is the one command that LAUNCHES, so its implicit path goes
1379
1527
  // through ensureDefaultBrowserProfile, which additionally verifies the
1380
- // resolved default can launch on THIS machine and regenerates it when it
1381
- // can't (the "Custom binary not found" repair) — a filter-only command
1382
- // must not warn or rewrite a profile, so that check lives only here.
1528
+ // resolved default can launch on THIS machine and, when nothing launchable
1529
+ // resolves, throws an actionable "pick a browser in `agents setup`" error
1530
+ // (PHNX-3296 — it no longer silently mints an auto-chrome). A filter-only
1531
+ // command must not warn about the config, so that check lives only here.
1383
1532
  let profileName;
1384
1533
  try {
1385
1534
  profileName = await resolveProfileRefForStart(opts.profile);
@@ -1409,14 +1558,12 @@ function registerTaskCommands(browser) {
1409
1558
  process.exit(1);
1410
1559
  }
1411
1560
  }
1412
- const deviceName = opts.device;
1561
+ // Only the LOCAL launch reaches here — an explicit remote --device already
1562
+ // forwarded and returned above. A residual `deviceName` means an explicit
1563
+ // self target (or a fleet-remote re-exec on the target); its profile
1564
+ // resolved locally, so verify this box actually declares it (the
1565
+ // `profiles claim` hint lives in `assertDeviceDeclaresProfile`).
1413
1566
  if (deviceName) {
1414
- const lowered = deviceName.toLowerCase();
1415
- if (lowered === 'all' || lowered === 'auto') {
1416
- console.error(`--device ${deviceName} is not valid on \`agents browser start\`: a task lives on one device.\n` +
1417
- `Next: agents browser start --task <name> --device <device>`);
1418
- process.exit(1);
1419
- }
1420
1567
  try {
1421
1568
  assertDeviceDeclaresProfile(deviceName, profileName);
1422
1569
  }
@@ -1424,36 +1571,6 @@ function registerTaskCommands(browser) {
1424
1571
  console.error(err instanceof Error ? err.message : String(err));
1425
1572
  process.exit(1);
1426
1573
  }
1427
- if (!isSelfHost(deviceName) && !isFleetRemoteInvocation()) {
1428
- try {
1429
- const result = await dispatchBrowserToDevice(deviceName, browserForwardedArgv(), 'capture');
1430
- process.stdout.write(result.stdout);
1431
- process.stderr.write(result.stderr);
1432
- if (result.code !== 0)
1433
- process.exit(result.code);
1434
- const taskName = opts.task || result.stdout.trim().split('\n').find((line) => line.length > 0);
1435
- if (!taskName) {
1436
- console.error(`Remote start on ${deviceName} produced no task name.`);
1437
- process.exit(1);
1438
- }
1439
- bindTask(taskName, {
1440
- device: deviceName,
1441
- profile: profileName,
1442
- url: opts.url,
1443
- sessionId: callerSessionId(),
1444
- launchId: callerLaunchId(),
1445
- createdAt: Date.now(),
1446
- });
1447
- if (!result.stderr.includes(`started on ${deviceName}`)) {
1448
- console.error(`Task "${taskName}" started on ${deviceName} (profile: ${profileName}).`);
1449
- }
1450
- }
1451
- catch (err) {
1452
- console.error(err instanceof Error ? err.message : String(err));
1453
- process.exit(1);
1454
- }
1455
- return;
1456
- }
1457
1574
  }
1458
1575
  // Grounded login guardrail: if the opening URL is a known login-gated
1459
1576
  // service and the resolved profile has no session for it, say so on stderr
@@ -1566,11 +1683,31 @@ function registerTaskCommands(browser) {
1566
1683
  });
1567
1684
  browser
1568
1685
  .command('stop')
1569
- .description('Stop a browser task and close its tabs; with --profile, detach the whole profile (close browser + drop cached connection)')
1686
+ .description('Stop a browser task and close its tabs; with --profile, detach the whole profile (close browser + drop cached connection); with --daemon, stop the browser daemon and clear a wedged socket')
1570
1687
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1571
1688
  .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1572
1689
  .option('-p, --profile <name>', 'Detach the whole profile (incl. composite "name@endpoint") instead of stopping a single task')
1690
+ .option('--daemon', 'Stop the browser daemon entirely and clear a stale/wedged socket so the next start comes up clean (recovery for "Timeout waiting for browser daemon socket")')
1573
1691
  .action(async (opts) => {
1692
+ if (opts.daemon) {
1693
+ if (opts.profile || opts.task) {
1694
+ console.error('--daemon stops the whole browser daemon; do not combine it with --profile or --task.');
1695
+ process.exit(1);
1696
+ }
1697
+ try {
1698
+ const result = await resetBrowserDaemon();
1699
+ const parts = [];
1700
+ parts.push(result.wasRunning ? 'Stopped browser daemon' : 'Browser daemon was not running');
1701
+ if (result.socketCleared)
1702
+ parts.push('cleared stale socket');
1703
+ console.log(`${parts.join('; ')}. It restarts on the next browser command.`);
1704
+ }
1705
+ catch (err) {
1706
+ console.error(err instanceof Error ? err.message : String(err));
1707
+ process.exit(1);
1708
+ }
1709
+ return;
1710
+ }
1574
1711
  if (opts.profile) {
1575
1712
  const profile = (await resolveProfileRef(opts.profile)) ?? opts.profile;
1576
1713
  const response = await sendIPCRequest({
@@ -111,6 +111,11 @@ function setConfig(parsed, value) {
111
111
  return;
112
112
  }
113
113
  case 'browser': {
114
+ if (parsed.property === 'device') {
115
+ // Fleet hub: user scope, one central value, never peer-targeted.
116
+ setConfigValue('browser.device', value);
117
+ return;
118
+ }
114
119
  // Device-local default lives in the per-device doc's config: block
115
120
  // (same store `agents devices config` / getConfigValue use). Bare
116
121
  // browser.profile targets this machine; devices.<name>.browser.profile
@@ -155,6 +160,11 @@ function unsetConfig(parsed) {
155
160
  return had;
156
161
  }
157
162
  case 'browser': {
163
+ if (parsed.property === 'device') {
164
+ const had = getConfigValue('browser.device').value !== undefined;
165
+ unsetConfigValue('browser.device');
166
+ return had;
167
+ }
158
168
  const target = parsed.device ? { device: parsed.device } : undefined;
159
169
  // Must follow parsed.property. Hardcoding 'browser.profile' here meant
160
170
  // `config unset browser.viewer` deleted the user's browser.profile while
@@ -195,6 +205,9 @@ function getConfig(parsed) {
195
205
  case 'auto':
196
206
  return getConfigValue('auto.pool').value;
197
207
  case 'browser': {
208
+ if (parsed.property === 'device') {
209
+ return getConfigValue('browser.device').value;
210
+ }
198
211
  return getConfigValue(parsed.property === 'viewer' ? 'browser.viewer' : 'browser.profile', parsed.device ? { device: parsed.device } : undefined).value;
199
212
  }
200
213
  case 'project':
@@ -267,6 +280,13 @@ function* listCentralConfigEntries() {
267
280
  const key = 'browser.viewer';
268
281
  yield { key, value: browserViewer, hint: configKeyStorageHint(parseConfigKey(key)) };
269
282
  }
283
+ // Fleet browser hub — user scope, so it belongs in the central listing next to
284
+ // its siblings. Omitting it repeats the browser.viewer invisibility bug.
285
+ const browserDevice = getConfigValue('browser.device').value;
286
+ if (browserDevice !== undefined) {
287
+ const key = 'browser.device';
288
+ yield { key, value: browserDevice, hint: configKeyStorageHint(parseConfigKey(key)) };
289
+ }
270
290
  }
271
291
  /** Collect device-scope config entries. */
272
292
  function* listDeviceConfigEntries(device) {
@@ -16,6 +16,8 @@
16
16
  */
17
17
  import type { Command } from 'commander';
18
18
  import { type DaemonServiceId } from '../lib/daemon-services.js';
19
+ /** Elapsed wall-clock seconds since `pid` started, or null if unavailable (best-effort, POSIX only). */
20
+ export declare function uptimeSeconds(pid: number): number | null;
19
21
  /** One row of the roster every registered daemon service — supervisor-managed or legacy — renders in `agents daemon services`. */
20
22
  export interface DaemonServiceRow {
21
23
  id: DaemonServiceId;
@@ -30,6 +30,7 @@ import { parseDuration } from '../lib/hooks/cache.js';
30
30
  import { registerFunnelCommand } from './funnel.js';
31
31
  import { DEFAULT_WEBHOOK_PORT, DEFAULT_WEBHOOK_RATE_LIMIT, addHostedReceiver, getDaemonWebhooksConfigPath, hostedReceiverPort, readDaemonWebhooksConfig, removeHostedReceiver, } from '../lib/daemon-webhooks.js';
32
32
  import { parseFunnelPort } from '../lib/funnel.js';
33
+ import { parseEtimeToSeconds } from '../lib/secrets/reaper.js';
33
34
  /**
34
35
  * Provably absent, or null when unknowable.
35
36
  *
@@ -256,13 +257,16 @@ function registryScopedDuplicates(processes, ownerPid) {
256
257
  return processes.filter((p) => registered.has(p.pid));
257
258
  }
258
259
  /** Elapsed wall-clock seconds since `pid` started, or null if unavailable (best-effort, POSIX only). */
259
- function uptimeSeconds(pid) {
260
+ export function uptimeSeconds(pid) {
260
261
  if (process.platform === 'win32')
261
262
  return null;
262
263
  try {
263
- const out = execFileSync('ps', ['-o', 'etimes=', '-p', String(pid)], { encoding: 'utf-8' }).trim();
264
- const n = parseInt(out, 10);
265
- return isNaN(n) ? null : n;
264
+ // `-o etimes=` is a GNU/procps keyword macOS/BSD `ps` rejects with a
265
+ // non-zero exit (`ps: etimes: keyword not found`), so `agents daemon status`
266
+ // errored out entirely on macOS. `etime` (`[[dd-]hh:]mm:ss`) is the portable
267
+ // POSIX field; `parseEtimeToSeconds` (shared with the keychain reaper) parses it.
268
+ const out = execFileSync('ps', ['-o', 'etime=', '-p', String(pid)], { encoding: 'utf-8' }).trim();
269
+ return parseEtimeToSeconds(out);
266
270
  }
267
271
  catch {
268
272
  return null;
@@ -123,7 +123,7 @@ function spawnHeadless(command, args, logFile) {
123
123
  });
124
124
  }
125
125
  export async function detachAction(id, opts = {}) {
126
- const { self, activeById } = await gatherLiveTargets(!!opts.local, { includeCloud: true });
126
+ const { self, activeById } = await gatherLiveTargets(!!opts.local, { includeCloud: true, selector: id });
127
127
  const resolved = resolveOne(activeById, id);
128
128
  if ('error' in resolved) {
129
129
  console.error(chalk.red(resolved.error));
@@ -7,7 +7,7 @@ import { arch, platform, release } from 'node:os';
7
7
  import { createRequire } from 'node:module';
8
8
  import chalk from 'chalk';
9
9
  import { showUrl } from '../lib/open-url.js';
10
- const REPO = 'phnx-labs/agents-cli';
10
+ const REPO = 'phnx-labs/agi-cli';
11
11
  const DISCUSSION_BASE = `https://github.com/${REPO}/discussions/new`;
12
12
  const ISSUE_BASE = `https://github.com/${REPO}/issues/new`;
13
13
  function readCliVersion() {
@@ -16,6 +16,7 @@ import type { Command } from 'commander';
16
16
  import { probeAttachRail, type AttachRailLiveness, type UnreachableFallback } from './go.js';
17
17
  import { type ActiveSession } from '../lib/session/active.js';
18
18
  import { type SessionMeta } from '../lib/session/types.js';
19
+ export { looksLikeTmuxAlias, resolveTmuxAliasState, shouldAttachLocalTmuxAliasBeforeFleet, type TmuxAliasState } from '../lib/session/local-tmux-attach.js';
19
20
  import { type LiveStatusFilter } from './sessions.js';
20
21
  import { openSurfaces, type Backend } from '../lib/terminal/index.js';
21
22
  /** Options for `sessions focus` — device scope + the `--active` live-state filters. */
@@ -99,16 +100,6 @@ export declare function isAttachableLiveSession(session: ActiveSession): boolean
99
100
  * ("Session transcript not available (file no longer exists). Path: ").
100
101
  */
101
102
  export declare function dedupeSessionsByLogicalId(rows: SessionMeta[], self?: string): SessionMeta[];
102
- export type TmuxAliasState = 'not-an-alias' | 'no-server' | 'absent' | 'dead' | 'live';
103
- /** Shape of the tmux alias the CLI mints for an agent session: `ag-<agent>-<shortid>`.
104
- * Delegates to the one canonical matcher beside the name parsers in active.ts. */
105
- export declare function looksLikeTmuxAlias(selector: string): boolean;
106
- /**
107
- * Classify a selector against the live tmux server. Split from the attach so the
108
- * decision is testable against a real tmux server without replacing the caller's
109
- * shell (attaching is not something a test can undo).
110
- */
111
- export declare function resolveTmuxAliasState(selector: string, socket?: string): Promise<TmuxAliasState>;
112
103
  /**
113
104
  * The session to focus once an id/identity selector has resolved across the
114
105
  * fleet. Prefer the already-gathered candidate-pool row (it carries the live
@@ -17,8 +17,9 @@ import chalk from 'chalk';
17
17
  import { confirm } from '@inquirer/prompts';
18
18
  import { gatherLiveTargets, pickLiveTarget, pickLiveTargets, jumpTo, probeAttachRail, refuseFallback } from './go.js';
19
19
  import { sessionProcessIsLocal, sessionProcessHost, shortIdFromName } from '../lib/session/active.js';
20
- import { attachTmux, ensureSessionHookRepaired, getDefaultSocketPath, hasSession, runTmux } from '../lib/tmux/index.js';
21
20
  import { isAgentTmuxAlias } from '../lib/session/types.js';
21
+ import { attachLocalLiveSelector } from '../lib/session/local-tmux-attach.js';
22
+ export { looksLikeTmuxAlias, resolveTmuxAliasState, shouldAttachLocalTmuxAliasBeforeFleet } from '../lib/session/local-tmux-attach.js';
22
23
  import { buildSessionRecoveryCommand, filterSessionsByQuery, formatLiveStatusHeadline, formatPickerLabel, isRunningLiveSession, pickerColumnsFor, resumeSessionInPlace, resolveSessionMetadataValue, resolveSessionAgentName, requestedLiveStatuses, } from './sessions.js';
23
24
  import { resolveBackend, CONFIRM_THRESHOLD } from './sessions-resume.js';
24
25
  import { runOnPeer } from '../lib/session/remote-list.js';
@@ -221,6 +222,18 @@ export async function focusAction(id, opts) {
221
222
  process.exitCode = 1;
222
223
  return;
223
224
  }
225
+ // A live local tmux alias — or a bare 8-hex short id naming exactly one
226
+ // live local pane — attaches immediately, before any fleet SSH.
227
+ // `collectSessionCandidates` fans out twice (transcript pool + live
228
+ // roster), so a selector that is already a pane on THIS box used to print
229
+ // two unreachable-device lists and stall on offline peers (~2 min measured
230
+ // on yosemite-s0 for `sessions resume ag-claude-0145ab8f --attach-only`)
231
+ // and only then attach. SES-41 / PHNX-3292: an alive `ag-<agent>-<8hex>`
232
+ // pane is attached by name; the pane is sufficient, no fleet SSH needed. A
233
+ // dead/absent pane falls through to id resolution.
234
+ if (await attachLocalLiveSelector(textSelector, hosts)) {
235
+ return;
236
+ }
224
237
  // RUSH-2477: an id selector for a LOCAL indexed session resolves against the
225
238
  // WAL index alone — a plain read, no write-heavy discovery scan (none of
226
239
  // `tryClaimScan`/`releaseScan`'s `BEGIN IMMEDIATE` writer lock) and no
@@ -268,12 +281,6 @@ export async function focusAction(id, opts) {
268
281
  let exact = idSelector
269
282
  ? dedupeSessionsByLogicalId(sessions.filter((session) => session.id.toLowerCase().startsWith(idSelector)), self)
270
283
  : [];
271
- // A tmux alias naming a LIVE local session attaches directly. This must run
272
- // before the metadata resolver: that resolver treats an unmatched alias as a
273
- // keyword query, so `ag-kimi-632c1fbc` came back as 13 unrelated text hits
274
- // while the pane was alive and attachable the whole time.
275
- if (exact.length === 0 && textSelector && !hosts.length && await attachLiveTmuxAlias(textSelector))
276
- return;
277
284
  // Not live (or remote-scoped): an alias still carries the session's shortid,
278
285
  // so resolve by THAT rather than handing the whole `ag-<agent>-<shortid>`
279
286
  // string to the resolver — which treats an unmatched alias as a keyword
@@ -396,25 +403,6 @@ function looksLikeIdentitySelector(selector) {
396
403
  return !!selector && (/^[0-9a-f][0-9a-f-]{5,}$/i.test(selector) ||
397
404
  isAgentTmuxAlias(selector));
398
405
  }
399
- /**
400
- * Attach a live tmux session named exactly as the selector, without needing the
401
- * session index to know anything about it.
402
- *
403
- * SES-41 requires a `ag-<agent>-<8hex>` tmux alias to resolve, but the alias's
404
- * hex is the LAUNCH id, not the harness session id, and for a harness that
405
- * writes no `state/sessions/<pid>.json` record there is no mapping back to a
406
- * SessionMeta at all. Measured on yosemite-s0: `--resolve ag-kimi-632c1fbc`
407
- * fell through to a keyword search and returned 13 unrelated rows, while the
408
- * pane itself was alive (`%124`, `kimi-code`). The pane is the thing the user
409
- * asked for, and its NAME is sufficient to attach it — so an unattributable
410
- * session is still reachable instead of being a dead end that forces raw
411
- * `tmux -S … attach`.
412
- *
413
- * Returns false when this is not an alias, the server/session is absent, or
414
- * every pane is dead — the caller then continues to normal id resolution.
415
- * SES-39's "re-read `pane_dead` immediately before attach" is honoured here:
416
- * liveness is queried at attach time, not read from the roster.
417
- */
418
406
  /**
419
407
  * Rank two rows that are the same logical session, best first.
420
408
  *
@@ -447,59 +435,6 @@ export function dedupeSessionsByLogicalId(rows, self) {
447
435
  }
448
436
  return [...byId.values()];
449
437
  }
450
- /** Shape of the tmux alias the CLI mints for an agent session: `ag-<agent>-<shortid>`.
451
- * Delegates to the one canonical matcher beside the name parsers in active.ts. */
452
- export function looksLikeTmuxAlias(selector) {
453
- return isAgentTmuxAlias(selector);
454
- }
455
- /**
456
- * Classify a selector against the live tmux server. Split from the attach so the
457
- * decision is testable against a real tmux server without replacing the caller's
458
- * shell (attaching is not something a test can undo).
459
- */
460
- export async function resolveTmuxAliasState(selector, socket) {
461
- if (!looksLikeTmuxAlias(selector))
462
- return 'not-an-alias';
463
- const sock = socket ?? getDefaultSocketPath();
464
- if (!fs.existsSync(sock))
465
- return 'no-server';
466
- if (!(await hasSession(selector, sock).catch(() => false)))
467
- return 'absent';
468
- const panes = await runTmux({
469
- socket: sock,
470
- args: ['list-panes', '-t', `=${selector}`, '-F', '#{pane_dead}'],
471
- }).catch(() => undefined);
472
- const states = (panes?.stdout ?? '').split('\n').map(s => s.trim()).filter(Boolean);
473
- return states.some(d => d === '0') ? 'live' : 'dead';
474
- }
475
- async function attachLiveTmuxAlias(selector) {
476
- const socket = getDefaultSocketPath();
477
- if (await resolveTmuxAliasState(selector, socket) !== 'live')
478
- return false;
479
- if (!process.stdout.isTTY) {
480
- console.error(chalk.red(`"${selector}" is a live tmux session, but attaching needs a TTY.`));
481
- console.error(chalk.gray(` Run it from a terminal, or: agents tmux attach ${selector}`));
482
- process.exitCode = 1;
483
- return true;
484
- }
485
- // Already inside a tmux client on this socket — which is the normal state for
486
- // an interactive agent session here — MOVE that client instead of nesting a
487
- // second one. jumpTo (go.ts) does the same; attaching from inside tmux
488
- // otherwise stacks clients rather than taking you to the session.
489
- if (process.env.TMUX) {
490
- await runTmux({ socket, args: ['switch-client', '-t', `=${selector}`], throwOnError: false }).catch(() => { });
491
- console.log(chalk.gray(`Switched this tmux client to ${selector}.`));
492
- return true;
493
- }
494
- console.log(chalk.gray(`Attaching ${selector} — Ctrl-b d to detach.`));
495
- // Repair a legacy/stale pane-died hook before handing the session to an
496
- // attach client — the 5-min daemon reconcile that used to cover this was
497
- // deleted; attach-time repair is what closes the gap now (RUSH-2435).
498
- await ensureSessionHookRepaired(selector, socket);
499
- const code = await attachTmux({ socket, args: ['attach-session', '-t', `=${selector}`] });
500
- process.exitCode = code;
501
- return true;
502
- }
503
438
  function hasFocusFilters(opts, statuses) {
504
439
  return statuses.length > 0 || !!(opts.active || opts.local || opts.device?.length || focusOptionAgent(opts) ||
505
440
  opts.all || opts.teams || opts.inTeam || opts.routine || opts.project || opts.skill || opts.plugin ||