@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
@@ -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));
@@ -1569,15 +1569,19 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1569
1569
  });
1570
1570
  }
1571
1571
  }
1572
- // A network drop kills the local ssh client (exit 255) but the remote
1573
- // agent survives in its detached tmux session. With a known session id
1574
- // (Claude's forced id, a resumed run, or a non-Claude id we just
1575
- // resolved from the remote hook record) and a tmux-hosted run,
1576
- // re-attach the live pane automatically instead of exiting — the user
1577
- // never has to notice the drop and `agents sessions focus` by hand.
1578
- // `raw` runs aren't tmux wrapped, so there is nothing to reconnect to.
1579
- // For `run auto` prefer the join-resolved id (the harness the remote
1580
- // ACTUALLY picked) over the explicit --session-id only claude adopts.
1572
+ // A network drop kills the local ssh client (exit 255). What the
1573
+ // remote agent does next depends on the peer's tmux.enabled
1574
+ // (PHNX-3316): wrapped, it survives in its detached pane and the
1575
+ // reconnect rejoins it; bare (the default), the drop SIGHUPs it and
1576
+ // the reconnect resumes the session in place from disk. Either way,
1577
+ // with a known session id (Claude's forced id, a resumed run, or a
1578
+ // non-Claude id we just resolved from the remote hook record) we
1579
+ // reconnect automatically instead of exiting — the user never has
1580
+ // to notice the drop and `agents sessions focus` by hand.
1581
+ // `raw` runs opted out of all of this, so there is nothing to
1582
+ // reconnect to. For `run auto` prefer the join-resolved id (the
1583
+ // harness the remote ACTUALLY picked) over the explicit
1584
+ // --session-id only claude adopts.
1581
1585
  const { pickReconnectTarget, reconnectInteractiveSession, afterInteractiveRemoteExit, SSH_CONN_FAILURE } = await import('../lib/hosts/reconnect.js');
