@phnx-labs/agents-cli 1.22.51 → 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 (59) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/commands/attach.js +7 -0
  3. package/dist/commands/browser.js +118 -56
  4. package/dist/commands/daemon.d.ts +2 -0
  5. package/dist/commands/daemon.js +8 -4
  6. package/dist/commands/detach.js +1 -1
  7. package/dist/commands/focus.d.ts +1 -10
  8. package/dist/commands/focus.js +14 -79
  9. package/dist/commands/go.d.ts +26 -0
  10. package/dist/commands/go.js +63 -5
  11. package/dist/commands/monitors.js +1 -1
  12. package/dist/commands/repo.js +31 -3
  13. package/dist/commands/sessions-resume.d.ts +1 -0
  14. package/dist/commands/sessions-resume.js +13 -2
  15. package/dist/commands/sessions-stop.js +1 -1
  16. package/dist/commands/sessions.d.ts +23 -13
  17. package/dist/commands/sessions.js +40 -20
  18. package/dist/commands/setup-browser.d.ts +5 -2
  19. package/dist/commands/setup-browser.js +14 -29
  20. package/dist/commands/setup-preferences.d.ts +22 -3
  21. package/dist/commands/setup-preferences.js +25 -8
  22. package/dist/commands/share.js +12 -8
  23. package/dist/commands/status.js +5 -0
  24. package/dist/commands/sync.js +58 -2
  25. package/dist/commands/tmux.d.ts +8 -1
  26. package/dist/commands/tmux.js +167 -17
  27. package/dist/lib/browser/ipc.d.ts +44 -0
  28. package/dist/lib/browser/ipc.js +120 -8
  29. package/dist/lib/browser/profiles.d.ts +39 -17
  30. package/dist/lib/browser/profiles.js +51 -52
  31. package/dist/lib/browser/runtime-state.d.ts +4 -2
  32. package/dist/lib/browser/runtime-state.js +4 -2
  33. package/dist/lib/browser/service.js +4 -3
  34. package/dist/lib/channels/owner-forward.d.ts +88 -0
  35. package/dist/lib/channels/owner-forward.js +116 -0
  36. package/dist/lib/channels/owner-sink.js +7 -0
  37. package/dist/lib/exec.d.ts +8 -0
  38. package/dist/lib/exec.js +7 -0
  39. package/dist/lib/feed-broadcast.js +15 -1
  40. package/dist/lib/git.d.ts +93 -0
  41. package/dist/lib/git.js +232 -0
  42. package/dist/lib/monitors/remote.d.ts +18 -1
  43. package/dist/lib/monitors/remote.js +15 -2
  44. package/dist/lib/notify.d.ts +7 -0
  45. package/dist/lib/notify.js +15 -1
  46. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  47. package/dist/lib/session/local-tmux-attach.js +164 -0
  48. package/dist/lib/session/remote-active.d.ts +8 -0
  49. package/dist/lib/session/remote-active.js +1 -0
  50. package/dist/lib/share/publish.d.ts +8 -11
  51. package/dist/lib/share/publish.js +16 -20
  52. package/dist/lib/share/worker-template.js +99 -12
  53. package/dist/lib/sync-status.d.ts +17 -0
  54. package/dist/lib/sync-status.js +21 -2
  55. package/dist/lib/tmux/index.d.ts +1 -1
  56. package/dist/lib/tmux/index.js +1 -1
  57. package/dist/lib/tmux/session.d.ts +10 -0
  58. package/dist/lib/tmux/session.js +29 -0
  59. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,97 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.52
