@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -11,19 +11,28 @@
11
11
  * SSH automatically, with bounded backoff, until the user detaches cleanly (the
12
12
  * remote returns 0) or the agent exits (the tmux session is gone; a non-255 code).
13
13
  *
14
- * The re-attach reuses the peer's OWN reconnect verb — `agents sessions focus <id>
15
- * --local --attach-only` joins the live local tmux pane there (no fork, no resumed
16
- * copy) so there is no second re-attach implementation to keep in sync.
14
+ * The re-attach reuses the peer's OWN recovery verb — `agents sessions focus <id>
15
+ * --local` which JOINS the live local tmux pane there (a second client, no fork)
16
+ * when it still exists, and RESUMES the session in place when the pane is already
17
+ * gone. Dropping `--attach-only` is deliberate: a reattach that lands after the
18
+ * remote pane died must not dead-end at a bare shell (the RUSH-2085 bug), it must
19
+ * fall through to resume so the user is put back into the agent. There is one
20
+ * re-attach implementation (the peer's focus) to keep in sync.
17
21
  *
18
- * **Why the budget refills on `connected`, not on call duration.** ssh returns 255
19
- * for BOTH "couldn't connect at all" and "connected, then the link dropped." A
20
- * failed connect still takes up to `ConnectTimeout` (10s) to return, so timing
21
- * alone can't tell the two apart — a duration threshold below the connect timeout
22
- * would classify every hung connect as a live session and retry forever under a
23
- * sustained outage (the exact failure this feature exists to survive). Instead each
24
- * attempt runs a fast preflight probe: a reattach that genuinely reconnected (and
25
- * then dropped again) refills the retry budget; one that never reached the host
26
- * counts against it, so a sustained outage gives up after MAX_ATTEMPTS.
22
+ * **What it takes to refill the budget: reached the host AND held the pane.** ssh
23
+ * returns 255 for BOTH "couldn't connect at all" and "connected, then the link
24
+ * dropped." A failed connect still takes up to `ConnectTimeout` (10s) to return, so
25
+ * the duration of the whole call can't tell the two apart — a threshold below the
26
+ * connect timeout would classify every hung connect as a live session and retry
27
+ * forever under a sustained outage (the exact failure this feature exists to
28
+ * survive). So each attempt runs a fast preflight probe FIRST, and only once that
29
+ * probe proves the host reachable does the interactive attach run which means the
30
+ * ATTACH's own duration is a clean signal, measured with the connect phase already
31
+ * behind it. A reattach that reached the host and then held for at least
32
+ * {@link MIN_HOLD_MS} refills the retry budget, so a long session that blinks all
33
+ * day keeps reconnecting. Everything else — never reached the host, or reached it
34
+ * and died right back — counts against the budget, so a sustained outage and a
35
+ * fast-flapping link both give up after {@link MAX_ATTEMPTS}.
27
36
  *
28
37
  * The retry policy is a pure state machine (`reconnectStep`) so it is unit-tested
29
38
  * without touching SSH; the loop (`reconnectInteractiveSession`) only adds the real
@@ -31,37 +40,37 @@
31
40
  *
32
41
  * **255 from the REMOTE side should never be trusted as "the link dropped."**
33
42
  * `reattachRemoteSession`'s `connected` flag is set as soon as the fast preflight
34
- * probe succeeds — it says nothing about whether the interactive attach that
35
- * follows actually reattached a live pane. If the REMOTE command (`agents
36
- * sessions focus <id> --local --attach-only`) itself ever happened to exit 255
37
- * for a reason that has nothing to do with the ssh transport, `sshStream` would
38
- * return that same 255, `reconnectStep` couldn't tell it apart from a genuine
39
- * drop, and `connected: true` would refill the retry budget forever — "attempt
40
- * 1/N" printed on every single cycle, the terminal filling with aborted-TTY
41
- * escape-code garbage, `MAX_ATTEMPTS` never actually bounding anything.
43
+ * probe succeeds — it says nothing about whether the interactive attach/resume
44
+ * that follows actually put the user back into the agent. If the REMOTE command
45
+ * (`agents sessions focus <id> --local`) itself ever happened to exit 255 for a
46
+ * reason that has nothing to do with the ssh transport, `sshStream` would return
47
+ * that same 255, `reconnectStep` couldn't tell it apart from a genuine drop, and
48
+ * `connected: true` would refill the retry budget forever — "attempt 1/N" printed
49
+ * on every single cycle, the terminal filling with aborted-TTY escape-code
50
+ * garbage, `MAX_ATTEMPTS` never actually bounding anything.
42
51
  *
43
- * Two candidate producers of that scenario were investigated
44
- * `refuseFallback`'s login-shell fallback (commands/go.ts) and `jumpTo`'s
45
- * nested remote-tmux hop and both turned out to be UNREACHABLE through this
46
- * exact remote command: under `--local`, `gatherLiveTargets` never sets a
47
- * foreign `.machine` (go.ts:61), so `remote` is always `undefined` in both,
48
- * and neither branch can fire (the same reasoning that made an earlier,
49
- * narrower fix here dead code see git history). So this fix does not close
50
- * a confirmed incident cause; what it closes is the underlying channel-level
51
- * flaw that would make *any* future remote-side 255 producer — reachable today
52
- * or not — indistinguishable from a real drop. {@link wrapRemoteExitCode} wraps
53
- * the entire remote command so that whatever exit code it decides on, a 255 is
54
- * remapped to {@link REMOTE_EXIT_255_REMAPPED} before `sshStream` ever sees it,
55
- * regardless of which internal branch produced it and regardless of the peer's
56
- * `agents` version (the remap happens in the shell wrapper THIS process sends).
52
+ * The resume fall-through only widens that surface the peer's focus now runs a
53
+ * full recovery path (`resumeSessionInPlace` / `runOnPeer`) on a dead pane, any
54
+ * step of which could in principle exit 255 for its own reasons. So the channel-
55
+ * level defense is what matters, not an audit of which branch can fire:
56
+ * {@link wrapRemoteExitCode} wraps the entire remote command so that whatever exit
57
+ * code it decides on, a 255 is remapped to {@link REMOTE_EXIT_255_REMAPPED} before
58
+ * `sshStream` ever sees it, regardless of which internal branch produced it and
59
+ * regardless of the peer's `agents` version (the remap happens in the shell
60
+ * wrapper THIS process sends).
57
61
  *
58
- * This does NOT close every way `reconnectStep` can loop on a real transport
59
- * 255: a genuinely recurring LOCAL ssh failure (a fast-flapping link, an
60
- * attach that dies at the TTY stage on every reconnect) still refills the
61
- * budget every time by design (see "Why the budget refills on `connected`"
62
- * above) and can still print "attempt 1/N" indefinitely. That's an accepted,
63
- * pre-existing tradeoff of the original feature, not something this fix
64
- * changes either way tracked separately (agents-cli#1884), not fixed here.
62
+ * A recurring LOCAL ssh failure used to defeat the budget the same way, and the
63
+ * remap alone did not close it (agents-cli#1884): `connected` was set by the
64
+ * preflight probe and said nothing about whether the attach that followed held, so
65
+ * a fast-flapping link or an attach that died at the TTY-negotiation stage every
66
+ * single time refilled the budget on every cycle, printed "attempt 1/N" forever,
67
+ * and left `MAX_ATTEMPTS` bounding nothing. The {@link MIN_HOLD_MS} floor above is
68
+ * what closes it: an attach that dies immediately is not a reconnection, so the
69
+ * budget drains and the loop gives up with {@link unstableNotice}. A flat total-
70
+ * attempt or wall-clock ceiling that ignored `connected` was the alternative and is
71
+ * deliberately NOT taken — any fixed total eventually strands the all-day-blinking
72
+ * session this feature exists for, while the hold floor only ever stops a loop that
73
+ * is failing to put the user back into the agent.
65
74
  */
66
75
  import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
67
76
  import { sshTargetFor } from './types.js';
@@ -72,13 +81,22 @@ export const SSH_CONN_FAILURE = 255;
72
81
  * {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
73
82
  * transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
74
83
  export const REMOTE_EXIT_255_REMAPPED = 254;
75
- /** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
76
- * {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
77
- * refills the budget, so a long session that blinks all day reconnects every time
78
- * only consecutive UNREACHABLE attempts exhaust it. */
84
+ /** Consecutive unproductive reattaches before giving up. Backoff is capped at
85
+ * {@link MAX_BACKOFF_MS}. A reattach that reconnected and HELD (then dropped again)
86
+ * refills the budget, so a long session that blinks all day reconnects every time
87
+ * an attempt that never reached the host, or reached it and died back inside
88
+ * {@link MIN_HOLD_MS}, counts against it. */
79
89
  export const MAX_ATTEMPTS = 6;
80
90
  const BASE_BACKOFF_MS = 2_000;
81
91
  const MAX_BACKOFF_MS = 30_000;
92
+ /** How long a reattach must hold the remote pane before it counts as a genuine
93
+ * reconnection that refills the budget. Measured on the interactive attach ALONE
94
+ * — the preflight probe has already returned by then, so this is not the connect
95
+ * timing the file header rules out. 10s is comfortably longer than any attach that
96
+ * dies during TTY negotiation and far shorter than a session the user is working
97
+ * in; a link that drops the user out inside 10s on every attempt is one this loop
98
+ * should stop retrying, not one it should keep re-entering. */
99
+ export const MIN_HOLD_MS = 10_000;
82
100
  export function initialReconnectState() {
83
101
  return { attempt: 0 };
84
102
  }
@@ -86,6 +104,16 @@ export function initialReconnectState() {
86
104
  export function backoffMs(attempt) {
87
105
  return Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
88
106
  }
107
+ /**
108
+ * Did this attempt genuinely put the user back into the agent? Only such an attempt
109
+ * refills the retry budget — it must have reached the host AND held the pane for at
110
+ * least {@link MIN_HOLD_MS}. An attach that reached the host and died right back is
111
+ * a flapping link, not a reconnection, and counts against the budget like an
112
+ * unreachable host (agents-cli#1884; see the file header).
113
+ */
114
+ export function refillsBudget(outcome) {
115
+ return outcome.connected && outcome.heldMs >= MIN_HOLD_MS;
116
+ }
89
117
  /**
90
118
  * Decide what to do after a run/re-attach returned `outcome`. Pure — the only
91
119
  * input is the prior state and the outcome, the only output is the next action.
@@ -93,14 +121,15 @@ export function backoffMs(attempt) {
93
121
  * - a non-255 code means the remote command spoke for itself (clean detach = 0,
94
122
  * agent exit / no live session = non-zero) → stop and surface that code.
95
123
  * - a 255 means the link dropped → retry, unless the budget is spent.
96
- * - a 255 from an attempt that DID connect (a genuine reconnection that then
97
- * dropped) refills the budget first; a 255 that never connected counts against
98
- * it, so a host that stays unreachable gives up after MAX_ATTEMPTS.
124
+ * - a 255 from an attempt that reconnected AND held ({@link refillsBudget})
125
+ * refills the budget first; every other 255 counts against it, so a host that
126
+ * stays unreachable — and a link that keeps dropping the attach immediately
127
+ * both give up after MAX_ATTEMPTS.
99
128
  */
100
129
  export function reconnectStep(state, outcome) {
101
130
  if (outcome.code !== SSH_CONN_FAILURE)
102
131
  return { action: 'stop', code: outcome.code };
103
- const attempts = outcome.connected ? 0 : state.attempt;
132
+ const attempts = refillsBudget(outcome) ? 0 : state.attempt;
104
133
  if (attempts >= MAX_ATTEMPTS)
105
134
  return { action: 'stop', code: SSH_CONN_FAILURE };
106
135
  return { action: 'retry', waitMs: backoffMs(attempts), state: { attempt: attempts + 1 } };
@@ -111,9 +140,21 @@ export function reconnectNotice(sessionId, host, attempt, waitMs) {
111
140
  const when = secs <= 1 ? 'now' : `in ${secs} seconds`;
112
141
  return `\nConnection to ${host} dropped — the agent is still running there. Reconnecting to ${sessionId.slice(0, 8)} ${when} (attempt ${attempt}/${MAX_ATTEMPTS})…\n`;
113
142
  }
114
- /** Notice shown once the retry budget is spent. */
143
+ /** Notice shown once the retry budget is spent on a host that stayed UNREACHABLE.
144
+ * Hands back the one verb that re-enters the terminal — attach the live pane if it
145
+ * survived, else resume. */
115
146
  export function exhaustedNotice(sessionId, host) {
116
- return `\nCouldn't reconnect to ${host} after ${MAX_ATTEMPTS} attempts. The agent may still be running — reattach when the network is back:\n agents sessions focus ${sessionId.slice(0, 8)}\n`;
147
+ return `\nCouldn't reconnect to ${host} after ${MAX_ATTEMPTS} attempts. The agent may still be running — get back in when the network is back:\n agents reconnect ${sessionId.slice(0, 8)}\n`;
148
+ }
149
+ /** Notice shown when the budget is spent the OTHER way: the last reattach reached
150
+ * the host and the connection dropped again within {@link MIN_HOLD_MS}. Saying
151
+ * "couldn't reconnect" there would be false — it did reconnect and could not stay
152
+ * — and the user needs to know the link, not the host, is the problem. It claims
153
+ * no count of successful reconnections: the budget can also be spent by a run of
154
+ * unreachable attempts followed by one that reconnected and dropped straight out. */
155
+ export function unstableNotice(sessionId, host) {
156
+ const secs = Math.round(MIN_HOLD_MS / 1000);
157
+ return `\nGave up reconnecting to ${host} after ${MAX_ATTEMPTS} attempts — it kept dropping again within ${secs} seconds of getting back in. The agent may still be running there; reconnect once the link is stable:\n agents reconnect ${sessionId.slice(0, 8)}\n`;
117
158
  }