1582
1586
  const reconnectTarget = pickReconnectTarget({
1583
1587
  agent: runAgent,
@@ -1814,6 +1818,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1814
1818
  let accountConfigVersion;
1815
1819
  let profileProvider;
1816
1820
  let fromProfile = false;
1821
+ let profileName;
1817
1822
  let profileFallbackModel;
1818
1823
  let workflowModel;
1819
1824
  // WORKFLOW.md capability scoping, translated to Claude headless flags below.
@@ -1897,6 +1902,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1897
1902
  profileProvider = readProfile(rawAgent).provider;
1898
1903
  profileFallbackModel = resolved.fallbackModel;
1899
1904
  fromProfile = true;
1905
+ profileName = resolved.profileName;
1900
1906
  process.stderr.write(chalk.gray(`Resolved custom harness '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
1901
1907
  if (resolved.tierNote) {
1902
1908
  process.stderr.write(chalk.gray(`[agents] ${resolved.tierNote}\n`));
@@ -2745,6 +2751,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2745
2751
  }
2746
2752
  const execOptions = {
2747
2753
  agent,
2754
+ harnessName: profileName,
2748
2755
  version,
2749
2756
  configVersion: accountConfigVersion,
2750
2757
  prompt,
@@ -71,6 +71,13 @@ async function runCapture(opts) {
71
71
  secretsBundles: listBundles().map((b) => b.name),
72
72
  routines: listJobs().map((j) => j.name),
73
73
  };
74
+ // Discovery decisions and dismissals are device-scoped now (PHNX-3315): they
75
+ // live in each box's device doc and sync via the repo, so `capture` must NOT
76
+ // hoist the cross-box union back into the shared `agents.yaml` `fleet:` block
77
+ // (that would recreate the N-boxes-rewrite-one-map conflict Task A removed and
78
+ // re-arm the fold-then-delete migration on every capture). `meta.fleet` carries
79
+ // only the roster/defaults; any lingering central-legacy discovery/ignored is
80
+ // carried forward verbatim and drained by the migration.
74
81
  const next = captureFleet(meta.fleet, inputs);
75
82
  if (opts.dryRun) {
76
83
  console.log(chalk.gray('# agents.yaml fleet: block (dry run — not written)'));
@@ -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';
@@ -30,6 +31,7 @@ import { multiItemPicker } from '../lib/picker.js';
30
31
  import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
31
32
  import { shellQuote, assertValidSshTarget } from '../lib/ssh-exec.js';
32
33
  import { openSurfaces, currentContext, availableBackends, detectCurrentBackend, } from '../lib/terminal/index.js';
34
+ import { addressabilityRecoveryHint } from '../lib/terminal/resolve.js';
33
35
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
34
36
  import { setHelpSections } from '../lib/help.js';
35
37
  const INHERITED_FOCUS_OPTIONS = [
@@ -221,6 +223,18 @@ export async function focusAction(id, opts) {
221
223
  process.exitCode = 1;
222
224
  return;
223
225
  }
226
+ // A live local tmux alias — or a bare 8-hex short id naming exactly one
227
+ // live local pane — attaches immediately, before any fleet SSH.
228
+ // `collectSessionCandidates` fans out twice (transcript pool + live
229
+ // roster), so a selector that is already a pane on THIS box used to print
230
+ // two unreachable-device lists and stall on offline peers (~2 min measured
231
+ // on yosemite-s0 for `sessions resume ag-claude-0145ab8f --attach-only`)
232
+ // and only then attach. SES-41 / PHNX-3292: an alive `ag-<agent>-<8hex>`
233
+ // pane is attached by name; the pane is sufficient, no fleet SSH needed. A
234
+ // dead/absent pane falls through to id resolution.
235
+ if (await attachLocalLiveSelector(textSelector, hosts)) {
236
+ return;
237
+ }
224
238
  // RUSH-2477: an id selector for a LOCAL indexed session resolves against the
225
239
  // WAL index alone — a plain read, no write-heavy discovery scan (none of
226
240
  // `tryClaimScan`/`releaseScan`'s `BEGIN IMMEDIATE` writer lock) and no
@@ -268,12 +282,6 @@ export async function focusAction(id, opts) {
268
282
  let exact = idSelector
269
283
  ? dedupeSessionsByLogicalId(sessions.filter((session) => session.id.toLowerCase().startsWith(idSelector)), self)
270
284
  : [];
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
285
  // Not live (or remote-scoped): an alias still carries the session's shortid,
278
286
  // so resolve by THAT rather than handing the whole `ag-<agent>-<shortid>`
279
287
  // string to the resolver — which treats an unmatched alias as a keyword
@@ -396,25 +404,6 @@ function looksLikeIdentitySelector(selector) {
396
404
  return !!selector && (/^[0-9a-f][0-9a-f-]{5,}$/i.test(selector) ||
397
405
  isAgentTmuxAlias(selector));
398
406
  }
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
407
  /**
419
408
  * Rank two rows that are the same logical session, best first.
420
409
  *
@@ -447,59 +436,6 @@ export function dedupeSessionsByLogicalId(rows, self) {
447
436
  }
448
437
  return [...byId.values()];
449
438
  }
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
439
  function hasFocusFilters(opts, statuses) {
504
440
  return statuses.length > 0 || !!(opts.active || opts.local || opts.device?.length || focusOptionAgent(opts) ||
505
441
  opts.all || opts.teams || opts.inTeam || opts.routine || opts.project || opts.skill || opts.plugin ||
@@ -599,6 +535,7 @@ export async function focusSelectedSession(meta, active, self) {
599
535
  return;
600
536
  }
601
537
  console.log(chalk.yellow('This live session has no session id or living attach rail to focus.'));
538
+ console.log(chalk.gray(addressabilityRecoveryHint(active, meta.id)));
602
539
  process.exitCode = 1;
603
540
  return;
604
541
  }
@@ -29,6 +29,28 @@ export declare function filterLivePool(sessions: ActiveSession[], opts?: {
29
29
  hosts?: string[];
30
30
  statuses?: LiveStatusFilter[];
31
31
  }): ActiveSession[];
32
+ /**
33
+ * Live rows whose session id starts with `selector` (case-insensitive prefix).
34
+ * Same match `resolveOne` uses, so skip-fleet and resolve stay aligned.
35
+ */
36
+ export declare function localLiveSelectorMatches(sessions: ActiveSession[], selector: string): ActiveSession[];
37
+ /**
38
+ * Skip `gatherRemoteActive` when local already answered the selector:
39
+ * two local matches fail closed without waiting for a sleeping peer that
40
+ * might collide; a single local hit skips only when the selector is a full
41
+ * UUID or unique-enough 8-hex (`isUniqueEnoughSelector`). A shorter unique
42
+ * local prefix still races the fleet so a remote collision can fail closed.
43
+ * Zero matches still race the fleet.
44
+ */
45
+ export declare function shouldSkipRemoteSweep(localMatches: ActiveSession[], selector: string): boolean;
46
+ /**
47
+ * First-hit abort predicate for a live-id fleet race. A full UUID is globally
48
+ * unique so the first exact hit is the only hit. A unique-enough live prefix
49
+ * (8+ chars, the tmux `ag-<agent>-<8hex>` short id) aborts remaining SSH once
50
+ * a reachable peer answers — unanswered boxes must not delay (PHNX-3298).
51
+ * Shorter prefixes stay all-settle.
52
+ */
53
+ export declare function isDefinitiveLiveMatch(session: ActiveSession, selector: string): boolean;
32
54
  /**
33
55
  * Live jump targets (local + remote), keyed by session id. Cloud is excluded by
34
56
  * default (it has no local pid to attach), but `detach` opts in with
@@ -38,11 +60,15 @@ export declare function filterLivePool(sessions: ActiveSession[], opts?: {
38
60
  * `hosts` scopes the sweep to named devices — the fan-out only dials them, and the
39
61
  * pool is then filtered to `s.machine ∈ hosts` so a stray local row can't leak in.
40
62
  * `statuses` narrows to the live-state words `--active` uses (orphan/crashed/…).
63
+ * `selector` (detach/stop) skips fleet SSH when local already has a unique live
64
+ * id or a local collision; omit it for browse (`focus` with no id) so the picker
65
+ * still all-settles.
41
66
  */
42
67
  export declare function gatherLiveTargets(local: boolean, opts?: {
43
68
  includeCloud?: boolean;
44
69
  hosts?: string[];
45
70
  statuses?: LiveStatusFilter[];
71
+ selector?: string;
46
72
  }): Promise<{
47
73
  self: string;
48
74
  activeById: Map<string, ActiveSession>;
@@ -18,21 +18,23 @@ import path from 'path';
18
18
  import { execFile } from 'child_process';
19
19
  import { promisify } from 'util';
20
20
  import { getActiveSessions, findSessionFileForKind, sessionProcessIsLocal, sessionProcessHost } from '../lib/session/active.js';
21
+ import { isSessionIdShape } from '../lib/session/pid-registry.js';
21
22
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
23
  import { discoverSessions } from '../lib/session/discover.js';
23
24
  import { deriveShortId } from '../lib/session/short-id.js';
24
- import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive, matchesLiveStatus, filterSessionsByQuery, formatPickerLabel, pickerColumnsFor, } from './sessions.js';
25
+ import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive, matchesLiveStatus, filterSessionsByQuery, formatPickerLabel, pickerColumnsFor, isUniqueEnoughSelector, } from './sessions.js';
25
26
  import { buildPreview } from './sessions-picker.js';
26
27
  import { multiItemPicker } from '../lib/picker.js';
27
28
  import { isPromptCancelled } from './utils.js';
28
29
  import { focusAction } from './focus.js';
29
30
  import { machineId } from '../lib/session/sync/config.js';
30
31
  import { attachTmux, runTmux } from '../lib/tmux/binary.js';
31
- import { ensureSessionHookRepaired, paneExitStatus } from '../lib/tmux/session.js';
32
+ import { ensureSessionHookRepaired, paneExitStatus, teardownIfAgentExited } from '../lib/tmux/session.js';
32
33
  import { getDefaultSocketPath } from '../lib/tmux/paths.js';
33
34
  import { sshExec, sshStream, assertValidSshTarget, shellQuote, SSH_CONN_FAILURE_CODE } from '../lib/ssh-exec.js';
34
35
  import { connectionEndedNotice } from '../lib/hosts/reconnect.js';
35
36
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
37
+ import { addressabilityRecoveryHint } from '../lib/terminal/resolve.js';
36
38
  const execFileAsync = promisify(execFile);
37
39
  export function registerGoCommand(program) {
38
40
  program
@@ -64,6 +66,50 @@ export function filterLivePool(sessions, opts = {}) {
64
66
  }
65
67
  return out;
66
68
  }
69
+ /**
70
+ * Live rows whose session id starts with `selector` (case-insensitive prefix).
71
+ * Same match `resolveOne` uses, so skip-fleet and resolve stay aligned.
72
+ */
73
+ export function localLiveSelectorMatches(sessions, selector) {
74
+ const q = selector.toLowerCase();
75
+ return sessions.filter((s) => (s.sessionId ?? '').toLowerCase().startsWith(q));
76
+ }
77
+ /**
78
+ * Skip `gatherRemoteActive` when local already answered the selector:
79
+ * two local matches fail closed without waiting for a sleeping peer that
80
+ * might collide; a single local hit skips only when the selector is a full
81
+ * UUID or unique-enough 8-hex (`isUniqueEnoughSelector`). A shorter unique
82
+ * local prefix still races the fleet so a remote collision can fail closed.
83
+ * Zero matches still race the fleet.
84
+ */
85
+ export function shouldSkipRemoteSweep(localMatches, selector) {
86
+ if (localMatches.length >= 2)
87
+ return true;
88
+ if (localMatches.length === 1)
89
+ return isUniqueEnoughSelector(selector);
90
+ return false;
91
+ }
92
+ /**
93
+ * First-hit abort predicate for a live-id fleet race. A full UUID is globally
94
+ * unique so the first exact hit is the only hit. A unique-enough live prefix
95
+ * (8+ chars, the tmux `ag-<agent>-<8hex>` short id) aborts remaining SSH once
96
+ * a reachable peer answers — unanswered boxes must not delay (PHNX-3298).
97
+ * Shorter prefixes stay all-settle.
98
+ */
99
+ export function isDefinitiveLiveMatch(session, selector) {
100
+ const id = (session.sessionId ?? '').toLowerCase();
101
+ const q = selector.trim().toLowerCase();
102
+ if (!id || !q)
103
+ return false;
104
+ if (isSessionIdShape(q))
105
+ return id === q;
106
+ return q.length >= 8 && id.startsWith(q);
107
+ }
108
+ function liveSelectorEarlyExit(selector) {
109
+ if (!selector || !isUniqueEnoughSelector(selector))
110
+ return undefined;
111
+ return { isDefinitive: (item) => isDefinitiveLiveMatch(item, selector) };
112
+ }
67
113
  /**
68
114
  * Live jump targets (local + remote), keyed by session id. Cloud is excluded by
69
115
  * default (it has no local pid to attach), but `detach` opts in with
@@ -73,6 +119,9 @@ export function filterLivePool(sessions, opts = {}) {
73
119
  * `hosts` scopes the sweep to named devices — the fan-out only dials them, and the
74
120
  * pool is then filtered to `s.machine ∈ hosts` so a stray local row can't leak in.
75
121
  * `statuses` narrows to the live-state words `--active` uses (orphan/crashed/…).
122
+ * `selector` (detach/stop) skips fleet SSH when local already has a unique live
123
+ * id or a local collision; omit it for browse (`focus` with no id) so the picker
124
+ * still all-settles.
76
125
  */
77
126
  export async function gatherLiveTargets(local, opts = {}) {
78
127
  const self = machineId();
@@ -81,9 +130,16 @@ export async function gatherLiveTargets(local, opts = {}) {
81
130
  if (!s.machine)
82
131
  s.machine = self;
83
132
  let active = localActive;
84
- if (!local) {
133
+ const skipRemote = local || (opts.selector
134
+ ? shouldSkipRemoteSweep(opts.hosts?.length
135
+ ? filterLivePool(localLiveSelectorMatches(localActive, opts.selector), { hosts: opts.hosts })
136
+ : localLiveSelectorMatches(localActive, opts.selector), opts.selector)
137
+ : false);
138
+ if (!skipRemote) {
85
139
  try {
86
- const remote = await gatherRemoteActive(opts.hosts);
140
+ const remote = await gatherRemoteActive(opts.hosts, {
141
+ earlyExit: liveSelectorEarlyExit(opts.selector),
142
+ });
87
143
  active = dedupeByMachineSession([...localActive, ...remote.sessions]);
88
144
  }
89
145
  catch { /* remote sweep is best-effort */ }
@@ -263,7 +319,7 @@ export async function refuseFallback(s, remote) {
263
319
  return;
264
320
  }
265
321
  console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
266
- chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
322
+ chalk.gray(`\n${addressabilityRecoveryHint(s)}`));
267
323
  process.exitCode = 1;
268
324
  }
269
325
  export async function jumpTo(s, self, fallback = refuseFallback) {
@@ -320,7 +376,10 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
320
376
  // attach-time repair is what closes the gap now (RUSH-2435).
321
377
  if (session)
322
378
  await ensureSessionHookRepaired(session, socket);
323
- process.exit(await attachTmux({ socket, args: ['attach-session', '-t', tgt] }));
379
+ const code = await attachTmux({ socket, args: ['attach-session', '-t', tgt] });
380
+ if (session)
381
+ await teardownIfAgentExited(session, socket);
382
+ process.exit(code);
324
383
  }
325
384
  // Path A: local Ghostty — focus its tab (Cmd+N via System Events).
326
385
  if (s.host === 'ghostty') {
@@ -358,8 +358,8 @@ async function guardAgainstDuplicateMonitor(config, force) {
358
358
  // different arguments (another PR) are not a clash and still pass.
359
359
  if (process.env[NO_MONITOR_FANOUT_ENV])
360
360
  return;
361
- const fleet = await gatherFleetMonitors();
362
361
  const mine = monitorFingerprint(config);
362
+ const fleet = await gatherFleetMonitors({ againstFingerprint: mine });
363
363
  const clash = fleet.monitors.find((r) => monitorFingerprint(r.monitor) === mine);
364
364
  if (clash) {
365
365
  stderrLine(chalk.red(`Monitor '${clash.monitor.name}' on ${chalk.bold(clash.machine)} already watches this exact source and fires the same action.`));
@@ -29,7 +29,7 @@ function resolveRepoPath(target) {
29
29
  return path.join(HOME, `.agents-${trimmed}`);
30
30
  }
31
31
  import { applyExtraAliasToVersions, ensureAgentsDir, getExtraRepoDir, getSystemAgentsDir, getUserAgentsDir, readMeta, resolveExtraRepoDir, updateMeta, } from '../lib/state.js';
32
- import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo, getRemoteUrl, sameGitRemote, displayHomePath, syncRepoGit, } from '../lib/git.js';
32
+ import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo, adoptUserRepoIfNeeded, recordUserRepoRemote, resolveUserRepoRemoteUrl, getRemoteUrl, sameGitRemote, displayHomePath, syncRepoGit, } from '../lib/git.js';
33
33
  import { DEFAULT_SYSTEM_REPO } from '../lib/types.js';
34
34
  import { ALL_AGENT_IDS, isAgentName, resolveAgentName } from '../lib/agents.js';
35
35
  import { refresh } from '../lib/refresh.js';
@@ -1126,8 +1126,33 @@ Examples:
1126
1126
  }
1127
1127
  for (const t of targets) {
1128
1128
  if (!fs.existsSync(t.dir) || !isGitRepo(t.dir)) {
1129
- console.log(chalk.yellow(` ${t.alias}: not a git repo, skipping`));
1130
- continue;
1129
+ // The user repo self-heals in place instead of being skipped: adopt a
1130
+ // non-git / partial checkout against its remote, preserving runtime
1131
+ // state, then fall through to the normal sync (PHNX-3301). System is
1132
+ // cloned by setup and extras by `repo add`, so those still skip.
1133
+ if (t.alias === 'user' && fs.existsSync(t.dir)) {
1134
+ const spinner = ora(`Adopting ${formatRepoTarget(t.alias, t.dir)} in place...`).start();
1135
+ const adopted = await adoptUserRepoIfNeeded(t.dir);
1136
+ if (!adopted || !adopted.success) {
1137
+ spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${adopted?.error ?? 'adopt failed'}`);
1138
+ if (adopted?.needsUrl)
1139
+ console.log(chalk.gray(' git-back it: agents repo pull user <git-url>'));
1140
+ process.exitCode = 1;
1141
+ continue;
1142
+ }
1143
+ spinner.succeed(`${formatRepoTarget(t.alias, t.dir)}: adopted in place → ${adopted.commit} (${adopted.materialized} file(s) materialized${adopted.reconciledAgentsYaml ? ', agents.yaml reconciled' : ''})`);
1144
+ if (adopted.localEdits.length > 0) {
1145
+ console.log(chalk.yellow(` kept ${adopted.localEdits.length} local edit(s): ${adopted.localEdits.slice(0, 5).join(', ')}${adopted.localEdits.length > 5 ? ', …' : ''}`));
1146
+ }
1147
+ if (adopted.agentsYamlBackup) {
1148
+ console.log(chalk.gray(` saved the previous agents.yaml to ${adopted.agentsYamlBackup}`));
1149
+ }
1150
+ // Now a git repo — fall through to the normal sync below.
1151
+ }
1152
+ else {
1153
+ console.log(chalk.yellow(` ${t.alias}: not a git repo, skipping`));
1154
+ continue;
1155
+ }
1131
1156
  }
