@phnx-labs/agents-cli 1.22.51 → 1.22.53

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 (117) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/attach.js +7 -0
  5. package/dist/commands/browser.js +118 -56
  6. package/dist/commands/daemon.d.ts +2 -0
  7. package/dist/commands/daemon.js +8 -4
  8. package/dist/commands/detach.js +1 -1
  9. package/dist/commands/exec.js +16 -9
  10. package/dist/commands/fleet-capture.js +7 -0
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +16 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +65 -6
  15. package/dist/commands/monitors.js +1 -1
  16. package/dist/commands/repo.js +31 -3
  17. package/dist/commands/sessions-inject.js +8 -3
  18. package/dist/commands/sessions-picker.js +2 -1
  19. package/dist/commands/sessions-resume.d.ts +1 -0
  20. package/dist/commands/sessions-resume.js +13 -2
  21. package/dist/commands/sessions-stop.js +1 -1
  22. package/dist/commands/sessions.d.ts +23 -13
  23. package/dist/commands/sessions.js +69 -39
  24. package/dist/commands/setup-browser.d.ts +5 -2
  25. package/dist/commands/setup-browser.js +14 -29
  26. package/dist/commands/setup-preferences.d.ts +22 -3
  27. package/dist/commands/setup-preferences.js +25 -8
  28. package/dist/commands/share.js +12 -8
  29. package/dist/commands/ssh.js +35 -12
  30. package/dist/commands/status.js +5 -0
  31. package/dist/commands/sync.js +102 -2
  32. package/dist/commands/tmux.d.ts +8 -1
  33. package/dist/commands/tmux.js +167 -17
  34. package/dist/lib/account-registry.d.ts +15 -5
  35. package/dist/lib/account-registry.js +150 -50
  36. package/dist/lib/answer-router.js +2 -1
  37. package/dist/lib/browser/ipc.d.ts +44 -0
  38. package/dist/lib/browser/ipc.js +120 -8
  39. package/dist/lib/browser/profiles.d.ts +57 -17
  40. package/dist/lib/browser/profiles.js +77 -53
  41. package/dist/lib/browser/registry.d.ts +44 -14
  42. package/dist/lib/browser/registry.js +141 -45
  43. package/dist/lib/browser/runtime-state.d.ts +4 -2
  44. package/dist/lib/browser/runtime-state.js +4 -2
  45. package/dist/lib/browser/service.js +4 -3
  46. package/dist/lib/channels/owner-forward.d.ts +88 -0
  47. package/dist/lib/channels/owner-forward.js +116 -0
  48. package/dist/lib/channels/owner-sink.js +7 -0
  49. package/dist/lib/daemon/runner.js +10 -2
  50. package/dist/lib/device-config.js +3 -2
  51. package/dist/lib/devices/config-migration.js +147 -1
  52. package/dist/lib/devices/device-docs.d.ts +35 -0
  53. package/dist/lib/devices/device-docs.js +163 -0
  54. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  55. package/dist/lib/devices/discovery-policy.js +31 -21
  56. package/dist/lib/devices/registry.d.ts +11 -5
  57. package/dist/lib/devices/registry.js +46 -18
  58. package/dist/lib/exec.d.ts +66 -28
  59. package/dist/lib/exec.js +71 -26
  60. package/dist/lib/feed/feed.d.ts +10 -2
  61. package/dist/lib/feed/feed.js +12 -1
  62. package/dist/lib/feed-broadcast.js +15 -1
  63. package/dist/lib/git.d.ts +93 -0
  64. package/dist/lib/git.js +232 -0
  65. package/dist/lib/hosts/dispatch.d.ts +4 -3
  66. package/dist/lib/hosts/dispatch.js +12 -8
  67. package/dist/lib/hosts/providers/local.d.ts +9 -3
  68. package/dist/lib/hosts/providers/local.js +23 -12
  69. package/dist/lib/hosts/reconnect.d.ts +7 -4
  70. package/dist/lib/hosts/reconnect.js +29 -25
  71. package/dist/lib/hosts/registry.js +4 -1
  72. package/dist/lib/hosts/remote-os.js +3 -1
  73. package/dist/lib/monitors/remote.d.ts +18 -1
  74. package/dist/lib/monitors/remote.js +15 -2
  75. package/dist/lib/notify.d.ts +7 -0
  76. package/dist/lib/notify.js +15 -1
  77. package/dist/lib/session/active.d.ts +10 -1
  78. package/dist/lib/session/active.js +7 -1
  79. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  80. package/dist/lib/session/actor-sidecar.js +2 -0
  81. package/dist/lib/session/db.d.ts +1 -1
  82. package/dist/lib/session/db.js +39 -3
  83. package/dist/lib/session/discover.js +7 -12
  84. package/dist/lib/session/live-metadata.js +1 -0
  85. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  86. package/dist/lib/session/local-tmux-attach.js +164 -0
  87. package/dist/lib/session/pid-registry.d.ts +7 -0
  88. package/dist/lib/session/prompt.d.ts +15 -0
  89. package/dist/lib/session/prompt.js +21 -0
  90. package/dist/lib/session/remote-active.d.ts +8 -0
  91. package/dist/lib/session/remote-active.js +1 -0
  92. package/dist/lib/session/types.d.ts +17 -0
  93. package/dist/lib/session/types.js +10 -0
  94. package/dist/lib/share/publish.d.ts +8 -11
  95. package/dist/lib/share/publish.js +16 -20
  96. package/dist/lib/share/worker-template.js +104 -12
  97. package/dist/lib/state.d.ts +8 -0
  98. package/dist/lib/state.js +143 -11
  99. package/dist/lib/sync-status.d.ts +17 -0
  100. package/dist/lib/sync-status.js +21 -2
  101. package/dist/lib/terminal/resolve.d.ts +7 -0
  102. package/dist/lib/terminal/resolve.js +41 -2
  103. package/dist/lib/tmux/index.d.ts +1 -1
  104. package/dist/lib/tmux/index.js +1 -1
  105. package/dist/lib/tmux/session.d.ts +10 -0
  106. package/dist/lib/tmux/session.js +29 -0
  107. package/dist/lib/traces/insights.d.ts +67 -0
  108. package/dist/lib/traces/insights.js +178 -0
  109. package/dist/lib/traces/phenotype.d.ts +67 -0
  110. package/dist/lib/traces/phenotype.js +437 -0
  111. package/dist/lib/traces/segments.d.ts +133 -0
  112. package/dist/lib/traces/segments.js +301 -0
  113. package/dist/lib/traces/sync.d.ts +33 -0
  114. package/dist/lib/traces/sync.js +11 -2
  115. package/dist/lib/types.d.ts +47 -1
  116. package/dist/lib/watchdog/runner.js +18 -4
  117. package/package.json +1 -1