118
159
  /** Notice shown when a reattach stops on a remapped remote-side exit
119
160
  * ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
@@ -121,7 +162,7 @@ export function exhaustedNotice(sessionId, host) {
121
162
  * {@link wrapRemoteExitCode}). Distinct from {@link exhaustedNotice}, which is
122
163
  * only for a genuinely spent retry budget. */
123
164
  export function remoteExitNotice(sessionId, host) {
124
- return `\nReattach to ${sessionId.slice(0, 8)} on ${host} ended (not a network drop) — check whether it's still live:\n agents sessions ${sessionId.slice(0, 8)}\n`;
165
+ return `\nReattach to ${sessionId.slice(0, 8)} on ${host} ended (not a network drop) — get back in, or check whether it's still live:\n agents reconnect ${sessionId.slice(0, 8)}\n`;
125
166
  }
126
167
  /**
127
168
  * Wrap `cmd` in `bash -lc` (the login-shell pattern `buildRemoteAgentsInvocation`
@@ -145,16 +186,17 @@ export function wrapRemoteExitCode(cmd) {
145
186
  return `bash -lc ${shellQuote(guarded)}`;
146
187
  }
147
188
  /**
148
- * The remote command a reattach runs — the peer's own reconnect verb
149
- * (`agents sessions focus <id> --local --attach-only`), wrapped by
150
- * {@link wrapRemoteExitCode} so a stray remote-origin 255 (from this command,
151
- * whatever produces it — see the file header) can never masquerade as a
152
- * network drop. Split out from {@link reattachRemoteSession} so it is
153
- * unit-tested without SSH mirrors `remoteAgentsJsonCommand` in
154
- * lib/remote-agents-json.ts.
189
+ * The remote command a reattach runs — the peer's own recovery verb
190
+ * (`agents sessions focus <id> --local`), wrapped by {@link wrapRemoteExitCode}
191
+ * so a stray remote-origin 255 (from this command, whatever produces it — see the
192
+ * file header) can never masquerade as a network drop. No `--attach-only`: focus
193
+ * joins the live pane when it survived, else RESUMES the session in place, so a
194
+ * reattach landing after the pane died recovers the agent instead of dead-ending
195
+ * (RUSH-2085). Split out from {@link reattachRemoteSession} so it is unit-tested
196
+ * without SSH — mirrors `remoteAgentsJsonCommand` in lib/remote-agents-json.ts.
155
197
  */