1132
1157
  const push = t.alias !== 'system';
1133
1158
  const spinner = ora(`Syncing ${formatRepoTarget(t.alias, t.dir)}...`).start();
@@ -1136,6 +1161,9 @@ Examples:
1136
1161
  const pushed = result.pushed ? ' (pushed)' : '';
1137
1162
  spinner.succeed(`${formatRepoTarget(t.alias, t.dir)}: ${result.commit}${pushed}`);
1138
1163
  if (t.alias === 'user') {
1164
+ const u = resolveUserRepoRemoteUrl(t.dir);
1165
+ if (u)
1166
+ recordUserRepoRemote(t.dir, u);
1139
1167
  const { reconcileDeviceDiscoveryPolicies } = await import('../lib/devices/discovery-policy.js');
1140
1168
  await reconcileDeviceDiscoveryPolicies();
1141
1169
  }
@@ -26,7 +26,11 @@ async function resolveTarget(sessionId) {
26
26
  const resolution = resolveInjectTargetForSession(match);
27
27
  if (!resolution.addressable) {
28
28
  // Surface the resolver's precise reason (host/rail), not a generic "not tmux".
29
- return { target: null, reason: `Session "${sessionId}": ${resolution.reason}` };
29
+ return {
30
+ target: null,
31
+ reason: `Session "${sessionId}": ${resolution.reason}`,
32
+ hint: resolution.hint,
33
+ };
30
34
  }
31
35
  return { target: resolution.target };
32
36
  }