@@ -826,27 +826,37 @@ export type MetadataResolveOutcome = {
826
826
  };
827
827
  /**
828
828
  * A match unique enough to resolve on the FIRST peer that returns it and cancel
829
- * the rest of the fleet sweep: a full session UUID, which is globally unique.
829
+ * the rest of the fleet sweep: a full session UUID (globally unique), a live
830
+ * tmux alias, or a bare 8-hex short id — all three name at most one session PER
831
+ * ANSWERING peer, so the first reachable hit is enough (PHNX-3292: the alias/
832
+ * short-id case is exactly what `agents tmux ls` prints, and it used to stay
833
+ * all-settle right alongside a genuine keyword search).
830
834
  *
831
- * Only a full UUID qualifies. A session **label** is deliberately NOT definitive
832
- * here even on an exact match: labels are free-form and can collide, so a
833
- * distinct session may carry the same label on a peer that has not answered yet
834
- * — early-exiting on the first would silently, and nondeterministically, resume
835
- * the wrong session (whichever peer replied first). Labels therefore stay
836
- * all-settle so a cross-machine label conflict surfaces as an ambiguity, and a
837
- * short-id PREFIX stays all-settle for the same reason (RUSH-2203).
835
+ * A session **label** is deliberately NOT definitive here even on an exact
836
+ * match: labels are free-form and can collide, so a distinct session may carry
837
+ * the same label on a peer that has not answered yet — early-exiting on the
838
+ * first would silently, and nondeterministically, resume the wrong session
839
+ * (whichever peer replied first). Labels therefore stay all-settle so a
840
+ * cross-machine label conflict surfaces as an ambiguity, and a short-id PREFIX
841
+ * *shorter* than the full 8-hex width stays all-settle for the same reason
842
+ * (RUSH-2203).
838
843
  *
839
844
  * This is about cancelling a sweep that is still IN FLIGHT, where a peer that
840
845
  * has not answered yet is still expected to. It is deliberately stricter than
841
846
  * `isUniqueEnoughSelector`, which decides what to do once the sweep is OVER and
842
- * a peer has definitively not answered — see SES-9a.
847
+ * a peer has definitively not answered — see SES-9a. A collision between two
848
+ * peers that BOTH answer before the abort lands is still caught by the ordinary
849
+ * uniqueness gate in `metadataResolveOutcome`/`fleetCandidatesByQuery`; only a
850
+ * peer that has not answered yet, at the moment of the FIRST hit, is the
851
+ * accepted risk (documented in the PHNX-3292 plan).
843
852
  */
844
853
  export declare function isDefinitiveMatch(session: SessionMeta, selector: string): boolean;
845
854
  /**
846
- * Whether a selector may enable early-exit on the cancellable fan-out — only a
847
- * full UUID, which is globally unique so the first hit is the only hit. Labels,
848
- * keywords, and short-id prefixes stay all-settle: their uniqueness (or
849
- * conflict) is only knowable once every peer has answered. See
855
+ * Whether a selector may enable early-exit on the cancellable fan-out: a full
856
+ * UUID, a tmux alias, or an exact 8-hex short id — every shape that is globally
857
+ * unique enough for the first hit to be the only hit worth waiting for. Labels,
858
+ * keywords, and short-id prefixes narrower than 8 hex stay all-settle: their
859
+ * uniqueness (or conflict) is only knowable once every peer has answered. See
850
860
  * {@link isDefinitiveMatch}.
851
861
  */
852
862
  export declare function selectorAllowsEarlyExit(selector: string): boolean;
@@ -18,10 +18,10 @@ import { sanitizeForTerminal, redactSecrets } from '../lib/redact.js';
18
18
  import { resolveProjectKey } from '../lib/project-key.js';
