@phnx-labs/agents-cli 1.22.26 → 1.22.28

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 (183) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +80 -27
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +71 -26
  8. package/dist/commands/hosts.js +4 -3
  9. package/dist/commands/insights.js +39 -5
  10. package/dist/commands/output.js +100 -25
  11. package/dist/commands/perf.d.ts +10 -0
  12. package/dist/commands/perf.js +14 -6
  13. package/dist/commands/projects.js +24 -10
  14. package/dist/commands/resume.d.ts +11 -0
  15. package/dist/commands/resume.js +51 -0
  16. package/dist/commands/routines.js +31 -4
  17. package/dist/commands/run-account-picker.d.ts +37 -0
  18. package/dist/commands/run-account-picker.js +101 -17
  19. package/dist/commands/secrets.js +86 -2
  20. package/dist/commands/sessions-picker.js +12 -7
  21. package/dist/commands/sessions.d.ts +73 -7
  22. package/dist/commands/sessions.js +391 -49
  23. package/dist/commands/ssh.js +184 -11
  24. package/dist/commands/teams.js +22 -11
  25. package/dist/index.js +16 -20
  26. package/dist/lib/activity.js +45 -99
  27. package/dist/lib/auth-health.d.ts +47 -3
  28. package/dist/lib/auth-health.js +74 -15
  29. package/dist/lib/bench/index.d.ts +4 -0
  30. package/dist/lib/bench/index.js +4 -0
  31. package/dist/lib/bench/runner.d.ts +16 -0
  32. package/dist/lib/bench/runner.js +111 -0
  33. package/dist/lib/bench/schema.d.ts +5 -0
  34. package/dist/lib/bench/schema.js +91 -0
  35. package/dist/lib/bench/storage.d.ts +5 -0
  36. package/dist/lib/bench/storage.js +32 -0
  37. package/dist/lib/bench/types.d.ts +40 -0
  38. package/dist/lib/bench/types.js +1 -0
  39. package/dist/lib/claude-account-token.d.ts +2 -0
  40. package/dist/lib/claude-account-token.js +56 -3
  41. package/dist/lib/cloud/host.js +1 -0
  42. package/dist/lib/crabbox/cli.d.ts +2 -0
  43. package/dist/lib/crabbox/cli.js +2 -0
  44. package/dist/lib/crabbox/lease.js +7 -1
  45. package/dist/lib/daemon.js +20 -0
  46. package/dist/lib/devices/connect.d.ts +2 -0
  47. package/dist/lib/devices/connect.js +7 -0
  48. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  49. package/dist/lib/devices/doctor-findings.js +15 -1
  50. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  51. package/dist/lib/devices/harness-inventory.js +0 -0
  52. package/dist/lib/devices/registry.d.ts +2 -0
  53. package/dist/lib/devices/resolve-target.d.ts +1 -0
  54. package/dist/lib/devices/resolve-target.js +9 -2
  55. package/dist/lib/devices/ssh-config.js +3 -0
  56. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  57. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  58. package/dist/lib/events.d.ts +1 -1
  59. package/dist/lib/events.js +2 -2
  60. package/dist/lib/exec.js +18 -8
  61. package/dist/lib/fleet/apply.js +9 -7
  62. package/dist/lib/fleet/remote-login.d.ts +4 -3
  63. package/dist/lib/fleet/remote-login.js +11 -9
  64. package/dist/lib/gemini-settings.d.ts +0 -1
  65. package/dist/lib/gemini-settings.js +12 -7
  66. package/dist/lib/git.d.ts +9 -3
  67. package/dist/lib/git.js +39 -22
  68. package/dist/lib/hooks/profile.d.ts +12 -1
  69. package/dist/lib/hooks/profile.js +6 -1
  70. package/dist/lib/hooks.d.ts +8 -5
  71. package/dist/lib/hooks.js +14 -7
  72. package/dist/lib/hosts/dispatch.d.ts +10 -0
  73. package/dist/lib/hosts/dispatch.js +85 -25
  74. package/dist/lib/hosts/logs.js +11 -1
  75. package/dist/lib/hosts/passthrough.d.ts +27 -0
  76. package/dist/lib/hosts/passthrough.js +19 -10
  77. package/dist/lib/hosts/progress.d.ts +12 -1
  78. package/dist/lib/hosts/progress.js +41 -8
  79. package/dist/lib/hosts/providers/devices.js +1 -0
  80. package/dist/lib/hosts/ready.d.ts +45 -4
  81. package/dist/lib/hosts/ready.js +124 -20
  82. package/dist/lib/hosts/reconcile.d.ts +2 -1
  83. package/dist/lib/hosts/reconcile.js +24 -7
  84. package/dist/lib/hosts/reconnect.d.ts +45 -12
  85. package/dist/lib/hosts/reconnect.js +94 -36
  86. package/dist/lib/hosts/registry.d.ts +2 -2
  87. package/dist/lib/hosts/registry.js +4 -5
  88. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  89. package/dist/lib/hosts/remote-cmd.js +29 -0
  90. package/dist/lib/hosts/tasks.d.ts +3 -0
  91. package/dist/lib/hosts/types.d.ts +1 -0
  92. package/dist/lib/hosts/types.js +3 -0
  93. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  94. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  95. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  96. package/dist/lib/menubar/install-menubar.js +20 -9
  97. package/dist/lib/menubar/snapshot.js +15 -2
  98. package/dist/lib/models.d.ts +42 -6
  99. package/dist/lib/models.js +55 -153
  100. package/dist/lib/perf/db.js +16 -3
  101. package/dist/lib/perf/types.d.ts +12 -1
  102. package/dist/lib/pricing/cost.d.ts +9 -0
  103. package/dist/lib/pricing/cost.js +24 -0
  104. package/dist/lib/pricing/index.d.ts +1 -1
  105. package/dist/lib/pricing/index.js +1 -1
  106. package/dist/lib/project-probe.d.ts +22 -3
  107. package/dist/lib/project-probe.js +105 -17
  108. package/dist/lib/project-status.d.ts +9 -0
  109. package/dist/lib/project-status.js +15 -0
  110. package/dist/lib/redact.js +8 -3
  111. package/dist/lib/refresh.js +22 -11
  112. package/dist/lib/remote-agents-json.d.ts +32 -0
  113. package/dist/lib/remote-agents-json.js +47 -16
  114. package/dist/lib/resource-profiles.js +1 -2
  115. package/dist/lib/rotate.d.ts +22 -0
  116. package/dist/lib/rotate.js +26 -0
  117. package/dist/lib/routine-notify-owner.d.ts +102 -0
  118. package/dist/lib/routine-notify-owner.js +232 -0
  119. package/dist/lib/routines.d.ts +10 -0
  120. package/dist/lib/runner.d.ts +9 -1
  121. package/dist/lib/runner.js +182 -17
  122. package/dist/lib/sandbox.d.ts +0 -2
  123. package/dist/lib/sandbox.js +2 -19
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  126. package/dist/lib/secrets/agent.d.ts +19 -3
  127. package/dist/lib/secrets/agent.js +61 -22
  128. package/dist/lib/secrets/audit.d.ts +1 -1
  129. package/dist/lib/secrets/audit.js +2 -0
  130. package/dist/lib/secrets/bundles.d.ts +3 -3
  131. package/dist/lib/secrets/bundles.js +15 -5
  132. package/dist/lib/secrets/filestore.d.ts +2 -0
  133. package/dist/lib/secrets/filestore.js +4 -0
  134. package/dist/lib/secrets/lease.d.ts +25 -0
  135. package/dist/lib/secrets/lease.js +44 -0
  136. package/dist/lib/secrets/session-store.d.ts +7 -0
  137. package/dist/lib/secrets/session-store.js +21 -0
  138. package/dist/lib/self-update.d.ts +20 -5
  139. package/dist/lib/self-update.js +93 -16
  140. package/dist/lib/session/active.d.ts +49 -0
  141. package/dist/lib/session/active.js +175 -11
  142. package/dist/lib/session/bash-command.d.ts +16 -0
  143. package/dist/lib/session/bash-command.js +65 -0
  144. package/dist/lib/session/db.d.ts +23 -2
  145. package/dist/lib/session/db.js +86 -20
  146. package/dist/lib/session/discover.d.ts +21 -1
  147. package/dist/lib/session/discover.js +124 -32
  148. package/dist/lib/session/insights.d.ts +19 -0
  149. package/dist/lib/session/insights.js +78 -10
  150. package/dist/lib/session/recovery.d.ts +17 -1
  151. package/dist/lib/session/recovery.js +111 -4
  152. package/dist/lib/session/remote-bundle.js +1 -1
  153. package/dist/lib/session/remote-list.d.ts +10 -9
  154. package/dist/lib/session/remote-list.js +25 -23
  155. package/dist/lib/session/resume-owner.d.ts +55 -0
  156. package/dist/lib/session/resume-owner.js +69 -0
  157. package/dist/lib/session/team-filter.d.ts +65 -0
  158. package/dist/lib/session/team-filter.js +98 -3
  159. package/dist/lib/session/types.d.ts +33 -0
  160. package/dist/lib/smart-launch.d.ts +31 -5
  161. package/dist/lib/smart-launch.js +43 -7
  162. package/dist/lib/ssh-exec.d.ts +27 -0
  163. package/dist/lib/ssh-exec.js +34 -1
  164. package/dist/lib/ssh-tunnel.d.ts +3 -2
  165. package/dist/lib/ssh-tunnel.js +25 -16
  166. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  167. package/dist/lib/staleness/detectors/skills.js +17 -2
  168. package/dist/lib/staleness/index.d.ts +10 -1
  169. package/dist/lib/staleness/index.js +28 -3
  170. package/dist/lib/startup/command-registry.d.ts +1 -0
  171. package/dist/lib/startup/command-registry.js +2 -0
  172. package/dist/lib/teams/agents.d.ts +1 -0
  173. package/dist/lib/teams/agents.js +38 -11
  174. package/dist/lib/teams/placement-probe.d.ts +1 -1
  175. package/dist/lib/teams/placement-probe.js +24 -24
  176. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  177. package/dist/lib/teams/remoteWorktree.js +29 -27
  178. package/dist/lib/tmux/session.d.ts +8 -0
  179. package/dist/lib/tmux/session.js +22 -0
  180. package/dist/lib/types.d.ts +2 -0
  181. package/dist/lib/versions.d.ts +1 -0
  182. package/dist/lib/versions.js +40 -22
  183. package/package.json +3 -1