@@ -43,10 +47,11 @@ async function runInject(sessionId, text, options) {
43
47
  else {
44
48
  const resolved = await resolveTarget(sessionId);
45
49
  if (!resolved.target) {
50
+ const message = resolved.hint ? `${resolved.reason}\n${resolved.hint}` : resolved.reason;
46
51
  if (options.json)
47
- console.log(JSON.stringify({ ok: false, error: resolved.reason }));
52
+ console.log(JSON.stringify({ ok: false, error: message }));
48
53
  else
49
- console.error(chalk.red(resolved.reason));
54
+ console.error(chalk.red(message));
50
55
  process.exit(1);
51
56
  }
52
57
  target = resolved.target;
@@ -9,6 +9,7 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import chalk from 'chalk';
11
11
  import { truncate, humanDuration } from '../lib/format.js';
12
+ import { sessionDisplayAgent } from '../lib/session/types.js';
12
13
  import { fetchPeerPreviewDigest } from '../lib/session/remote-list.js';
13
14
  import { parseSession, sanitizeForTerminal, SNAPSHOT_TODO_TOOLS } from '../lib/session/parse.js';
14
15
  import { safeTeamText } from '../lib/session/team-filter.js';
@@ -404,7 +405,7 @@ function formatHeader(session, events) {
404
405
  const { createdAgo, lastActiveAgo, duration } = extractTiming(session, events);
405
406
  // Line 1: Agent v version · shortId · model · account
406
407
  const line1 = [];
407
- line1.push(chalk.gray(`${displayAgent(session.agent)}${session.version ? ` v${session.version}` : ''}`));
408
+ line1.push(chalk.gray(`${displayAgent(sessionDisplayAgent(session))}${session.version ? ` v${session.version}` : ''}`));
408
409
  if (session.shortId)
409
410
  line1.push(chalk.dim(session.shortId));
410
411
  if (model)
@@ -22,6 +22,7 @@ export interface ResumeOptions extends StrictResumeOptions {
22
22
  local?: boolean;
23
23
  }
24
24
  export declare function registerSessionsResumeCommand(sessionsCmd: Command): void;
25
+ export declare function sessionsResumeAction(query: string | undefined, prompt: string | undefined, options: ResumeOptions): Promise<void>;
25
26
  /** IDs and tmux aliases are actions, not picker search text. Human phrases keep
26
27
  * the existing pre-filtered picker, while an explicit identity resumes directly. */
27
28
  export declare function isDirectResumeSelector(query: string): boolean;
@@ -28,6 +28,7 @@ import { looksLikeSessionId } from '../lib/session/discover.js';
28
28
  import { machineId } from '../lib/session/sync/config.js';
29
29
  import { sessionOriginDevice, sessionRecoveryDestinationMatches } from '../lib/session/recovery.js';
30
30
  import { runStrictResume, wantsStrictResume } from './resume.js';
31
+ import { attachLocalLiveSelector } from '../lib/session/local-tmux-attach.js';
31
32
  /** Opening more than this many live sessions at once asks for confirmation first. */
32
33
  export const CONFIRM_THRESHOLD = 5;
33
34
  export function registerSessionsResumeCommand(sessionsCmd) {
@@ -101,7 +102,7 @@ export function registerSessionsResumeCommand(sessionsCmd) {
101
102
  await sessionsResumeAction(query, prompt, options);
102
103
  });
103
104
  }
104
- async function sessionsResumeAction(query, prompt, options) {
105
+ export async function sessionsResumeAction(query, prompt, options) {
105
106
  const strictOpts = {
106
107
  mode: options.mode,
107
108
  interactive: options.interactive,
@@ -115,8 +116,18 @@ async function sessionsResumeAction(query, prompt, options) {
115
116
  // must go through sessions focus so they cannot silently fork a copy
116
117
  // (AGI EXT still shells `sessions resume <id> --local`).
117
118
  if (query && (isDirectResumeSelector(query) || wantsStrictResume(prompt, strictOpts))) {
119
+ const hosts = options.device ? [options.device] : [];
120
+ // PHNX-3292: a live LOCAL tmux pane (the exact alias, or a unique 8-hex
121
+ // short id) attaches immediately, before any fleet SSH — the product rule
122
+ // is "first unique match wins," and a pane already on this box is the
123
+ // fastest possible match. This applies to bare resume too, not just
124
+ // `--attach-only`: a prompt/mode/headless/cwd override still means the
125
+ // caller wants strict resume semantics (a scripted continue), so that
126
+ // case is excluded via wantsStrictResume.
127
+ if (!wantsStrictResume(prompt, strictOpts) && await attachLocalLiveSelector(query.trim(), hosts)) {
128
+ return;
129
+ }
118
130
  if (resumeUsesLifecycleDispatch(query, prompt, options)) {
119
- const hosts = options.device ? [options.device] : [];
120
131
  await dispatchSessionLifecycleInPlace(query.trim(), hosts, !!options.attachOnly, !!options.local);
121
132
  return;
122
133
  }
@@ -32,7 +32,7 @@ export function registerSessionsStopCommand(program) {
32
32
  });
33
33
  }
34
34
  export async function stopSessionAction(id, opts = {}) {
35
- const { self, activeById } = await gatherLiveTargets(!!opts.local, { includeCloud: true });
35
+ const { self, activeById } = await gatherLiveTargets(!!opts.local, { includeCloud: true, selector: id });
36
36
  const resolved = resolveOne(activeById, id);
37
37
  if ('error' in resolved) {
38
38
  console.error(chalk.red(resolved.error));