19
19
  import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
20
20
  import ora from 'ora';
21
- import { SESSION_AGENTS } from '../lib/session/types.js';
21
+ import { SESSION_AGENTS, isAgentTmuxAlias, sessionDisplayAgent } from '../lib/session/types.js';
22
22
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
23
23
  import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, composeWin32CommandLine } from '../lib/platform/index.js';
24
- import { getActiveSessions, describeActiveDiscoveryHealth, sessionProcessIsLocal, backfillActiveRowsFromIndex, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson } from '../lib/session/active.js';
24
+ import { getActiveSessions, describeActiveDiscoveryHealth, sessionProcessIsLocal, backfillActiveRowsFromIndex, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson, shortIdFromName } from '../lib/session/active.js';
25
25
  export { activeSessionProjectKey, backfillActiveRowsFromIndex, backfillActiveRowsFromMeta, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson } from '../lib/session/active.js';
26
26
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
27
27
  import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
@@ -233,9 +233,10 @@ async function renderArtifactsForSession(session, listAll, name) {
233
233
  console.log(chalk.gray('No file-write artifacts found in this session.'));
234
234
  return;
235
235
  }
236
- const agentColor = colorAgent(session.agent);
236
+ const shown = sessionDisplayAgent(session);
237
+ const agentColor = colorAgent(shown);
237
238
  console.log('');
238
- console.log(agentColor(session.agent) +
239
+ console.log(agentColor(shown) +
239
240
  chalk.gray(` · ${session.shortId} · ${formatRelativeTime(session.timestamp)}`));
240
241
  console.log(chalk.gray('─'.repeat(72)));
241
242
  for (const a of artifacts) {
@@ -726,7 +727,8 @@ function fitCell(content, room) {
726
727
  */
727
728
  export function renderActiveRowLines(s, indent, termW) {
728
729
  const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', ROW_ID_W));
729
- const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, ROW_AGENT_W), ROW_AGENT_W + 1));
730
+ const shownKind = sessionDisplayAgent({ agent: s.kind, harness: s.harness });
731
+ const kindCol = colorAgent(shownKind)(padToWidth(truncateToWidth(shownKind, ROW_AGENT_W), ROW_AGENT_W + 1));
730
732
  const versionCol = chalk.gray(padToWidth(truncateToWidth(s.version ?? '', ROW_VERSION_W), ROW_VERSION_W + 1));
731
733
  const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), ROW_STATUS_W - 1), ROW_STATUS_W));
732
734
  const ownerCol = chalk.cyan(padToWidth(truncateToWidth(ownerLabel(s), ROW_OWNER_W - 1), ROW_OWNER_W));