4
+
5
+ - **`agents artifacts share --visibility me|org` — identity-gated share pages (PHNX-3260).**
6
+ Extends the P1 `public|unlisted` tiers (RUSH-3135) with two Phoenix-gated GET
7
+ visibilities. `me` is visible only to the signed-in owner (the viewer's Phoenix
8
+ `userId` must equal the stamped `owner`); `org` is visible to any signed-in
9
+ Phoenix user whose **verified email domain** matches the owner's company domain
10
+ (`org_domain`, stamped from the owner's verified email on PUT). A mismatch on
11
+ either returns **404** — the same body as a missing object — so a wrong viewer
12
+ cannot even learn the page exists. Both are hidden from the public gallery and
13
+ `share list`, like `unlisted`. Publishing `org` from a public inbox
14
+ (gmail/googlemail/outlook/hotmail/live/icloud/me) is refused with **400**, and
15
+ `me`/`org` PUT without a Phoenix identity is a loud 400 (a BYO `WRITE_TOKEN`
16
+ alone cannot publish them). Browser GET of an unauthenticated `me`/`org` URL
17
+ **302**s to `${PHOENIX_ID_BASE}/login?return=<url>` (the same Google OAuth as
18
+ `/device`, no new IdP); the returned one-time `phoenix_ticket` is redeemed once
19
+ via `POST /api/v1/auth/ticket` and exchanged for an HttpOnly, HMAC-signed
20
+ `__Host-phoenix_share` cookie on the share host. An existing CLI
21
+ `Authorization: Bearer` still works with no redirect. `me`/`org` responses send
22
+ `Cache-Control: private, no-store` and `X-Robots-Tag: noindex`;
23
+ `public`/`unlisted` GET is unchanged (anonymous 200). Because the Worker template
24
+ changed, **already-provisioned BYO endpoints need `agents artifacts share update`**
25
+ to serve the new gate. Source: `cli/src/lib/share/worker-template.ts`,
26
+ `cli/src/lib/share/{backend,publish}.ts`, `cli/src/commands/share.ts`.
27
+
28
+ - **Harden the browser daemon against the socket-timeout wedge (PHNX-3289).** Four
29
+ fixes to the browser/daemon reliability surface. `agents daemon status` no longer
30
+ errors on macOS with `ps: etimes: keyword not found` — the process-uptime probe now
31
+ shells the portable `ps -o etime=` and parses it with the same helper the keychain
32
+ reaper uses. `waitForSocket` raises its ceiling from a flat 6s to 15s and re-probes
33
+ across an IPC-server restart (requiring two stable accepts), so a browser
34
+ start/navigate that lands in a restart window no longer intermittently throws
35
+ `Timeout waiting for browser daemon socket`. New `agents browser stop --daemon` stops
36
+ the browser daemon and clears a stale/wedged `browser.sock`, failing loud if a live
37
+ server still holds it, so the next `start` comes up clean. And `agents browser start
38
+ --device <host>` now resolves the browser/profile on the TARGET device instead of the
39
+ local one — a bare start from a browserless box forwards to `--device` instead of
40
+ failing with a misleading `No supported browser found`. Source:
41
+ `cli/src/commands/daemon.ts`, `cli/src/lib/browser/ipc.ts`, `cli/src/commands/browser.ts`.
42
+
43
+ - **Managed share: a BYO-published page no longer locks the rightful Phoenix owner out of their own handle (PHNX-3291).** The share Worker's handle-ownership check (`assertHandleOwner`) scanned every page under a namespace and 409'd `handle taken` when any page's stamped `owner` differed from the writer's Phoenix `userId`. But a BYO `WRITE_TOKEN` publish stamps `owner = SHARE_NAMESPACE` (the namespace string, e.g. `octocat`), not a userId — so once a namespace held any BYO page, its legitimate Phoenix handle owner got a 409 on every subsequent publish, and could neither publish nor change a page's `--visibility` (which blocked the me/org tiers from PHNX-3260 for that user entirely). The `__handles/<handle>` claim object is now authoritative when it exists: the recorded userId may write and anyone else is refused, regardless of a stray page's owner stamp. The page-owner scan remains as the pre-claim fallback but ignores BYO namespace stamps (`owner === handle`), so a first Phoenix publish can still claim a handle previously used only by BYO pages. Source: `cli/src/lib/share/worker-template.ts`. Requires `agents artifacts share update` on already-provisioned endpoints (the Worker template hash changed).
44
+
45
+ - **`sessions resume`/`sessions attach`/`sessions focus` no longer SSH-probe the fleet before attaching a live local tmux pane (PHNX-3292).** First unique match now wins: a selector naming a live pane on THIS box — the full `ag-<agent>-<8hex>` alias `agents tmux ls` prints, or its bare 8-hex suffix when it names exactly one live local pane — attaches with zero SSH, for bare `sessions resume <alias>` and deprecated `sessions attach <alias>` too, not just `--attach-only`. Previously `collectSessionCandidates` ran first — two fleet sweeps (transcript pool + live roster) that stall on offline devices (~2 minutes measured on yosemite-s0, two copies of `unreachable or no agents CLI — skipped`) — and bare `resume` of a live alias didn't even reach the tmux-attach path at all. A miss still races the reachable fleet in parallel: `isDefinitiveMatch`/`selectorAllowsEarlyExit` (RUSH-2203) now cover a tmux alias and an exact 8-hex short id, not just a full UUID, so the first peer to answer with that pane aborts the rest of the sweep instead of waiting out every offline box's timeout. Two LIVE panes (local or fleet-answered) sharing the same 8-hex suffix fail closed with both names printed, rather than guessing; a dead/retained pane is never attached. `--device` skips the local gate entirely and scopes the race to the named box(es). Source: `cli/src/lib/session/local-tmux-attach.ts` (new, `attachLocalLiveSelector`), `cli/src/commands/{sessions,sessions-resume,attach,focus}.ts`.
46
+
47
+ - **`agents tmux attach` tears the session down when the agent exits (PHNX-3293).**
48
+ Attaching to a wrapped pane and exiting the agent (`/exit`, Ctrl-c) used to
49
+ print `[detached]` and leave the tmux session in `tmux ls` — the v6 pane-died
50
+ hook only `detach-client`s when a client is attached, and the attach verbs
51
+ never killed the `remain-on-exit` husk. `tmux attach`, `sessions focus` /
52
+ `resume --attach-only`, and `go`'s local-tmux attach now destroy the session
53
+ when every pane is dead and still keep it on Ctrl-b d. A piped (no-TTY)
54
+ local `agents run --interactive` no longer wraps in tmux, so session-tracker
55
+ tests cannot leak live "trust this folder" panes. `agents tmux kill` with no
56
+ name opens a picker whose preview is the pane's last screen (so a leaked
57
+ first-run dialog is distinguishable from real work); `tmux ls` prints that
58
+ snippet on each row. Source:
59
+ `cli/src/lib/tmux/session.ts`, `cli/src/commands/tmux.ts`,
60
+ `cli/src/commands/focus.ts`, `cli/src/lib/exec.ts`.
61
+
62
+ - **`agents browser start` no longer silently mints a logged-out `auto-chrome` (PHNX-3296).** With no configured default and no existing launchable profile, start now errors and points at `agents setup` / `agents browser use <name>` / the `browser.device` fleet hub instead of popping a signed-out Chrome. A pre-existing `auto-chrome` or legacy `default` still resolves. `agents setup`'s browser pick replaces the misleading "auto-detect on first use" opt-out with an explicit "None — this box uses the fleet hub" choice. Source: `cli/src/lib/browser/profiles.ts`, `cli/src/commands/setup-preferences.ts`, `cli/src/commands/browser.ts`.
63
+
64
+ - **`sessions detach` / `sessions stop` of a unique live local session no longer wait for fleet SSH timeouts (PHNX-3298).** A live pane already visible locally is the hit: a full UUID or a unique 8-hex prefix skips `gatherRemoteActive` entirely, so a sleeping peer cannot print `unreachable or no agents CLI` or stall SIGTERM. Two local matches fail closed without waiting for unanswered boxes. A genuine miss still races the fleet and aborts remaining SSH on the first unique reachable live row; browse / `focus` with no id stays all-settle. Source: `cli/src/commands/go.ts`, `cli/src/commands/detach.ts`, `cli/src/commands/sessions-stop.ts`.
65
+
66
+ - **`agents monitors add` refuses immediately when a reachable peer already has the same watcher (PHNX-3299).** The fleet duplicate guard used to collect every peer before reporting a clash, so a single slow box forced the full 12-second timeout even when another peer had already answered with the same fingerprint. The fan-out now aborts remaining SSH captures the moment a peer reports a matching monitor, while the no-match path still waits for the whole fleet so uniqueness can be proved. Source: `cli/src/commands/monitors.ts`, `cli/src/lib/monitors/remote.ts`.
67
+
68
+ - **`agents repo sync user` self-heals a non-git or partial `~/.agents` by adopting it in place — no re-clone, no data loss (PHNX-3301).** When the user-layer checkout lost (or never had) its `.git`, sync hard-failed with `Not a git repo: ~/.agents` and the only fix was a destructive re-clone that wiped runtime state (`.cache` / `.history` / `scratch` / device config). It now git-backs the existing directory in place with plumbing only (`init` / `remote` / `fetch` / `update-ref` / `read-tree` / `checkout-index` / `restore`, so it never trips the fleet git-guard): materializes only the MISSING tracked files, restores a stale-stub top-level `agents.yaml` from origin (backing up the previous copy to `.history/agents.yaml.pre-adopt.bak` first), preserves gitignored runtime state, and surfaces (never clobbers) real local edits. The remote URL is resolved from an existing `origin`, `AGENTS_USER_REPO_URL`, or a device-local record left by a prior healthy sync — never hardcoded. `agents sync status` now flags a non-git `~/.agents` as a distinct drift state instead of burying it as "N missing". Source: `cli/src/lib/git.ts`, `cli/src/commands/sync.ts`, `cli/src/commands/repo.ts`, `cli/src/lib/sync-status.ts`, `cli/src/commands/status.ts`.
69
+
70
+ - **Owner notifications forward to a capable fleet peer when this box can't deliver (PHNX-3303).**
71
+ The owner's delivery provider for the rush-backed channels (imessage / telegram /
72
+ slack / discord via the `rush` CLI) is macOS-only, so `agents feed post --level
73
+ important` / `--blocked`, `agents notify`, and monitor `notify` actions run from a
74
+ headless Linux worker used to record the post but fail to reach the owner with
75
+ `owner failed: … rush CLI not found on PATH`. They now hand the delivery over SSH to
76
+ a reachable macOS peer that DOES have the provider — the same reroute
77
+ `agents message` already uses — instead of stranding the important post. It is
78
+ best-effort: it never throws or blocks the post, only the macOS-only rush family
79
+ triggers a forward (a Linux-capable transport like openclaw-telegram stays local),
80
+ and when no capable peer is reachable the existing clean local error stands. A box
81
+ that received a forward never forwards onward (`AGENTS_OWNER_NO_FORWARD` guard).
82
+ Source: `cli/src/lib/channels/owner-forward.ts`, `cli/src/lib/notify.ts`,
83
+ `cli/src/lib/feed-broadcast.ts`.
84
+
85
+ - **`agents artifacts share <file>` now derives a stable slug automatically (PHNX-3310).**
86
+ The default comes from the HTML `<title>` or Markdown frontmatter `title:`, then
87
+ the filename, so repeat publishes of a long-running artifact update the same URL;
88
+ `--slug` remains an optional explicit override. Source:
89
+ `cli/src/lib/share/publish.ts`, `cli/src/commands/share.ts`.
90
+
91
+ - **The release attestation now shards its test suite across the fleet instead of pinning one box.** `release-attestation-produce.sh` — the full-suite run that mints a release's exact-tree attestation — ran the ~13k-test suite on a single auto-picked box at `--maxWorkers=2` (~880s measured, ~14 min per attestation). It now fans the suite across the fleet via `test.sh --shard N` by default, resolving N from the eligible workers `agents devices pick` reports (capped) and falling back to a single box only when fewer than two are eligible. The suite is throughput-bound, so this is ~1/N the wall time (~269s on one box → ~31s on nine). Each shard still runs vitest at `--maxWorkers=2 --retry=2`, so the RUSH-3015 per-box flake mitigation is unchanged — sharding adds machines, not per-box concurrency. New `--test-shard <n>` / `--test-devices a,b,c` overrides mirror `test.sh`. Source: `cli/scripts/release-attestation-produce.sh`.
92
+
93
+ - **Shared pages now show who shared them and — the point — a visibility cue (PHNX-3260 follow-up).** The share Worker injects a slim attribution bar at the top of every served HTML page: `Shared by <handle>`, `Made with <agent>`, the date, and a colour-coded **visibility chip** — 🔒 `Only you` (me), 🏢 `Anyone at <org>` (org), 🌐 `Public`, 🔗 `Unlisted` — so a viewer can tell at a glance who can see the page, the thing the raw served HTML never surfaced. All values come from metadata the Worker already stamps (`visibility`, `owner`/handle, `org_domain`, `agent`, `date`); no new publish-time metadata and no CLI change, so it ships with `agents artifacts share update`. Non-HTML assets (images, JSON, the OG cover) are served byte-for-byte; metadata is HTML-escaped; the rewritten body drops the now-stale R2 etag. Source: `cli/src/lib/share/worker-template.ts`.
94
+
3
95
  ## 1.22.51