@@ -6,13 +6,23 @@ export declare const SSH_CONN_FAILURE = 255;
6
6
  * {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
7
7
  * transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
8
8
  export declare const REMOTE_EXIT_255_REMAPPED = 254;
9
- /** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
10
- * {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
11
- * refills the budget, so a long session that blinks all day reconnects every time
12
- * only consecutive UNREACHABLE attempts exhaust it. */
9
+ /** Consecutive unproductive reattaches before giving up. Backoff is capped at
10
+ * {@link MAX_BACKOFF_MS}. A reattach that reconnected and HELD (then dropped again)
11
+ * refills the budget, so a long session that blinks all day reconnects every time
12
+ * an attempt that never reached the host, or reached it and died back inside
13
+ * {@link MIN_HOLD_MS}, counts against it. */
13
14
  export declare const MAX_ATTEMPTS = 6;
15
+ /** How long a reattach must hold the remote pane before it counts as a genuine
16
+ * reconnection that refills the budget. Measured on the interactive attach ALONE
17
+ * — the preflight probe has already returned by then, so this is not the connect
18
+ * timing the file header rules out. 10s is comfortably longer than any attach that
19
+ * dies during TTY negotiation and far shorter than a session the user is working
20
+ * in; a link that drops the user out inside 10s on every attempt is one this loop
21
+ * should stop retrying, not one it should keep re-entering. */
22
+ export declare const MIN_HOLD_MS = 10000;
14
23
  export interface ReconnectState {
15
- /** Consecutive failed-to-connect reattaches since the last genuine reconnection. */
24
+ /** Consecutive unproductive reattaches since the last genuine reconnection — one
25
+ * that reached the host and held for {@link MIN_HOLD_MS}. */
16
26
  attempt: number;
17
27
  }
18
28
  export interface ReconnectOutcome {
@@ -20,8 +30,13 @@ export interface ReconnectOutcome {
20
30
  code: number;
21
31
  /** Whether the ssh handshake for this attempt actually completed. The initial run
22
32
  * and any reattach whose preflight probe succeeded are `connected`; a reattach
23
- * that couldn't reach the host is not. Drives the budget refill (see file head). */
33
+ * that couldn't reach the host is not. Half of the budget refill (see file head). */
24
34
  connected: boolean;
35
+ /** Wall-clock ms the interactive attach ran for, timed from after the preflight
36
+ * probe returned. `0` when the attempt never reached the host. The other half of
37
+ * the refill: it must be at least {@link MIN_HOLD_MS} to count as a genuine
38
+ * reconnection rather than a link that drops the user straight back out. */
39
+ heldMs: number;
25
40
  }
26
41
  export type ReconnectDecision = {
27
42
  action: 'stop';
@@ -34,6 +49,14 @@ export type ReconnectDecision = {
34
49
  export declare function initialReconnectState(): ReconnectState;
35
50
  /** Exponential backoff capped at {@link MAX_BACKOFF_MS}: 2s, 4s, 8s, 16s, 30s… */
36
51
  export declare function backoffMs(attempt: number): number;
52
+ /**
53
+ * Did this attempt genuinely put the user back into the agent? Only such an attempt
54
+ * refills the retry budget — it must have reached the host AND held the pane for at
55
+ * least {@link MIN_HOLD_MS}. An attach that reached the host and died right back is
56
+ * a flapping link, not a reconnection, and counts against the budget like an
57
+ * unreachable host (agents-cli#1884; see the file header).
58
+ */
59
+ export declare function refillsBudget(outcome: ReconnectOutcome): boolean;
37
60
  /**
38
61
  * Decide what to do after a run/re-attach returned `outcome`. Pure — the only
39
62
  * input is the prior state and the outcome, the only output is the next action.
@@ -41,16 +64,25 @@ export declare function backoffMs(attempt: number): number;
41
64
  * - a non-255 code means the remote command spoke for itself (clean detach = 0,
42
65
  * agent exit / no live session = non-zero) → stop and surface that code.
43
66
  * - a 255 means the link dropped → retry, unless the budget is spent.
44
- * - a 255 from an attempt that DID connect (a genuine reconnection that then
45
- * dropped) refills the budget first; a 255 that never connected counts against
46
- * it, so a host that stays unreachable gives up after MAX_ATTEMPTS.
67
+ * - a 255 from an attempt that reconnected AND held ({@link refillsBudget})
68
+ * refills the budget first; every other 255 counts against it, so a host that
69
+ * stays unreachable — and a link that keeps dropping the attach immediately
70
+ * both give up after MAX_ATTEMPTS.
47
71
  */
48
72
  export declare function reconnectStep(state: ReconnectState, outcome: ReconnectOutcome): ReconnectDecision;
49
73
  /** Human-readable notice shown before each reconnect wait. "13 seconds", not "12.8s". */
50
74
  export declare function reconnectNotice(sessionId: string, host: string, attempt: number, waitMs: number): string;
51
- /** Notice shown once the retry budget is spent. Hands back the one verb that
52
- * re-enters the terminal — attach the live pane if it survived, else resume. */
75
+ /** Notice shown once the retry budget is spent on a host that stayed UNREACHABLE.
76
+ * Hands back the one verb that re-enters the terminal — attach the live pane if it
77
+ * survived, else resume. */
53
78
  export declare function exhaustedNotice(sessionId: string, host: string): string;
79
+ /** Notice shown when the budget is spent the OTHER way: the last reattach reached
80
+ * the host and the connection dropped again within {@link MIN_HOLD_MS}. Saying
81
+ * "couldn't reconnect" there would be false — it did reconnect and could not stay
82
+ * — and the user needs to know the link, not the host, is the problem. It claims
83
+ * no count of successful reconnections: the budget can also be spent by a run of
84
+ * unreachable attempts followed by one that reconnected and dropped straight out. */
85
+ export declare function unstableNotice(sessionId: string, host: string): string;
54
86
  /** Notice shown when a reattach stops on a remapped remote-side exit
55
87
  * ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
56
88
  * on for its own reasons, not the ssh transport dropping; see
@@ -94,7 +126,8 @@ export declare function reattachRemoteCommand(sessionId: string): string;
94
126
  * a reachable host do we run the interactive attach-or-resume (which carries no
95
127
  * credentials — the agent already runs on the peer — so it rides the normal
96
128
  * transport). Returns the ssh exit code (255 = dropped again / unreachable; 0 =
97
- * clean detach; other = session ended) plus whether this attempt connected.
129
+ * clean detach; other = session ended), whether this attempt connected, and how
130
+ * long the attach held — the two inputs {@link refillsBudget} decides on.
98
131
  */
99
132
  export declare function reattachRemoteSession(host: Host, sessionId: string): ReconnectOutcome;
100
133
  export interface ReconnectLoopOpts {
@@ -19,15 +19,20 @@
19
19
  * fall through to resume so the user is put back into the agent. There is one
20
20
  * re-attach implementation (the peer's focus) to keep in sync.
21
21
  *
22
- * **Why the budget refills on `connected`, not on call duration.** ssh returns 255
23
- * for BOTH "couldn't connect at all" and "connected, then the link dropped." A
24
- * failed connect still takes up to `ConnectTimeout` (10s) to return, so timing
25
- * alone can't tell the two apart — a duration threshold below the connect timeout
26
- * would classify every hung connect as a live session and retry forever under a
27
- * sustained outage (the exact failure this feature exists to survive). Instead each
28
- * attempt runs a fast preflight probe: a reattach that genuinely reconnected (and
29
- * then dropped again) refills the retry budget; one that never reached the host
30
- * 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}.
31
36
  *
32
37
  * The retry policy is a pure state machine (`reconnectStep`) so it is unit-tested
33
38
  * without touching SSH; the loop (`reconnectInteractiveSession`) only adds the real
@@ -54,16 +59,21 @@
54
59
  * regardless of the peer's `agents` version (the remap happens in the shell
55
60
  * wrapper THIS process sends).
56
61
  *
57
- * This does NOT close every way `reconnectStep` can loop on a real transport
58
- * 255: a genuinely recurring LOCAL ssh failure (a fast-flapping link, an
59
- * attach that dies at the TTY stage on every reconnect) still refills the
60
- * budget every time by design (see "Why the budget refills on `connected`"
61
- * above) and can still print "attempt 1/N" indefinitely. That's an accepted,
62
- * pre-existing tradeoff of the original feature, not something this fix
63
- * 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.
64
74
  */
65
75
  import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
66
- import { sshTargetFor } from './types.js';
76
+ import { hostIdentityArgs, sshTargetFor } from './types.js';
67
77
  /** ssh's connection-layer failure code — the signal that the link dropped rather
68
78
  * than the remote command exiting on its own. Mirrors ssh-exec.ts `sshStream`. */
69
79
  export const SSH_CONN_FAILURE = 255;
@@ -71,13 +81,22 @@ export const SSH_CONN_FAILURE = 255;
71
81
  * {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
72
82
  * transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
73
83
  export const REMOTE_EXIT_255_REMAPPED = 254;
74
- /** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
75
- * {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
76
- * refills the budget, so a long session that blinks all day reconnects every time
77
- * 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. */
78
89
  export const MAX_ATTEMPTS = 6;
79
90
  const BASE_BACKOFF_MS = 2_000;
80
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;
81
100
  export function initialReconnectState() {
82
101
  return { attempt: 0 };
83
102
  }
@@ -85,6 +104,16 @@ export function initialReconnectState() {
85
104
  export function backoffMs(attempt) {
86
105
  return Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
87
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
+ }
88
117
  /**
89
118
  * Decide what to do after a run/re-attach returned `outcome`. Pure — the only
90
119
  * input is the prior state and the outcome, the only output is the next action.
@@ -92,14 +121,15 @@ export function backoffMs(attempt) {
92
121
  * - a non-255 code means the remote command spoke for itself (clean detach = 0,
93
122
  * agent exit / no live session = non-zero) → stop and surface that code.
94
123
  * - a 255 means the link dropped → retry, unless the budget is spent.
95
- * - a 255 from an attempt that DID connect (a genuine reconnection that then
96
- * dropped) refills the budget first; a 255 that never connected counts against
97
- * 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.
98
128
  */
99
129
  export function reconnectStep(state, outcome) {
100
130
  if (outcome.code !== SSH_CONN_FAILURE)
101
131
  return { action: 'stop', code: outcome.code };
102
- const attempts = outcome.connected ? 0 : state.attempt;
132
+ const attempts = refillsBudget(outcome) ? 0 : state.attempt;
103
133
  if (attempts >= MAX_ATTEMPTS)
104
134
  return { action: 'stop', code: SSH_CONN_FAILURE };
105
135
  return { action: 'retry', waitMs: backoffMs(attempts), state: { attempt: attempts + 1 } };
@@ -110,11 +140,22 @@ export function reconnectNotice(sessionId, host, attempt, waitMs) {
110
140
  const when = secs <= 1 ? 'now' : `in ${secs} seconds`;
111
141
  return `\nConnection to ${host} dropped — the agent is still running there. Reconnecting to ${sessionId.slice(0, 8)} ${when} (attempt ${attempt}/${MAX_ATTEMPTS})…\n`;
112
142
  }
113
- /** Notice shown once the retry budget is spent. Hands back the one verb that
114
- * re-enters the terminal — attach the live pane if it survived, else resume. */
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
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`;
117
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`;
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
120
161
  * on for its own reasons, not the ssh transport dropping; see
@@ -168,16 +209,23 @@ export function reattachRemoteCommand(sessionId) {
168
209
  * a reachable host do we run the interactive attach-or-resume (which carries no
169
210
  * credentials — the agent already runs on the peer — so it rides the normal
170
211
  * transport). Returns the ssh exit code (255 = dropped again / unreachable; 0 =
171
- * clean detach; other = session ended) plus whether this attempt connected.
212
+ * clean detach; other = session ended), whether this attempt connected, and how
213
+ * long the attach held — the two inputs {@link refillsBudget} decides on.
172
214
  */
173
215
  export function reattachRemoteSession(host, sessionId) {
174
216
  const target = sshTargetFor(host);
217
+ const extraSshArgs = hostIdentityArgs(host);
175
218
  // Fresh (non-multiplexed) reachability probe: code 0 means the handshake actually
176
219
  // completed, so a hung/failed connect is never mistaken for a live reconnection.
177
- const probe = sshExec(target, 'true', { multiplex: false });
220
+ // RUSH-2265: pass host identity on every hop (probe + stream), not only the first.
221
+ const probe = sshExec(target, 'true', { multiplex: false, extraSshArgs });
178
222
  if (probe.code !== 0)
179
- return { code: SSH_CONN_FAILURE, connected: false };
180
- return { code: sshStream(target, reattachRemoteCommand(sessionId), { tty: true }), connected: true };
223
+ return { code: SSH_CONN_FAILURE, connected: false, heldMs: 0 };
224
+ // Timed from AFTER the probe returned, so this is the attach's own duration and
225
+ // carries none of the connect phase the file header rules out as a signal.
226
+ const startedAt = Date.now();
227
+ const code = sshStream(target, reattachRemoteCommand(sessionId), { tty: true, extraSshArgs });
228
+ return { code, connected: true, heldMs: Date.now() - startedAt };
181
229
  }
182
230
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
183
231
  /**
@@ -190,15 +238,25 @@ export async function reconnectInteractiveSession(opts) {
190
238
  const wait = opts.wait ?? sleep;
191
239
  const reattach = opts.reattach ?? reattachRemoteSession;
192
240
  let state = initialReconnectState();
193
- // The initial run reached the point of running the agent, so it connected.
194
- let outcome = { code: opts.initialExit, connected: true };
241
+ // The initial run reached the point of running the agent, so it connected. Its
242
+ // hold duration is never measured — exec.ts owns that call — and never needs to
243
+ // be: refilling is a no-op at attempt 0, which is where the loop starts, so this
244
+ // outcome can only ever produce the first retry either way.
245
+ let outcome = { code: opts.initialExit, connected: true, heldMs: 0 };
195
246
  for (;;) {
196
247
  const decision = reconnectStep(state, outcome);
197
248
  if (decision.action === 'stop') {
198
- if (decision.code === SSH_CONN_FAILURE)
199
- write(exhaustedNotice(opts.sessionId, opts.host.name));
200
- else if (decision.code === REMOTE_EXIT_255_REMAPPED)
249
+ // A spent budget has two shapes and one wrong message: `outcome` is the
250
+ // reattach that spent it, so an unreachable host reads "couldn't reconnect"
251
+ // and a link that reconnected but kept dropping reads as exactly that.
252
+ if (decision.code === SSH_CONN_FAILURE) {
253
+ write(outcome.connected
254
+ ? unstableNotice(opts.sessionId, opts.host.name)
255
+ : exhaustedNotice(opts.sessionId, opts.host.name));
256
+ }
257
+ else if (decision.code === REMOTE_EXIT_255_REMAPPED) {
201
258
  write(remoteExitNotice(opts.sessionId, opts.host.name));
259
+ }
202
260
  return decision.code;
203
261
  }
204
262
  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
  /**
@@ -94,6 +94,7 @@ function deviceHost(device, user, overlay) {
94
94
  source: 'inline',
95
95
  ...(address ? { address } : {}),
96
96
  user: user ?? device.user,
97
+ identityFile: device.auth.identityFile,
97
98
  os: device.platform !== 'unknown' ? device.platform : overlay?.os,
98
99
  ...(overlay?.caps?.length ? { caps: overlay.caps } : {}),
99
100
  enrolled: true,
@@ -148,11 +149,9 @@ function literalHost(token, host, user) {
148
149
  * into a dispatch verdict.
149
150
  */
150
151
  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
152
+ // Generic host-only callers resolve `auto` through the affinity engine here;
153
+ // harness-aware run/team placement resolves it earlier with live probes. A
154
+ // `null` plan.host means the affinity engine picked this
156
155
  // very machine; resolve that as the local device/host entry (if any) rather than
157
156
  // returning nothing — callers that already special-case "target is this
158
157
  // 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,9 @@ 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;
17
+ remoteShell?: 'posix' | 'powershell';
15
18
  agent: string;
16
19
  prompt: string;
17
20
  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),
@@ -1,8 +1,9 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { buildRoutineListJson } from '../../commands/routines.js';
4
- import { backfillActiveRowsFromIndex, serializeActiveSessionsForJson, serializeSessionsJson } from '../../commands/sessions.js';
4
+ import { backfillActiveRowsFromIndex, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson } from '../../commands/sessions.js';
5
5
  import { getConfigValue } from '../device-config.js';
6
+ import { machineId } from '../machine-id.js';
6
7
  import { querySessions } from '../session/db.js';
7
8
  import { readActiveSessionsCache } from '../session/session-cache.js';
8
9
  import { getRuntimeStateDir } from '../state.js';
@@ -21,7 +22,19 @@ export async function computeMenubarSnapshot() {
21
22
  Promise.resolve(querySessions({ limit: 40, skipExistenceCheck: true })),
22
23
  ]);
23
24
  const active = readActiveSessionsCache('local');
24
- const activeSessions = active?.sessions ?? [];
25
+ const rawSessions = active?.sessions ?? [];
26
+ // The raw cache is never filtered at write time (RUSH-2336) — it retains
27
+ // queued/closed/crashed rows so `--queued`/`--closed`/`--crashed` can
28
+ // recover them, and the daemon's warm-tick gather (unlike the CLI's own
29
+ // local gather) never stamps `machine` on a row. Stamp self here — this IS
30
+ // the 'local' scope by construction — then apply the ONE canonical
31
+ // bare-active selector so the menubar never shows a retained dead/queued
32
+ // row nor a process row of unverified liveness.
33
+ const self = machineId();
34
+ for (const s of rawSessions)
35
+ if (!s.machine)
36
+ s.machine = self;
37
+ const activeSessions = rawSessions.filter(isRunningLiveSession);
25
38
  backfillActiveRowsFromIndex(activeSessions);
26
39
  return {
27
40
  version: 1,