@@ -1010,6 +1012,7 @@ export function serializeResolvedSessionsJson(sessions) {
1010
1012
  id: session.id,
1011
1013
  shortId: session.shortId,
1012
1014
  agent: session.agent,
1015
+ harness: session.harness,
1013
1016
  origin: session.origin,
1014
1017
  timestamp: session.timestamp,
1015
1018
  lastActivity: session.lastActivity,
@@ -1805,7 +1808,8 @@ function runOutcomeDetail(meta) {
1805
1808
  /** Compact per-session metadata line under a linked session row. */
1806
1809
  function linkedSessionMeta(session) {
1807
1810
  const parts = [];
1808
- parts.push(session.version ? `${session.agent} v${session.version}` : session.agent);
1811
+ const shown = sessionDisplayAgent(session);
1812
+ parts.push(session.version ? `${shown} v${session.version}` : shown);
1809
1813
  if (session.account)
1810
1814
  parts.push(session.account);
1811
1815
  if (session.model)
@@ -2996,7 +3000,8 @@ function timeCell(age, topicSlack) {
2996
3000
  * when `showTicket` — otherwise a listing with no refs would waste a column of
2997
3001
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
2998
3002
  export function flatSessionRow(session, live, showTicket = false, cols = {}, bookmarked = false) {
2999
- const agentColor = colorAgent(session.agent);
3003
+ const shown = sessionDisplayAgent(session);
3004
+ const agentColor = colorAgent(shown);
3000
3005
  const age = sessionAgeParts(session.timestamp, session.lastActivity);
3001
3006
  const project = session.project || '-';
3002
3007
  const tag = originTag(session) || teamTag(session);
@@ -3045,7 +3050,7 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, boo
3045
3050
  const topicW = Math.max(MIN_TOPIC_W, width - fixedW - modelW - when.extraW);
3046
3051
  return (bookmarkCell +
3047
3052
  chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
3048
- agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
3053
+ agentColor(padToWidth(truncateToWidth(shown, 8), 9)) +
3049
3054
  chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
3050
3055
  (modelW ? chalk.yellow(padToWidth(truncateToWidth(modelLabel(session.model), modelW - 1), modelW)) : '') +
3051
3056
  machineCell +
@@ -3060,7 +3065,8 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, boo
3060
3065
  }
3061
3066
  /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
3062
3067
  function treeSessionRow(session, live) {
3063
- const agentColor = colorAgent(session.agent);
3068
+ const shown = sessionDisplayAgent(session);
3069
+ const agentColor = colorAgent(shown);
3064
3070
  const age = sessionAgeParts(session.timestamp, session.lastActivity);
3065
3071
  const tag = originTag(session) || teamTag(session);
3066
3072
  const label = session.label;
@@ -3082,7 +3088,7 @@ function treeSessionRow(session, live) {
3082
3088
  const topicW = Math.max(12, baseTopicW - when.extraW);
3083
3089
  return (' ' +
3084
3090
  chalk.dim(padToWidth(session.shortId, 9)) +
3085
- agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
3091
+ agentColor(padToWidth(truncateToWidth(shown, 7), 8)) +
3086
3092
  (badges ? badges + ' ' : '') +
3087
3093
  (glyph ? glyph + ' ' : '') +
3088
3094
  statusCell +
@@ -3246,7 +3252,8 @@ const TEAM_HANDLE_W = 16;
3246
3252
  * identity the `--teams` view exists to show (RUSH-1997).
3247
3253
  */
3248
3254
  function teamMemberRow(session, live) {
3249
- const agentColor = colorAgent(session.agent);
3255
+ const shown = sessionDisplayAgent(session);
3256
+ const agentColor = colorAgent(shown);
3250
3257
  const origin = session.teamOrigin;
3251
3258
  const age = sessionAgeParts(session.timestamp, session.lastActivity);
3252
3259
  const handle = safeTeamText(origin?.handle) ?? session.shortId;
@@ -3267,7 +3274,7 @@ function teamMemberRow(session, live) {
3267
3274
  const topicW = Math.max(12, baseTopicW - when.extraW);
3268
3275
  return (' ' +
3269
3276
  chalk.dim(padToWidth(session.shortId, 9)) +
3270
- agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
3277
+ agentColor(padToWidth(truncateToWidth(shown, 7), 8)) +
3271
3278
  chalk.yellow(padToWidth(truncateToWidth(mode || '-', TEAM_MODE_W), TEAM_MODE_W + 1)) +
3272
3279
  (badges ? badges + ' ' : '') +
3273
3280
  (glyph ? glyph + ' ' : '') +
@@ -3444,13 +3451,14 @@ function renderArchivedSession(session, mode, options = {}) {
3444
3451
  }, null, 2));
3445
3452
  return;
3446
3453
  }
3447
- const agentColor = colorAgent(session.agent);
3454
+ const shown = sessionDisplayAgent(session);
3455
+ const agentColor = colorAgent(shown);
3448
3456
  const absTime = formatAbsoluteTime(session.timestamp);
3449
3457
  const title = session.label || session.topic;
3450
3458
  console.log('');
3451
3459
  if (title)
3452
3460
  console.log(chalk.bold.white(title));
3453
- console.log(agentColor(session.agent) +
3461
+ console.log(agentColor(shown) +
3454
3462
  (session.version ? chalk.yellow(` ${session.version}`) : '') +
3455
3463
  (session.project ? chalk.cyan(` ${session.project}`) : '') +
3456
3464
  chalk.gray(` ${absTime} (${formatRelativeTime(session.timestamp)})`) +
@@ -3481,7 +3489,7 @@ async function renderSession(session, mode, filters, options = {}) {
3481
3489
  console.log(chalk.yellow('Session transcript not available (file no longer exists).'));
3482
3490
  console.log(chalk.gray(`Path: ${session.filePath}`));
3483
3491
  if (session.version)
3484
- console.log(chalk.gray(`Version: ${session.agent} ${session.version}`));
3492
+ console.log(chalk.gray(`Version: ${sessionDisplayAgent(session)} ${session.version}`));
3485
3493
  if (session.project)
3486
3494
  console.log(chalk.gray(`Project: ${session.project}`));
3487
3495
  if (session.account)
@@ -3489,11 +3497,12 @@ async function renderSession(session, mode, filters, options = {}) {
3489
3497
  console.log(chalk.gray(`Time: ${session.timestamp}`));
3490
3498
  return;
3491
3499
  }
3492
- const spinner = ora(`Parsing ${session.agent} session...`).start();
3500
+ const spinner = ora(`Parsing ${sessionDisplayAgent(session)} session...`).start();
3493
3501
  const parsedEvents = parseSession(session.filePath, session.agent);
3494
3502
  spinner.stop();
3495
3503
  let events = filterEvents(parsedEvents, filters);
3496
- const agentColor = colorAgent(session.agent);
3504
+ const shown = sessionDisplayAgent(session);
3505
+ const agentColor = colorAgent(shown);
3497
3506
  console.log('');
3498
3507
  if (mode === 'summary') {
3499
3508
  const stats = computeSummaryStats(events);
@@ -3507,7 +3516,7 @@ async function renderSession(session, mode, filters, options = {}) {
3507
3516
  const badges = signalBadges(metaSignals(session));
3508
3517
  console.log(chalk.bold.white(title) + (badges ? ' ' + badges : ''));
3509
3518
  }
3510
- console.log(agentColor(session.agent) +
3519
+ console.log(agentColor(shown) +
3511
3520
  (session.version ? chalk.yellow(` ${session.version}`) : '') +
3512
3521
  modelStr +
3513
3522
  (session.project ? chalk.cyan(` ${session.project}`) + branchStr : branchStr) +
@@ -3521,7 +3530,7 @@ async function renderSession(session, mode, filters, options = {}) {
3521
3530
  return;
3522
3531
  }
3523
3532
  if (mode === 'markdown') {
3524
- console.log(agentColor(session.agent) +
3533
+ console.log(agentColor(shown) +
3525
3534
  (session.version ? chalk.yellow(` ${session.version}`) : '') +
3526
3535
  (session.project ? chalk.cyan(` ${session.project}`) : '') +
3527
3536
  chalk.gray(` ${formatRelativeTime(session.timestamp)}`) +
@@ -3662,7 +3671,8 @@ export function liveHostLabel(a) {
3662
3671
  return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
3663
3672
  }
3664
3673
  export function formatPickerLabel(s, query, cols = {}, ssh, host = '', bookmarked = false, live) {
3665
- const agentColor = colorAgent(s.agent);
3674
+ const shown = sessionDisplayAgent(s);
3675
+ const agentColor = colorAgent(shown);
3666
3676
  const age = sessionAgeParts(s.timestamp, s.lastActivity);
3667
3677
  const project = s.project || '-';
3668
3678
  // SSH-launch origin (live rows only): mirrors the flat listing's `ssh←<device>`
@@ -3727,7 +3737,7 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', bookmarke
3727
3737
  // live row with no session id is named by its pid or cloud task, which can
3728
3738
  // run past the column and shunt every later column out of alignment.
3729
3739
  chalk.white(padRight(truncate(s.shortId, 9), 10)) +
3730
- agentColor(padRight(truncate(s.agent, 8), 9)) +
3740
+ agentColor(padRight(truncate(shown, 8), 9)) +
3731
3741
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
3732
3742
  machineCell +
3733
3743
  hostCell +
@@ -4579,35 +4589,53 @@ function modeFlag(mode) {
4579
4589
  return undefined; // summary — the peer's default render
4580
4590
  }
4581
4591
  const FULL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4592
+ /** Width of `SessionMeta.shortId` / the hex a tmux alias embeds (`deriveShortId`). */
4593
+ const SHORT_SESSION_ID_RE = /^[0-9a-f]{8}$/i;
4582
4594
  /**
4583
4595
  * A match unique enough to resolve on the FIRST peer that returns it and cancel
4584
- * the rest of the fleet sweep: a full session UUID, which is globally unique.
4596
+ * the rest of the fleet sweep: a full session UUID (globally unique), a live
4597
+ * tmux alias, or a bare 8-hex short id — all three name at most one session PER
4598
+ * ANSWERING peer, so the first reachable hit is enough (PHNX-3292: the alias/
4599
+ * short-id case is exactly what `agents tmux ls` prints, and it used to stay
4600
+ * all-settle right alongside a genuine keyword search).
4585
4601
  *
4586
- * Only a full UUID qualifies. A session **label** is deliberately NOT definitive
4587
- * here even on an exact match: labels are free-form and can collide, so a
4588
- * distinct session may carry the same label on a peer that has not answered yet
4589
- * — early-exiting on the first would silently, and nondeterministically, resume
4590
- * the wrong session (whichever peer replied first). Labels therefore stay
4591
- * all-settle so a cross-machine label conflict surfaces as an ambiguity, and a
4592
- * short-id PREFIX stays all-settle for the same reason (RUSH-2203).
4602
+ * A session **label** is deliberately NOT definitive here even on an exact
4603
+ * match: labels are free-form and can collide, so a distinct session may carry
4604
+ * the same label on a peer that has not answered yet — early-exiting on the
4605
+ * first would silently, and nondeterministically, resume the wrong session
4606
+ * (whichever peer replied first). Labels therefore stay all-settle so a
4607
+ * cross-machine label conflict surfaces as an ambiguity, and a short-id PREFIX
4608
+ * *shorter* than the full 8-hex width stays all-settle for the same reason
4609
+ * (RUSH-2203).
4593
4610
  *
4594
4611
  * This is about cancelling a sweep that is still IN FLIGHT, where a peer that
4595
4612
  * has not answered yet is still expected to. It is deliberately stricter than
4596
4613
  * `isUniqueEnoughSelector`, which decides what to do once the sweep is OVER and
4597
- * a peer has definitively not answered — see SES-9a.
4614
+ * a peer has definitively not answered — see SES-9a. A collision between two
4615
+ * peers that BOTH answer before the abort lands is still caught by the ordinary
4616
+ * uniqueness gate in `metadataResolveOutcome`/`fleetCandidatesByQuery`; only a
4617
+ * peer that has not answered yet, at the moment of the FIRST hit, is the
4618
+ * accepted risk (documented in the PHNX-3292 plan).
4598
4619
  */
4599
4620
  export function isDefinitiveMatch(session, selector) {
4600
- return FULL_SESSION_ID_RE.test(selector) && session.id.toLowerCase() === selector.trim().toLowerCase();
4621
+ const trimmed = selector.trim();
4622
+ if (FULL_SESSION_ID_RE.test(trimmed)) {
4623
+ return session.id.toLowerCase() === trimmed.toLowerCase();
4624
+ }
4625
+ const shortId = shortIdFromName(trimmed) ?? (SHORT_SESSION_ID_RE.test(trimmed) ? trimmed : undefined);
4626
+ return !!shortId && session.shortId.toLowerCase() === shortId.toLowerCase();
4601
4627
  }
4602
4628
  /**
4603
- * Whether a selector may enable early-exit on the cancellable fan-out — only a
4604
- * full UUID, which is globally unique so the first hit is the only hit. Labels,
4605
- * keywords, and short-id prefixes stay all-settle: their uniqueness (or
4606
- * conflict) is only knowable once every peer has answered. See
4629
+ * Whether a selector may enable early-exit on the cancellable fan-out: a full
4630
+ * UUID, a tmux alias, or an exact 8-hex short id — every shape that is globally
4631
+ * unique enough for the first hit to be the only hit worth waiting for. Labels,
4632
+ * keywords, and short-id prefixes narrower than 8 hex stay all-settle: their
4633
+ * uniqueness (or conflict) is only knowable once every peer has answered. See
4607
4634
  * {@link isDefinitiveMatch}.
4608
4635
  */
4609
4636
  export function selectorAllowsEarlyExit(selector) {
4610
- return FULL_SESSION_ID_RE.test(selector);
4637
+ const trimmed = selector.trim();
4638
+ return FULL_SESSION_ID_RE.test(trimmed) || isAgentTmuxAlias(trimmed) || SHORT_SESSION_ID_RE.test(trimmed);
4611
4639
  }
4612
4640
  /** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
4613
4641
  * local lookups, then group copies by logical session id. Synced mirrors of one
@@ -4725,9 +4753,11 @@ export function metadataResolveOutcome(localMatches, remote, selector) {
4725
4753
  // 6-char word like `facade` or `decade` is not treated as an identifier.
4726
4754
  // A label stays fail-closed at any length: labels are free-form and collide by
4727
4755
  // design, so one hit there really is a guess.
4728
- // (RUSH-2203's early-exit stays UUID-only — see isDefinitiveMatch — because
4729
- // that cancels the sweep mid-flight, where a peer that has not answered YET is
4730
- // still expected to answer. Here the sweep is already over.)
4756
+ // (RUSH-2203's early-exit — see isDefinitiveMatch — cancels the sweep
4757
+ // mid-flight, where a peer that has not answered YET is still expected to
4758
+ // answer, so it stays narrower than this post-sweep gate even though
4759
+ // PHNX-3292 widened it past UUID to alias/8-hex too. Here the sweep is
4760
+ // already over.)
4731
4761
  if (isUniqueEnoughSelector(selector) && candidates.length === 1) {
4732
4762
  return { kind: 'resolved', session: candidates[0].hits[0].session };
4733
4763
  }
@@ -10,8 +10,11 @@
10
10
  import type { Command } from 'commander';
11
11
  /**
12
12
  * Interactive browser setup. Returns true if a usable default profile exists
13
- * afterwards, false if the machine has no supported browser or the user backed
14
- * out. Never throws on cancel — the `agents setup` hub relies on that.
13
+ * afterwards, false if none exists / the user backed out. Non-interactively it
14
+ * only RECOGNIZES an existing profile — it never creates one (PHNX-3296), since
15
+ * silently minting a logged-out `auto-chrome` on a headless box is the exact bug
16
+ * that removed; a headless box gets its browser from the fleet hub. Never throws
17
+ * on cancel — the `agents setup` hub relies on that.
15
18
  */
16
19
  export declare function runBrowserWizard(): Promise<boolean>;
17
20
  /** Register `agents setup browser` under the parent `setup` command. */
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import chalk from 'chalk';
11
11
  import { setConfigValue } from '../lib/device-config.js';
12
- import { findFirstInstalledBrowser, listInstalledBrowsers } from '../lib/browser/chrome.js';
12
+ import { listInstalledBrowsers } from '../lib/browser/chrome.js';
13
13
  import { DEFAULT_BROWSER_PROFILE_NAME, getAutoDetectedProfile, createProfile, findFreeProfilePort, getConfiguredDefaultProfileName, } from '../lib/browser/profiles.js';
14
14
  import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
15
15
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -18,25 +18,27 @@ const INSTALL_HINT = 'Install one of: Google Chrome, Brave, Microsoft Edge, Chro
18
18
  '(Safari and Firefox are not supported — agents browser drives over the Chrome DevTools Protocol.)';
19
19
  /**
20
20
  * Interactive browser setup. Returns true if a usable default profile exists
21
- * afterwards, false if the machine has no supported browser or the user backed
22
- * out. Never throws on cancel — the `agents setup` hub relies on that.
21
+ * afterwards, false if none exists / the user backed out. Non-interactively it
22
+ * only RECOGNIZES an existing profile — it never creates one (PHNX-3296), since
23
+ * silently minting a logged-out `auto-chrome` on a headless box is the exact bug
24
+ * that removed; a headless box gets its browser from the fleet hub. Never throws
25
+ * on cancel — the `agents setup` hub relies on that.
23
26
  */
24
27
  export async function runBrowserWizard() {
25
28
  if (!isInteractiveTerminal()) {
26
- // Non-interactive: do the safe, deterministic thing (auto-detect + pin a
27
- // default) without prompting, or print the install hint and bail.
28
- if (!findFirstInstalledBrowser()) {
29
- console.error(chalk.red('No supported browser found.\n') + chalk.dim(INSTALL_HINT));
30
- return false;
31
- }
29
+ // Non-interactive / headless: recognize an existing default, but NEVER mint
30
+ // one. Silently auto-detecting an installed browser and creating an
31
+ // `auto-chrome` here is exactly the bug PHNX-3296 removed — a headless box
32
+ // gets its browser from the fleet hub (`browser.device`), or from an
33
+ // interactive pick later.
32
34
  const existing = await getAutoDetectedProfile();
33
35
  if (existing) {
34
36
  console.log(chalk.dim(`Browser profile "${existing.name}" already exists.`));
35
37
  return true;
36
38
  }
37
- const created = await createAutoDefault();
38
- console.log(chalk.green(`Created browser profile "${created.name}" → ${created.browser}.`));
39
- return true;
39
+ console.log(chalk.dim('No default browser profile on this machine. Re-run `agents setup browser` in an ' +
40
+ 'interactive terminal to pick one, or use the fleet hub: agents config set browser.device <host>.'));
41
+ return false;
40
42
  }
41
43
  const installed = listInstalledBrowsers();
42
44
  if (installed.length === 0) {
@@ -86,23 +88,6 @@ export async function runBrowserWizard() {
86
88
  printOnboardingNextStep(profile.name);
87
89
  return true;
88
90
  }
89
- /** Build + persist a `default` profile pinned to the first installed browser. */
90
- async function createAutoDefault() {
91
- const detected = findFirstInstalledBrowser();
92
- if (!detected)
93
- throw new Error('No supported browser found.');
94
- const freePort = await findFreeProfilePort();
95
- const profile = {
96
- name: DEFAULT_BROWSER_PROFILE_NAME,
97
- description: `Auto-detected ${detected.browserType} profile`,
98
- browser: detected.browserType,
99
- binary: detected.binary,
100
- endpoints: [`cdp://127.0.0.1:${freePort}`],
101
- viewport: { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height },
102
- };
103
- await createProfile(profile);
104
- return profile;
105
- }
106
91
  /** Offer to make `name` this machine's default browser profile (device-local). */
107
92
  async function maybeSetDeviceDefault(name, confirm) {
108
93
  const current = getConfiguredDefaultProfileName();
@@ -40,10 +40,29 @@ export declare function maybePickInteractiveHost(): Promise<boolean>;
40
40
  * Offer to pin this machine's default browser profile to a chosen browser.
41
41
  * Only asked when there is nothing to preserve: no configured device default
42
42
  * AND no existing `default` profile (an existing profile is the user's earlier
43
- * choice — never re-pinned behind their back). Returns true when a profile was
44
- * created and set as the device default. Silent no-op non-TTY.
43
+ * choice — never re-pinned behind their back). The list is the installed
44
+ * Chromium-family browsers plus a "None — this box uses the fleet hub" opt-out.
45
+ *
46
+ * This is the ONE place a default browser is chosen for a machine (PHNX-3296):
47
+ * a bare `agents browser start` no longer auto-detects and mints one, so picking
48
+ * here (or `agents browser use` / `agents setup browser` later) is how a box
49
+ * gets a local default at all. Picking "None" leaves it to the fleet hub
50
+ * (`browser.device`). Returns true when a profile was created and set as the
51
+ * device default. Silent no-op non-TTY — a headless box relies on the hub.
45
52
  */
46
- export declare function maybePickBrowserProfile(): Promise<boolean>;
53
+ export declare function maybePickBrowserProfile(deps?: {
54
+ /** Force interactivity in a test; defaults to a real TTY probe. */
55
+ interactive?: boolean;
56
+ /** Inject the prompt so the pick is testable without a TTY. */
57
+ select?: (config: {
58
+ message: string;
59
+ default?: string;
60
+ choices: Array<{
61
+ name: string;
62
+ value: string;
63
+ }>;
64
+ }) => Promise<string>;
65
+ }): Promise<boolean>;
47
66
  /**
48
67
  * The bare-`agents setup` preferences step: interactive host, then browser.
49
68
  * Runs AFTER the capability hub so it never delays the bootstrap. Never
@@ -80,11 +80,19 @@ export async function maybePickInteractiveHost() {
80
80
  * Offer to pin this machine's default browser profile to a chosen browser.
81
81
  * Only asked when there is nothing to preserve: no configured device default
82
82
  * AND no existing `default` profile (an existing profile is the user's earlier
83
- * choice — never re-pinned behind their back). Returns true when a profile was
84
- * created and set as the device default. Silent no-op non-TTY.
83
+ * choice — never re-pinned behind their back). The list is the installed
84
+ * Chromium-family browsers plus a "None — this box uses the fleet hub" opt-out.
85
+ *
86
+ * This is the ONE place a default browser is chosen for a machine (PHNX-3296):
87
+ * a bare `agents browser start` no longer auto-detects and mints one, so picking
88
+ * here (or `agents browser use` / `agents setup browser` later) is how a box
89
+ * gets a local default at all. Picking "None" leaves it to the fleet hub
90
+ * (`browser.device`). Returns true when a profile was created and set as the
91
+ * device default. Silent no-op non-TTY — a headless box relies on the hub.
85
92
  */
86
- export async function maybePickBrowserProfile() {
87
- if (!isInteractiveTerminal())
93
+ export async function maybePickBrowserProfile(deps = {}) {
94
+ const interactive = deps.interactive ?? isInteractiveTerminal();
95
+ if (!interactive)
88
96
  return false;
89
97
  if (getConfiguredDefaultProfileName())
90
98
  return false;
@@ -93,15 +101,24 @@ export async function maybePickBrowserProfile() {
93
101
  const installed = listInstalledBrowsers();
94
102
  if (installed.length === 0)
95
103
  return false;
96
- const { select } = await import('@inquirer/prompts');
97
- const picked = await select({
104
+ const config = {
98
105
  message: 'Which browser should agents drive on THIS machine?',
99
106
  default: defaultBrowserChoice(installed) ?? SKIP,
100
107
  choices: [
101
108
  ...installed.map((b) => ({ name: `${b.browserType} ${chalk.dim(b.binary)}`, value: b.browserType })),
102
- { name: `Skip ${chalk.dim('— auto-detect on first use (priority order)')}`, value: SKIP },
109
+ { name: `None ${chalk.dim('— this box uses the fleet hub (agents config set browser.device <host>)')}`, value: SKIP },
103
110
  ],
104
- });
111
+ };
112
+ // The prompt is the only non-testable boundary here, so it is injectable —
113
+ // everything after (createProfile, setConfigValue) runs for real.
114
+ let picked;
115
+ if (deps.select) {
116
+ picked = await deps.select(config);
117
+ }
118
+ else {
119
+ const { select } = await import('@inquirer/prompts');
120
+ picked = await select(config);
121
+ }
105
122
  if (picked === SKIP)
106
123
  return false;
107
124
  const chosen = installed.find((b) => b.browserType === picked) ?? installed[0];
@@ -22,6 +22,8 @@ export function formatSharePublishResult(result, json = false) {
22
22
  if (json)
23
23
  return JSON.stringify(result, null, 2);
24
24
  const lines = [chalk.green(result.url)];
25
+ if (result.slug)
26
+ lines.push(chalk.dim(` slug: ${result.slug}`));
25
27
  if (result.label) {
26
28
  const hint = result.labelSource === 'derived' ? chalk.dim(' (derived — pass --label to set one)') : '';
27
29
  lines.push(chalk.dim(` "${result.label}"`) + hint);
@@ -436,7 +438,7 @@ export function registerShareCommands(artifactsCmd) {
436
438
  .command('share')
437
439
  .description('Publish an HTML file to a shareable link — managed if signed in, otherwise your Cloudflare R2.')
438
440
  .argument('[file]', 'file to publish (HTML or any static asset)')
439
- .option('--slug <slug>', 'custom URL slug under your namespace (default: <feature>-<16hex>)')
441
+ .option('--slug <slug>', 'URL slug override (default: stable slug of the artifact title, then filename)')
440
442
  .option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config; ignored on the managed endpoint)')
441
443
  .option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
442
444
  .addOption(new Option('--visibility <level>', 'public | unlisted | me | org (default public). unlisted is a capability URL; me/org require a Phoenix session and are hidden from the gallery')
@@ -497,17 +499,17 @@ export function registerShareCommands(artifactsCmd) {
497
499
  # Hide from the public gallery (direct URL still works, noindex) and expire sooner
498
500
  agents artifacts share ./out/report.html --visibility unlisted --expire 12h
499
501
 
500
- # Permanent public page (opt out of the default 30d expiry)
501
- agents artifacts share ./out/landing.html --slug landing --expire never
502
+ # Permanent public page (the slug is derived from its title)
503
+ agents artifacts share ./out/landing.html --expire never
502
504
 
503
- # Custom slug, expiring in 7 days
505
+ # Optional slug override
504
506
  agents artifacts share ./out/report.html --slug q3-report --expire 7d
505
507
 
506
508
  # Human title + structured metadata, shown in the gallery and share list
507
509
  agents artifacts share ./out/plan.html --label "Q3 fleet plan" --meta kind=plan --meta ticket=RUSH-2683
508
510
 
509
- # Republish without keeping the previous version as a revision
510
- agents artifacts share ./out/plan.html --slug q3-report --no-revision
511
+ # Republish the same title to the same URL without retaining a revision
512
+ agents artifacts share ./out/plan.html --no-revision
511
513
  ${SHARE_DELETE_EXAMPLES}
512
514
  # One-time setup (or join an existing endpoint)
513
515
  agents artifacts setup
@@ -519,8 +521,10 @@ ${SHARE_DELETE_EXAMPLES}
519
521
  notes: `
520
522
  Signed-in users publish to the managed endpoint (share.agents-cli.sh/<handle>/…)
521
523
  with the Phoenix session — no Cloudflare account, bucket, or write token. The
522
- handle is the local-part of the signed-in email; the default slug is
523
- <readable>-<16hex>. Without a session, the existing BYO Cloudflare path still
524
+ handle is the local-part of the signed-in email. The default slug is derived
525
+ deterministically from the HTML <title> or Markdown frontmatter title, then
526
+ the filename, so republishing the same title updates the same URL. --slug is
527
+ an optional override. Without a session, the existing BYO Cloudflare path still
524
528
  applies (setup / join). HTML is stored as one object: local images are inlined
525
529
  and Chrome-saved file:// TOC links become in-page hashes.
526
530