4
96
 
5
97
  - **`publish-computer-helper-mac.sh` cuts the helper's own tag (PHNX-3228).**
@@ -3,6 +3,7 @@ import { runOnPeer } from '../lib/session/remote-list.js';
3
3
  import { sessionRecoveryPeer } from '../lib/session/recovery.js';
4
4
  import { resolveSessionMetadataValue, resumeSessionInPlace } from './sessions.js';
5
5
  import { readDetachRecord, clearDetachRecord, isHeadlessAlive } from '../lib/session/detached.js';
6
+ import { attachLocalLiveSelector } from '../lib/session/local-tmux-attach.js';
6
7
  export function registerAttachCommand(program) {
7
8
  // Deprecated: superseded by `agents sessions resume`, which detects the state
8
9
  // (live pane / headless / ended) instead of making the caller pick a verb.
@@ -19,6 +20,12 @@ export function registerAttachCommand(program) {
19
20
  });
20
21
  }
21
22
  export async function attachAction(id) {
23
+ // PHNX-3292: this deprecated spelling still gets the local-pane fast path —
24
+ // a live tmux alias/short id on THIS box attaches with zero SSH, matching
25
+ // `sessions resume`. `attach` takes no `--device`, so hosts is always empty.
26
+ if (await attachLocalLiveSelector(id.trim(), [])) {
27
+ return;
28
+ }
22
29
  const outcome = await resolveSessionMetadataValue(id);
23
30
  if (outcome.kind === 'partial') {
24
31
  // RUSH-2492: an unreachable peer is a warning, not a hard failure. The
@@ -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';
@@ -294,7 +294,7 @@ export function registerBrowserCommand(program) {
294
294
  # Create a Chrome profile pointed at a CDP endpoint
295
295
  agents browser profiles create work --browser chrome --endpoint http://localhost:9222
296
296
 
297
- # 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)
298
298
  agents browser start
299
299
 
300
300
  # Or pin to a specific profile
@@ -323,6 +323,11 @@ export function registerBrowserCommand(program) {
323
323
 
324
324
  # Close tabs the daemon's own reaper would have caught on its next 5-min tick
325
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
326
331
  `,
327
332
  notes: `
328
333
  Most agent workflows should use the 'browser' skill instead of raw subcommands.
@@ -363,7 +368,7 @@ export function buildBrowserUseChoices(profiles, installed, current) {
363
368
  export async function runBrowserUse(name, opts, interactive = isInteractiveTerminal()) {
364
369
  if (opts.unset || name === 'auto') {
365
370
  unsetConfigValue('browser.profile');
366
- 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.');
367
372
  return true;
368
373
  }
369
374
  let selectedName = name;
@@ -372,7 +377,7 @@ export async function runBrowserUse(name, opts, interactive = isInteractiveTermi
372
377
  if (!interactive) {
373
378
  console.log(current
374
379
  ? `Default browser profile (this machine): ${current}`
375
- : 'Default browser profile (this machine): auto-detect');
380
+ : 'Default browser profile (this machine): none — pick one with `agents browser use <name>`');
376
381
  const hub = getConfigValue('browser.device').value;
377
382
  if (hub) {
378
383
  console.log(isSelfHost(hub)
@@ -421,7 +426,7 @@ export async function runBrowserUse(name, opts, interactive = isInteractiveTermi
421
426
  function configureBrowserUseCommand(command, deprecated = false) {
422
427
  command
423
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.')
424
- .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)')
425
430
  .action(async (name, opts) => {
426
431
  if (deprecated) {
427
432
  console.warn(chalk.yellow('Deprecation: `agents browser profiles set-default` is replaced by `agents browser use`.'));
@@ -942,7 +947,7 @@ function registerProfilesCommands(browser) {
942
947
  (removed > 0 ? ` (and ${removed} cache dir${removed === 1 ? '' : 's'})` : ''));
943
948
  if (getConfiguredDefaultProfileName() === name) {
944
949
  console.error(`warning: "${name}" was this machine's default browser profile; ` +
945
- `\`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>`);
946
951
  }
947
952
  });
948
953
  profiles
@@ -1025,13 +1030,13 @@ function registerProfilesCommands(browser) {
1025
1030
  with \`agents browser profiles claim\` on the machine that hosts the browser.
1026
1031
 
1027
1032
  In \`list\`, the \`*\` marker means "this machine's configured default"
1028
- (\`agents browser start\` with no --profile). The auto-detected profile is
1029
- 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
1030
1035
  this machine is configured to use, resolved the same way by every command.
1031
1036
 
1032
1037
  \`prune\` only considers profiles this device declares. It never removes a
1033
- profile that is in use, the configured default, or the auto-detected
1034
- \`auto-chrome\`.
1038
+ profile that is in use, the configured default, or the \`auto-chrome\`
1039
+ profile a setup wizard created.
1035
1040
  `,
1036
1041
  });
1037
1042
  profiles
@@ -1371,8 +1376,8 @@ function registerTaskCommands(browser) {
1371
1376
  });
1372
1377
  browser
1373
1378
  .command('start')
1374
- .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.')
1375
- .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>`)')
1376
1381
  .option(TASK_OPTION_FLAG, 'Task name (auto-generated short id if omitted)')
1377
1382
  .option('--title <label>', 'Human label shown in `browser status` (defaults to first navigated host)')
1378
1383
  .option('-e, --endpoint <name>', 'Endpoint preset (defaults to the profile\'s default)')
@@ -1445,13 +1450,85 @@ function registerTaskCommands(browser) {
1445
1450
  return;
1446
1451
  }
1447
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
+ }
1448
1524
  // One resolution order for every command (RUSH-2709): `--profile default`
1449
1525
  // means the same profile here as it does in stop / status / navigate.
1450
1526
  // `start` is the one command that LAUNCHES, so its implicit path goes
1451
1527
  // through ensureDefaultBrowserProfile, which additionally verifies the
1452
- // resolved default can launch on THIS machine and regenerates it when it
1453
- // can't (the "Custom binary not found" repair) — a filter-only command
1454
- // 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.
1455
1532
  let profileName;
1456
1533
  try {
1457
1534
  profileName = await resolveProfileRefForStart(opts.profile);
@@ -1481,17 +1558,12 @@ function registerTaskCommands(browser) {
1481
1558
  process.exit(1);
1482
1559
  }
1483
1560
  }
1484
- // The `browser.device` hub default is applied above (bare/--profile, no
1485
- // --device). Here only an EXPLICIT --device remains — with `local`/`self` as
1486
- // the escape that forces a local run when a hub is configured.
1487
- const deviceName = opts.device === 'local' || opts.device === 'self' ? undefined : 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`).
1488
1566
  if (deviceName) {
1489
- const lowered = deviceName.toLowerCase();
1490
- if (lowered === 'all' || lowered === 'auto') {
1491
- console.error(`--device ${deviceName} is not valid on \`agents browser start\`: a task lives on one device.\n` +
1492
- `Next: agents browser start --task <name> --device <device>`);
1493
- process.exit(1);
1494
- }
1495
1567
  try {
1496
1568
  assertDeviceDeclaresProfile(deviceName, profileName);
1497
1569
  }
@@ -1499,36 +1571,6 @@ function registerTaskCommands(browser) {
1499
1571
  console.error(err instanceof Error ? err.message : String(err));
1500
1572
  process.exit(1);
1501
1573
  }
1502
- if (!isSelfHost(deviceName) && !isFleetRemoteInvocation()) {
1503
- try {
1504
- const result = await dispatchBrowserToDevice(deviceName, browserForwardedArgv(), 'capture');
1505
- process.stdout.write(result.stdout);
1506
- process.stderr.write(result.stderr);
1507
- if (result.code !== 0)
1508
- process.exit(result.code);
1509
- const taskName = opts.task || result.stdout.trim().split('\n').find((line) => line.length > 0);
1510
- if (!taskName) {
1511
- console.error(`Remote start on ${deviceName} produced no task name.`);
1512
- process.exit(1);
1513
- }
1514
- bindTask(taskName, {
1515
- device: deviceName,
1516
- profile: profileName,
1517
- url: opts.url,
1518
- sessionId: callerSessionId(),
1519
- launchId: callerLaunchId(),
1520
- createdAt: Date.now(),
1521
- });
1522
- if (!result.stderr.includes(`started on ${deviceName}`)) {
1523
- console.error(`Task "${taskName}" started on ${deviceName} (profile: ${profileName}).`);
1524
- }
1525
- }
1526
- catch (err) {
1527
- console.error(err instanceof Error ? err.message : String(err));
1528
- process.exit(1);
1529
- }
1530
- return;
1531
- }
1532
1574
  }
1533
1575
  // Grounded login guardrail: if the opening URL is a known login-gated
1534
1576
  // service and the resolved profile has no session for it, say so on stderr
@@ -1641,11 +1683,31 @@ function registerTaskCommands(browser) {
1641
1683
  });
1642
1684
  browser
1643
1685
  .command('stop')
1644
- .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')
1645
1687
  .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1646
1688
  .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1647
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")')
1648
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
+ }
1649
1711
  if (opts.profile) {
1650
1712
  const profile = (await resolveProfileRef(opts.profile)) ?? opts.profile;
1651
1713
  const response = await sendIPCRequest({
@@ -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));
@@ -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 ||