156
198
  export function reattachRemoteCommand(sessionId) {
157
- const inner = ['agents', 'sessions', 'focus', sessionId, '--local', '--attach-only']
199
+ const inner = ['agents', 'sessions', 'focus', sessionId, '--local']
158
200
  .map(shellQuote)
159
201
  .join(' ');
160
202
  return wrapRemoteExitCode(inner);
@@ -164,10 +206,11 @@ export function reattachRemoteCommand(sessionId) {
164
206
  * `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
165
207
  * first establishes whether the host is actually reachable this attempt — that
166
208
  * `connected` bit, not the call duration, is what the retry policy keys on. Only on
167
- * a reachable host do we run the interactive attach (which carries no credentials —
168
- * the agent already runs on the peer — so it rides the normal transport). Returns
169
- * the ssh exit code (255 = dropped again / unreachable; 0 = clean detach; other =
170
- * session ended) plus whether this attempt connected.
209
+ * a reachable host do we run the interactive attach-or-resume (which carries no
210
+ * credentials — the agent already runs on the peer — so it rides the normal
211
+ * transport). Returns the ssh exit code (255 = dropped again / unreachable; 0 =
212
+ * clean detach; other = session ended), whether this attempt connected, and how
213
+ * long the attach held — the two inputs {@link refillsBudget} decides on.
171
214
  */
172
215
  export function reattachRemoteSession(host, sessionId) {
173
216
  const target = sshTargetFor(host);
@@ -175,8 +218,12 @@ export function reattachRemoteSession(host, sessionId) {
175
218
  // completed, so a hung/failed connect is never mistaken for a live reconnection.
176
219
  const probe = sshExec(target, 'true', { multiplex: false });
177
220
  if (probe.code !== 0)
178
- return { code: SSH_CONN_FAILURE, connected: false };
179
- return { code: sshStream(target, reattachRemoteCommand(sessionId), { tty: true }), connected: true };
221
+ return { code: SSH_CONN_FAILURE, connected: false, heldMs: 0 };
222
+ // Timed from AFTER the probe returned, so this is the attach's own duration and
223
+ // carries none of the connect phase the file header rules out as a signal.
224
+ const startedAt = Date.now();
225
+ const code = sshStream(target, reattachRemoteCommand(sessionId), { tty: true });
226
+ return { code, connected: true, heldMs: Date.now() - startedAt };
180
227
  }
181
228
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
182
229
  /**
@@ -189,15 +236,25 @@ export async function reconnectInteractiveSession(opts) {
189
236
  const wait = opts.wait ?? sleep;
190
237
  const reattach = opts.reattach ?? reattachRemoteSession;
191
238
  let state = initialReconnectState();
192
- // The initial run reached the point of running the agent, so it connected.
193
- let outcome = { code: opts.initialExit, connected: true };
239
+ // The initial run reached the point of running the agent, so it connected. Its
240
+ // hold duration is never measured — exec.ts owns that call — and never needs to
241
+ // be: refilling is a no-op at attempt 0, which is where the loop starts, so this
242
+ // outcome can only ever produce the first retry either way.
243
+ let outcome = { code: opts.initialExit, connected: true, heldMs: 0 };
194
244
  for (;;) {
195
245
  const decision = reconnectStep(state, outcome);
196
246
  if (decision.action === 'stop') {
197
- if (decision.code === SSH_CONN_FAILURE)
198
- write(exhaustedNotice(opts.sessionId, opts.host.name));
199
- else if (decision.code === REMOTE_EXIT_255_REMAPPED)
247
+ // A spent budget has two shapes and one wrong message: `outcome` is the
248
+ // reattach that spent it, so an unreachable host reads "couldn't reconnect"
249
+ // and a link that reconnected but kept dropping reads as exactly that.
250
+ if (decision.code === SSH_CONN_FAILURE) {
251
+ write(outcome.connected
252
+ ? unstableNotice(opts.sessionId, opts.host.name)
253
+ : exhaustedNotice(opts.sessionId, opts.host.name));
254
+ }
255
+ else if (decision.code === REMOTE_EXIT_255_REMAPPED) {
200
256
  write(remoteExitNotice(opts.sessionId, opts.host.name));
257
+ }
201
258
  return decision.code;
202
259
  }
203
260
  write(reconnectNotice(opts.sessionId, opts.host.name, decision.state.attempt, decision.waitMs));
@@ -53,8 +53,8 @@ export interface MatchHostOptions {
53
53
  * "Unknown device" verdict reachable).
54
54
  */
55
55
  allowBareLiteral?: boolean;
56
- /** Override the affinity pick for the `auto` sentinel (tests). Defaults to
57
- * `resolveDeviceAffinity({})` the same engine `agents run --device auto` uses. */
56
+ /** Override the affinity pick for generic `auto` host resolution in tests.
57
+ * Harness-aware run/team placement resolves `auto` before reaching this core. */
58
58
  resolveAuto?: () => DeviceAffinityPlan;
59
59
  }
60
60
  /**
@@ -148,11 +148,9 @@ function literalHost(token, host, user) {
148
148
  * into a dispatch verdict.
149
149
  */
150
150
  export async function matchHost(name, opts = {}) {
151
- // `auto` is the same affinity sentinel `agents run --device auto` resolves
152
- // (isDeviceAuto / resolveDeviceAffinity in ../smart-launch.js) shared here so
153
- // every caller through this one core (ssh, teams, dispatch/passthrough) picks a
154
- // device the SAME way `run` does instead of rejecting it as "Unknown device
155
- // 'auto'" (RUSH-2185). A `null` plan.host means the affinity engine picked this
151
+ // Generic host-only callers resolve `auto` through the affinity engine here;
152
+ // harness-aware run/team placement resolves it earlier with live probes. A
153
+ // `null` plan.host means the affinity engine picked this
156
154
  // very machine; resolve that as the local device/host entry (if any) rather than
157
155
  // returning nothing — callers that already special-case "target is this
158
156
  // machine" (teams add/create, the passthrough self-host check) then treat it as
@@ -140,6 +140,23 @@ export interface WindowsAgentsCommand {
140
140
  * not the progress stream.)
141
141
  */
142
142
  export declare const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'SilentlyContinue'";
143
+ /**
144
+ * Strip a PowerShell CLIXML wrapper from stdout relayed off a Windows host.
145
+ *
146
+ * PowerShell 5.1 serializes progress / error / verbose records to CLIXML when a
147
+ * stream is a redirected pipe (an ssh capture, not a console): a `#< CLIXML`
148
+ * banner followed by one or more `<Objs …>…</Objs>` elements. {@link
149
+ * POWERSHELL_PROGRESS_SILENCE} suppresses the common "Preparing modules for
150
+ * first use." progress record at the source, but a Windows peer reached WITHOUT
151
+ * that prelude — a raw `agents ssh <win> 'agents … --json'`, an older peer, or a
152
+ * record on a stream we did not silence — can still emit the banner ahead of the
153
+ * real payload, which breaks a naive `JSON.parse` of the relayed `--json`
154
+ * (RUSH-2286). Remove the banner and every self-contained `<Objs …>…</Objs>`
155
+ * block, leaving the genuine payload untouched. A no-op (returns the input
156
+ * unchanged) when no `#< CLIXML` marker is present, so it is safe to apply on
157
+ * every remote-JSON boundary regardless of the peer's OS.
158
+ */
159
+ export declare function stripClixml(stdout: string): string;
143
160
  export declare function windowsAgentsScript(cmd: WindowsAgentsCommand): string;
144
161
  /**
145
162
  * Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
@@ -252,6 +252,35 @@ export function decodePowershell(encoded) {
252
252
  * not the progress stream.)
253
253
  */
254
254
  export const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'SilentlyContinue'";
255
+ /**
256
+ * Strip a PowerShell CLIXML wrapper from stdout relayed off a Windows host.
257
+ *
258
+ * PowerShell 5.1 serializes progress / error / verbose records to CLIXML when a
259
+ * stream is a redirected pipe (an ssh capture, not a console): a `#< CLIXML`
260
+ * banner followed by one or more `<Objs …>…</Objs>` elements. {@link
261
+ * POWERSHELL_PROGRESS_SILENCE} suppresses the common "Preparing modules for
262
+ * first use." progress record at the source, but a Windows peer reached WITHOUT
263
+ * that prelude — a raw `agents ssh <win> 'agents … --json'`, an older peer, or a
264
+ * record on a stream we did not silence — can still emit the banner ahead of the
265
+ * real payload, which breaks a naive `JSON.parse` of the relayed `--json`
266
+ * (RUSH-2286). Remove the banner and every self-contained `<Objs …>…</Objs>`
267
+ * block, leaving the genuine payload untouched. A no-op (returns the input
268
+ * unchanged) when no `#< CLIXML` marker is present, so it is safe to apply on
269
+ * every remote-JSON boundary regardless of the peer's OS.
270
+ */
271
+ export function stripClixml(stdout) {
272
+ if (!stdout.includes('#< CLIXML'))
273
+ return stdout;
274
+ // A CLIXML flush is the `#< CLIXML` banner immediately followed by one or more
275
+ // <Objs …>…</Objs> elements, as one contiguous block. Remove that whole unit,
276
+ // ANCHORED to the banner: the <Objs> removal is scoped to blocks that follow a
277
+ // banner, so a stray `<Objs>` appearing inside a legitimate JSON string value
278
+ // (a session title/prompt that quotes CLIXML text) is never touched — stripping
279
+ // <Objs> globally would silently delete JSON between two such substrings.
280
+ return stdout
281
+ .replace(/#< CLIXML[^\n]*\r?\n?(?:\s*<Objs\b[\s\S]*?<\/Objs>)*/g, '')
282
+ .trim();
283
+ }
255
284
  export function windowsAgentsScript(cmd) {
256
285
  const { args, env, cwd, propagateExit = true } = cmd;
257
286
  const parts = [POWERSHELL_PROGRESS_SILENCE];
@@ -12,6 +12,8 @@ export interface HostTask {
12
12
  id: string;
13
13
  host: string;
14
14
  target: string;
15
+ /** OpenSSH private-key path retained for follow/reconcile/stop calls. */
16
+ identityFile?: string;
15
17
  agent: string;
16
18
  prompt: string;
17
19
  pid?: number;
@@ -67,3 +67,4 @@ export interface HostProvider {
67
67
  * resolves HostName/User/Port/Identity), else `user@address` (or `address`).
68
68
  */
69
69
  export declare function sshTargetFor(host: Host): string;
70
+ export declare function hostIdentityArgs(host: Host): string[];
@@ -37,3 +37,6 @@ export function sshTargetFor(host) {
37
37
  }
38
38
  return host.user ? `${host.user}@${host.address}` : host.address;
39
39
  }
40
+ export function hostIdentityArgs(host) {
41
+ return host.identityFile ? ['-i', host.identityFile, '-o', 'IdentitiesOnly=yes'] : [];
42
+ }
@@ -134,12 +134,11 @@ export declare function disableMenubarService(): void;
134
134
  * different CDHashes. Any digest gate therefore reports "changed" for exactly
135
135
  * the skew case it was meant to exempt.
136
136
  *
137
- * So ownership decides instead: the plist's `AGENTS_ENTRY` names the owner, and
138
- * only the owner may reinstall. A non-owner takes over only once the recorded
139
- * owner is gone from disk, which is what makes the rule converge — a dead
140
- * install cannot hold the helper hostage, and a live one cannot be fought over.
141
- * A same-install upgrade keeps its entry path, so `npm update` still installs
142
- * the new helper normally. Pure so the truth table is unit-testable.
137
+ * So the installed version decides release skew: a newer signed release takes
138
+ * ownership immediately, an older release cannot downgrade it, and equal foreign
139
+ * releases retain the recorded owner. The plist's `AGENTS_ENTRY` remains the
140
+ * ownership signal for legacy state with no version marker, where the cooldown
141
+ * bounds takeover churn. Pure so the truth table is unit-testable.
143
142
  */
144
143
  export declare function mayInstallMenubarHelper(opts: {
145
144
  /** `AGENTS_ENTRY` baked into the installed plist — the recorded owner. */
@@ -152,6 +151,10 @@ export declare function mayInstallMenubarHelper(opts: {
152
151
  helperExecMissing: boolean;
153
152
  /** Installed copy is ad-hoc while the shipped source is Developer ID. */
154
153
  needsDevIdHeal: boolean;
154
+ /** Version stamped beside the installed helper, or null for legacy state. */
155
+ installedVersion: string | null;
156
+ /** Version of the agents-cli install now attempting the heal. */
157
+ currentVersion: string | null;
155
158
  /** ms since the last self-heal reinstall, or null if none is recorded. */
156
159
  msSinceLastHeal: number | null;
157
160
  /** How long a non-owner waits before it may take over. */
@@ -23,6 +23,7 @@ import { sleepSync } from '../fs-atomic.js';
23
23
  import { getRuntimeStateDir, getHelpersDir } from '../state.js';
24
24
  import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../version.js';
25
25
  import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
26
+ import { compareVersions } from '../agent-spec/primitives.js';
26
27
  const APP_BUNDLE_NAME = 'MenubarHelper.app';
27
28
  const INSTALL_DIR_NAME = 'agents-cli';
28
29
  const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
@@ -491,12 +492,11 @@ export function disableMenubarService() {
491
492
  * different CDHashes. Any digest gate therefore reports "changed" for exactly
492
493
  * the skew case it was meant to exempt.
493
494
  *
494
- * So ownership decides instead: the plist's `AGENTS_ENTRY` names the owner, and
495
- * only the owner may reinstall. A non-owner takes over only once the recorded
496
- * owner is gone from disk, which is what makes the rule converge — a dead
497
- * install cannot hold the helper hostage, and a live one cannot be fought over.
498
- * A same-install upgrade keeps its entry path, so `npm update` still installs
499
- * the new helper normally. Pure so the truth table is unit-testable.
495
+ * So the installed version decides release skew: a newer signed release takes
496
+ * ownership immediately, an older release cannot downgrade it, and equal foreign
497
+ * releases retain the recorded owner. The plist's `AGENTS_ENTRY` remains the
498
+ * ownership signal for legacy state with no version marker, where the cooldown
499
+ * bounds takeover churn. Pure so the truth table is unit-testable.
500
500
  */
501
501
  export function mayInstallMenubarHelper(opts) {
502
502
  // Repairs are never gated: a missing binary or a broken signing identity leaves
@@ -511,10 +511,18 @@ export function mayInstallMenubarHelper(opts) {
511
511
  // No owner recorded yet (fresh or pre-`AGENTS_ENTRY` plist) — adopt it.
512
512
  if (!opts.plistEntry)
513
513
  return true;
514
- if (opts.plistEntry === opts.activeEntry)
515
- return true; // we are the owner
516
514
  if (!opts.ownerEntryExists)
517
515
  return true; // the recorded owner is gone
516
+ if (opts.installedVersion && opts.currentVersion) {
517
+ const versionOrder = compareVersions(opts.currentVersion, opts.installedVersion);
518
+ if (versionOrder > 0)
519
+ return opts.sourceIsDeveloperId;
520
+ if (versionOrder < 0)
521
+ return false;
522
+ return opts.plistEntry === opts.activeEntry;
523
+ }
524
+ if (opts.plistEntry === opts.activeEntry)
525
+ return true; // we are the owner
518
526
  // A foreign install while the owner still exists. Refusing outright bounds the
519
527
  // loop but strands the user when the recorded owner is a stale copy that simply
520
528
  // still sits on disk (an old nvm node dir) while their daily driver upgrades:
@@ -534,7 +542,8 @@ export function mayInstallMenubarHelper(opts) {
534
542
  return opts.msSinceLastHeal === null || opts.msSinceLastHeal >= opts.cooldownMs;
535
543
  }
536
544
  /**
537
- * How long a non-owner install waits before it may take the helper over. Long
545
+ * How long a non-owner install waits before it may take an unversioned legacy
546
+ * helper over. Long
538
547
  * enough that a multi-install box restarts the helper at most once an hour
539
548
  * instead of every few seconds; short enough that a user who switched installs
540
549
  * gets their upgrade without hunting for `agents menubar setup`.
@@ -572,6 +581,8 @@ function mayHealMenubar(needsDevIdHeal) {
572
581
  ownerEntryExists: Boolean(plistEntry) && fs.existsSync(plistEntry),
573
582
  helperExecMissing: !fs.existsSync(installedExecutablePath()),
574
583
  needsDevIdHeal,
584
+ installedVersion: readInstalledMenubarVersion(),
585
+ currentVersion: getCliVersion(),
575
586
  msSinceLastHeal: msSinceLastMenubarHeal(),
576
587
  cooldownMs: MENUBAR_TAKEOVER_COOLDOWN_MS,
577
588
  sourceIsDeveloperId: Boolean(src) && hasDeveloperIdSignature(src),
@@ -43,7 +43,7 @@ export interface ModelCatalog {
43
43
  aliases: Record<string, string>;
44
44
  }
45
45
  /** How the model catalog source was obtained. */
46
- export type ModelSourceKind = 'bundle' | 'binary' | 'js' | 'cli';
46
+ export type ModelSourceKind = 'bundle' | 'binary' | 'cli';
47
47
  /** Describes the location and extraction strategy for a model catalog source. */
48
48
  export interface ModelSource {
49
49
  path: string;
@@ -76,6 +76,35 @@ export declare function locateModelSource(agent: AgentId, version: string): Mode
76
76
  * current model and is kept.
77
77
  */
78
78
  export declare function dropBareLegacyIds(ids: string[]): string[];
79
+ /**
80
+ * Scan raw binary/bundle text for canonical Claude model ids, then drop bare
81
+ * legacy prefixes (issue #1892). Two independent guards keep non-model strings
82
+ * out of the catalog:
83
+ *
84
+ * - **Word-boundary anchors on the id regex.** The id must not be glued to a
85
+ * surrounding identifier character, and must not be the truncated prefix of a
86
+ * longer *version* token. `(?<![A-Za-z0-9_])` rejects a glued prefix;
87
+ * `(?![A-Za-z0-9])` rejects a glued alnum suffix; `(?!\.\d)` rejects a
88
+ * dotted-version continuation — so the bare-major prefix of the binary's own
89
+ * "Typo in model ID" troubleshooting string `claude-sonnet-4.6` is not
90
+ * scraped as `claude-sonnet-4`, while a real id followed by an unrelated `.`
91
+ * suffix (`claude-fable-5.md`) still matches. Dash-separated segments only:
92
+ * the dotted form never appears in a genuine id.
93
+ *
94
+ * The id body is captured inside a lookahead (`(?=(...))\1`) so the greedy
95
+ * `-\d+` run matches **atomically**: without it, a suffix-glued token like
96
+ * `claude-opus-4-1x` would fail the trailing anchor on the full match, then
97
+ * backtrack a segment and re-emit the bare `claude-opus-4` — the exact 404-able
98
+ * id this scan exists to suppress (two packed strings can end up glued with no
99
+ * separator in the extracted binary text). The atomic match fails outright
100
+ * instead of degrading to the bare form.
101
+ * - **`dropBareLegacyIds`.** A standalone `.includes("claude-opus-4")` prefix
102
+ * check is a fully delimited string the anchors cannot tell apart from a real
103
+ * bare id, so a bare `claude-<family>-<major>` is dropped only when a
104
+ * more-specific sibling (`claude-opus-4-8`) is also present; a genuinely bare
105
+ * current id with no sibling (`claude-sonnet-5`) is kept.
106
+ */
107
+ export declare function scanClaudeCatalogIds(text: string): string[];
79
108
  /**
80
109
  * Parse `grok models` stdout into a catalog. Exported for unit tests.
81
110
  *