@phnx-labs/agents-cli 1.22.46 → 1.22.49

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 (348) hide show
  1. package/CHANGELOG.md +663 -0
  2. package/LICENSE +102 -182
  3. package/README.md +96 -49
  4. package/dist/bootstrap.js +4 -2
  5. package/dist/cli/command-registry.d.ts +2 -1
  6. package/dist/cli/command-registry.js +6 -2
  7. package/dist/commands/accounts.d.ts +58 -0
  8. package/dist/commands/accounts.js +243 -25
  9. package/dist/commands/artifacts.js +7 -6
  10. package/dist/commands/attach.js +1 -1
  11. package/dist/commands/auth.js +7 -5
  12. package/dist/commands/browser-sessions-picker.js +9 -7
  13. package/dist/commands/browser.js +711 -62
  14. package/dist/commands/config.d.ts +0 -1
  15. package/dist/commands/config.js +26 -24
  16. package/dist/commands/cost.d.ts +1 -1
  17. package/dist/commands/daemon.d.ts +21 -0
  18. package/dist/commands/daemon.js +147 -12
  19. package/dist/commands/detach.d.ts +13 -0
  20. package/dist/commands/detach.js +11 -8
  21. package/dist/commands/exec.js +107 -53
  22. package/dist/commands/feed-watch.d.ts +2 -0
  23. package/dist/commands/feed-watch.js +28 -0
  24. package/dist/commands/feed.js +27 -0
  25. package/dist/commands/feedback.js +3 -19
  26. package/dist/commands/focus.d.ts +14 -0
  27. package/dist/commands/focus.js +48 -5
  28. package/dist/commands/go.d.ts +3 -0
  29. package/dist/commands/go.js +16 -3
  30. package/dist/commands/insights.d.ts +1 -1
  31. package/dist/commands/insights.js +2 -2
  32. package/dist/commands/inspect.js +2 -2
  33. package/dist/commands/lease.d.ts +1 -1
  34. package/dist/commands/lease.js +7 -6
  35. package/dist/commands/mailboxes.js +4 -3
  36. package/dist/commands/menubar.js +11 -4
  37. package/dist/commands/projects.d.ts +16 -1
  38. package/dist/commands/projects.js +58 -64
  39. package/dist/commands/resume.js +1 -1
  40. package/dist/commands/route.js +4 -4
  41. package/dist/commands/routines.js +4 -3
  42. package/dist/commands/run-broadcast.d.ts +1 -1
  43. package/dist/commands/run-broadcast.js +1 -1
  44. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  45. package/dist/commands/sessions-picker-factory.js +1 -1
  46. package/dist/commands/sessions-picker.d.ts +22 -0
  47. package/dist/commands/sessions-picker.js +48 -9
  48. package/dist/commands/sessions-stop.d.ts +24 -0
  49. package/dist/commands/sessions-stop.js +66 -0
  50. package/dist/commands/sessions-trace.d.ts +82 -0
  51. package/dist/commands/sessions-trace.js +387 -0
  52. package/dist/commands/sessions.js +5 -0
  53. package/dist/commands/setup-secrets.js +23 -0
  54. package/dist/commands/share.d.ts +20 -5
  55. package/dist/commands/share.js +152 -52
  56. package/dist/commands/ssh.d.ts +15 -0
  57. package/dist/commands/ssh.js +243 -18
  58. package/dist/commands/teams.js +34 -2
  59. package/dist/commands/traces.d.ts +2 -0
  60. package/dist/commands/traces.js +184 -0
  61. package/dist/commands/view.d.ts +2 -0
  62. package/dist/commands/view.js +32 -62
  63. package/dist/commands/watchdog.js +9 -14
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js +5 -0
  66. package/dist/lib/account-capabilities.d.ts +4 -0
  67. package/dist/lib/account-capabilities.js +13 -9
  68. package/dist/lib/account-registry.d.ts +2 -0
  69. package/dist/lib/account-registry.js +30 -2
  70. package/dist/lib/accounting/rotate.d.ts +9 -0
  71. package/dist/lib/accounting/rotate.js +12 -2
  72. package/dist/lib/accounting/usage.d.ts +84 -6
  73. package/dist/lib/accounting/usage.js +293 -75
  74. package/dist/lib/agent-modes.js +29 -1
  75. package/dist/lib/agent-spec/agents.d.ts +4 -3
  76. package/dist/lib/agent-spec/agents.js +25 -15
  77. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  78. package/dist/lib/analytics/mix-commands.js +2 -2
  79. package/dist/lib/auth-health.d.ts +13 -3
  80. package/dist/lib/auth-health.js +50 -8
  81. package/dist/lib/browser/devices.d.ts +19 -0
  82. package/dist/lib/browser/devices.js +23 -0
  83. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  84. package/dist/lib/browser/drivers/ssh.js +36 -9
  85. package/dist/lib/browser/ipc.d.ts +1 -1
  86. package/dist/lib/browser/ipc.js +40 -9
  87. package/dist/lib/browser/profiles.d.ts +104 -65
  88. package/dist/lib/browser/profiles.js +277 -165
  89. package/dist/lib/browser/registry.d.ts +39 -0
  90. package/dist/lib/browser/registry.js +149 -0
  91. package/dist/lib/browser/remote-control.d.ts +28 -2
  92. package/dist/lib/browser/remote-control.js +35 -3
  93. package/dist/lib/browser/resolve-target.d.ts +68 -0
  94. package/dist/lib/browser/resolve-target.js +250 -0
  95. package/dist/lib/browser/runtime-state.d.ts +44 -11
  96. package/dist/lib/browser/runtime-state.js +98 -15
  97. package/dist/lib/browser/service.d.ts +65 -9
  98. package/dist/lib/browser/service.js +196 -61
  99. package/dist/lib/browser/sessions-list.d.ts +1 -3
  100. package/dist/lib/browser/sessions-list.js +3 -16
  101. package/dist/lib/browser/task-index.d.ts +70 -0
  102. package/dist/lib/browser/task-index.js +180 -0
  103. package/dist/lib/browser/types.d.ts +12 -1
  104. package/dist/lib/byok-usage.js +2 -2
  105. package/dist/lib/claude-statusline.d.ts +29 -0
  106. package/dist/lib/claude-statusline.js +139 -0
  107. package/dist/lib/cloud/host.d.ts +1 -1
  108. package/dist/lib/cloud/host.js +1 -1
  109. package/dist/lib/cloud/rush.js +1 -1
  110. package/dist/lib/codex-policy.d.ts +11 -1
  111. package/dist/lib/codex-policy.js +20 -5
  112. package/dist/lib/computer/computer-rpc.js +2 -2
  113. package/dist/lib/computer/download.d.ts +15 -15
  114. package/dist/lib/computer/download.js +35 -98
  115. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  116. package/dist/lib/computer/ssh-tunnel.js +12 -22
  117. package/dist/lib/config-keys.d.ts +6 -11
  118. package/dist/lib/config-keys.js +18 -19
  119. package/dist/lib/crabbox/cli.d.ts +1 -1
  120. package/dist/lib/crabbox/cli.js +2 -2
  121. package/dist/lib/crabbox/lease.d.ts +9 -0
  122. package/dist/lib/crabbox/lease.js +70 -15
  123. package/dist/lib/crabbox/runtimes.js +1 -1
  124. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  125. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  126. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  127. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  128. package/dist/lib/daemon/daemon.d.ts +3 -0
  129. package/dist/lib/daemon/daemon.js +212 -444
  130. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  131. package/dist/lib/daemon/device-probe-service.js +47 -0
  132. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  133. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  134. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  135. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  136. package/dist/lib/daemon/runner.js +15 -2
  137. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  138. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  139. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  140. package/dist/lib/daemon/self-heal-service.js +48 -0
  141. package/dist/lib/daemon/service.d.ts +102 -0
  142. package/dist/lib/daemon/service.js +98 -0
  143. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  144. package/dist/lib/daemon/session-index-service.js +40 -0
  145. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  146. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  147. package/dist/lib/daemon/supervisor.d.ts +87 -0
  148. package/dist/lib/daemon/supervisor.js +298 -0
  149. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  150. package/dist/lib/daemon/watchdog-service.js +40 -0
  151. package/dist/lib/daemon-health.d.ts +15 -0
  152. package/dist/lib/daemon-health.js +29 -5
  153. package/dist/lib/daemon-services.d.ts +8 -1
  154. package/dist/lib/daemon-services.js +55 -0
  155. package/dist/lib/daemon-ticks.d.ts +13 -2
  156. package/dist/lib/daemon-ticks.js +22 -20
  157. package/dist/lib/device-config.d.ts +25 -10
  158. package/dist/lib/device-config.js +90 -39
  159. package/dist/lib/devices/config-migration.d.ts +7 -0
  160. package/dist/lib/devices/config-migration.js +67 -3
  161. package/dist/lib/devices/health.d.ts +27 -2
  162. package/dist/lib/devices/health.js +39 -9
  163. package/dist/lib/devices/interactive-host.d.ts +15 -0
  164. package/dist/lib/devices/interactive-host.js +53 -0
  165. package/dist/lib/devices/registry.d.ts +61 -4
  166. package/dist/lib/devices/registry.js +94 -38
  167. package/dist/lib/devices/stats-cache.d.ts +26 -0
  168. package/dist/lib/devices/stats-cache.js +59 -7
  169. package/dist/lib/devices/worker-pick.d.ts +48 -0
  170. package/dist/lib/devices/worker-pick.js +113 -0
  171. package/dist/lib/exec.d.ts +70 -14
  172. package/dist/lib/exec.js +110 -38
  173. package/dist/lib/feed/answer.d.ts +29 -0
  174. package/dist/lib/feed/answer.js +145 -0
  175. package/dist/lib/feed/attention.d.ts +83 -0
  176. package/dist/lib/feed/attention.js +0 -0
  177. package/dist/lib/feed/feed.d.ts +109 -0
  178. package/dist/lib/feed/feed.js +154 -3
  179. package/dist/lib/feed/pr-status.d.ts +14 -0
  180. package/dist/lib/feed/pr-status.js +47 -0
  181. package/dist/lib/feed/watch.d.ts +59 -0
  182. package/dist/lib/feed/watch.js +180 -0
  183. package/dist/lib/fleet/capture.js +6 -0
  184. package/dist/lib/fleet/remote-login.js +8 -3
  185. package/dist/lib/fleet/types.d.ts +17 -0
  186. package/dist/lib/harness/adapter.d.ts +11 -0
  187. package/dist/lib/harness/adapters/claude.js +38 -24
  188. package/dist/lib/harness/adapters/codex.js +2 -4
  189. package/dist/lib/harness/adapters/cursor.js +8 -11
  190. package/dist/lib/helper-download.d.ts +108 -0
  191. package/dist/lib/helper-download.js +194 -0
  192. package/dist/lib/helper-versions.d.ts +52 -0
  193. package/dist/lib/helper-versions.js +58 -0
  194. package/dist/lib/hooks/cache.js +19 -0
  195. package/dist/lib/hooks/install.js +2 -2
  196. package/dist/lib/hooks/match.d.ts +3 -0
  197. package/dist/lib/hooks/match.js +25 -0
  198. package/dist/lib/hosts/credentials.d.ts +1 -1
  199. package/dist/lib/hosts/credentials.js +1 -1
  200. package/dist/lib/hosts/dispatch.d.ts +7 -1
  201. package/dist/lib/hosts/dispatch.js +24 -5
  202. package/dist/lib/hosts/passthrough.d.ts +8 -15
  203. package/dist/lib/hosts/passthrough.js +35 -16
  204. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  205. package/dist/lib/hosts/providers/devices.js +1 -1
  206. package/dist/lib/hosts/reconnect.d.ts +179 -21
  207. package/dist/lib/hosts/reconnect.js +266 -45
  208. package/dist/lib/hosts/registry.js +7 -0
  209. package/dist/lib/installations/migrate.d.ts +4 -5
  210. package/dist/lib/installations/migrate.js +6 -7
  211. package/dist/lib/installations/shims.d.ts +1 -1
  212. package/dist/lib/installations/shims.js +23 -13
  213. package/dist/lib/installations/versions.js +25 -2
  214. package/dist/lib/linear-autoclose.d.ts +1 -1
  215. package/dist/lib/linear-autoclose.js +1 -1
  216. package/dist/lib/loop.d.ts +2 -2
  217. package/dist/lib/loop.js +1 -1
  218. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  219. package/dist/lib/menubar/download-menubar.js +60 -0
  220. package/dist/lib/menubar/install-menubar.d.ts +44 -7
  221. package/dist/lib/menubar/install-menubar.js +105 -23
  222. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  223. package/dist/lib/menubar/notify-desktop.js +6 -6
  224. package/dist/lib/model-tiers.d.ts +1 -1
  225. package/dist/lib/open-url.d.ts +60 -2
  226. package/dist/lib/open-url.js +167 -12
  227. package/dist/lib/project-probe.d.ts +1 -1
  228. package/dist/lib/project-probe.js +1 -1
  229. package/dist/lib/project-pull.d.ts +1 -1
  230. package/dist/lib/project-pull.js +2 -2
  231. package/dist/lib/projects.d.ts +1 -1
  232. package/dist/lib/projects.js +1 -1
  233. package/dist/lib/refresh-coordinator.d.ts +1 -1
  234. package/dist/lib/refresh-coordinator.js +1 -1
  235. package/dist/lib/sandbox.js +6 -7
  236. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  237. package/dist/lib/secrets/download-keychain.js +70 -0
  238. package/dist/lib/secrets/install-helper.d.ts +20 -1
  239. package/dist/lib/secrets/install-helper.js +44 -3
  240. package/dist/lib/session/active.d.ts +67 -0
  241. package/dist/lib/session/active.js +86 -2
  242. package/dist/lib/session/db.d.ts +11 -1
  243. package/dist/lib/session/db.js +142 -8
  244. package/dist/lib/session/discover.d.ts +23 -1
  245. package/dist/lib/session/discover.js +44 -10
  246. package/dist/lib/session/highlights.d.ts +49 -1
  247. package/dist/lib/session/highlights.js +95 -0
  248. package/dist/lib/session/host-link.d.ts +16 -2
  249. package/dist/lib/session/host-link.js +7 -1
  250. package/dist/lib/session/parse.js +33 -1
  251. package/dist/lib/session/prompt.d.ts +35 -0
  252. package/dist/lib/session/prompt.js +56 -0
  253. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  254. package/dist/lib/session/remote/remote-list.js +19 -2
  255. package/dist/lib/session/remote/watch.d.ts +1 -1
  256. package/dist/lib/session/remote/watch.js +17 -3
  257. package/dist/lib/session/render.js +7 -3
  258. package/dist/lib/session/session-cache.d.ts +53 -0
  259. package/dist/lib/session/session-cache.js +93 -1
  260. package/dist/lib/session/state.js +1 -1
  261. package/dist/lib/session/tool-calls.d.ts +2 -0
  262. package/dist/lib/session/tool-calls.js +1 -1
  263. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  264. package/dist/lib/session/trajectory-compare.js +116 -0
  265. package/dist/lib/session/trajectory-html.d.ts +19 -0
  266. package/dist/lib/session/trajectory-html.js +770 -0
  267. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  268. package/dist/lib/session/trajectory-lineage.js +195 -0
  269. package/dist/lib/session/trajectory-text.d.ts +37 -0
  270. package/dist/lib/session/trajectory-text.js +284 -0
  271. package/dist/lib/session/trajectory.d.ts +102 -0
  272. package/dist/lib/session/trajectory.js +384 -0
  273. package/dist/lib/session/types.d.ts +15 -0
  274. package/dist/lib/sha256-asset.d.ts +26 -0
  275. package/dist/lib/sha256-asset.js +41 -0
  276. package/dist/lib/share/backend.d.ts +95 -0
  277. package/dist/lib/share/backend.js +139 -0
  278. package/dist/lib/share/delete.d.ts +6 -2
  279. package/dist/lib/share/delete.js +8 -9
  280. package/dist/lib/share/html.d.ts +22 -0
  281. package/dist/lib/share/html.js +88 -0
  282. package/dist/lib/share/provision.d.ts +18 -4
  283. package/dist/lib/share/provision.js +38 -4
  284. package/dist/lib/share/publish.d.ts +39 -15
  285. package/dist/lib/share/publish.js +55 -26
  286. package/dist/lib/share/worker-template.js +171 -11
  287. package/dist/lib/sqlite.js +16 -3
  288. package/dist/lib/ssh-exec.d.ts +65 -0
  289. package/dist/lib/ssh-exec.js +120 -4
  290. package/dist/lib/startup/command-registry.d.ts +3 -1
  291. package/dist/lib/startup/command-registry.js +6 -3
  292. package/dist/lib/startup/dev-build.js +3 -3
  293. package/dist/lib/state.d.ts +2 -2
  294. package/dist/lib/state.js +11 -5
  295. package/dist/lib/subagents-registry.js +1 -1
  296. package/dist/lib/teams/agents.js +9 -0
  297. package/dist/lib/terminal/index.d.ts +1 -1
  298. package/dist/lib/terminal/inject.d.ts +19 -0
  299. package/dist/lib/terminal/inject.js +19 -9
  300. package/dist/lib/terminal/types.d.ts +1 -1
  301. package/dist/lib/terminal/types.js +1 -1
  302. package/dist/lib/tmux/orphan-reap.js +1 -1
  303. package/dist/lib/tmux/session.d.ts +46 -13
  304. package/dist/lib/tmux/session.js +204 -27
  305. package/dist/lib/traces/backend.d.ts +29 -0
  306. package/dist/lib/traces/backend.js +46 -0
  307. package/dist/lib/traces/classify.d.ts +52 -0
  308. package/dist/lib/traces/classify.js +72 -0
  309. package/dist/lib/traces/config.d.ts +10 -0
  310. package/dist/lib/traces/config.js +2 -0
  311. package/dist/lib/traces/provision.d.ts +19 -0
  312. package/dist/lib/traces/provision.js +30 -0
  313. package/dist/lib/traces/sync.d.ts +150 -0
  314. package/dist/lib/traces/sync.js +447 -0
  315. package/dist/lib/traces/worker-template.d.ts +4 -0
  316. package/dist/lib/traces/worker-template.js +159 -0
  317. package/dist/lib/types.d.ts +34 -19
  318. package/dist/lib/types.js +14 -0
  319. package/dist/lib/usage-backoff.d.ts +2 -1
  320. package/dist/lib/usage-backoff.js +60 -19
  321. package/dist/lib/usage-refresh.d.ts +18 -4
  322. package/dist/lib/usage-refresh.js +68 -19
  323. package/dist/lib/watchdog/log.d.ts +1 -1
  324. package/dist/lib/watchdog/log.js +1 -1
  325. package/dist/lib/watchdog/runner.d.ts +36 -45
  326. package/dist/lib/watchdog/runner.js +150 -119
  327. package/dist/lib/watchdog/service.d.ts +0 -1
  328. package/dist/lib/watchdog/service.js +0 -1
  329. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  330. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  331. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  332. package/dist/lib/watchdog/watchdog.js +37 -104
  333. package/dist/lib/workflows.d.ts +1 -1
  334. package/package.json +5 -7
  335. package/dist/commands/usage.d.ts +0 -27
  336. package/dist/commands/usage.js +0 -96
  337. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  338. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  339. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  340. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  341. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  342. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  343. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -24
  344. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  345. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -123
  346. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  347. package/dist/lib/usage-fleet.d.ts +0 -32
  348. package/dist/lib/usage-fleet.js +0 -125
@@ -4,12 +4,24 @@
4
4
  *
5
5
  * A remote interactive agent runs in a DETACHED tmux session on the peer (see
6
6
  * lib/exec.ts `runInTmux`), so a network blink kills only the local ssh client —
7
- * the agent keeps running. `sshStream` reports that drop as exit code 255 (ssh's
7
+ * the agent keeps running.
8
+ *
9
+ * **That premise is a guarantee, not a hope, only since RUSH-3125.** It used to
10
+ * rest on the peer's `tmux.enabled`, an ergonomics preference that defaults OFF
11
+ * — so on a default box the agent was a child of the sshd session, a blink
12
+ * SIGHUPed it, and this file reconnected to a corpse while telling the user it
13
+ * was "still running there." The interactive dispatch now exports
14
+ * REMOTE_INTERACTIVE_ENV and exec.ts `resolveTmuxWrap` wraps on it regardless of
15
+ * that toggle, so what is written below actually holds. Anything that would let
16
+ * a remote interactive run reach the peer unwrapped breaks this whole file.
17
+ *
18
+ * `sshStream` reports that drop as exit code 255 (ssh's
8
19
  * own connection-layer failure; see ssh-exec.ts). Without this, exec.ts would
9
20
  * `process.exit(255)` and the user would have to notice, find the session id, and
10
21
  * `agents sessions focus` by hand. Instead we re-attach the live remote pane over
11
22
  * SSH automatically, with bounded backoff, until the user detaches cleanly (the
12
- * remote returns 0) or the agent exits (the tmux session is gone; a non-255 code).
23
+ * remote returns 0), the agent exits (the tmux session is gone; a non-255 code),
24
+ * or the user interrupts the wait with Ctrl-C ({@link waitOrInterrupt} → 130).
13
25
  *
14
26
  * The re-attach reuses the peer's OWN recovery verb — `agents sessions focus <id>
15
27
  * --local` — which JOINS the live local tmux pane there (a second client, no fork)
@@ -32,7 +44,8 @@
32
44
  * {@link MIN_HOLD_MS} refills the retry budget, so a long session that blinks all
33
45
  * day keeps reconnecting. Everything else — never reached the host, or reached it
34
46
  * 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}.
47
+ * fast-flapping link both give up once {@link RECONNECT_WINDOW_MS} of unproductive
48
+ * retrying has elapsed.
36
49
  *
37
50
  * The retry policy is a pure state machine (`reconnectStep`) so it is unit-tested
38
51
  * without touching SSH; the loop (`reconnectInteractiveSession`) only adds the real
@@ -47,7 +60,7 @@
47
60
  * that same 255, `reconnectStep` couldn't tell it apart from a genuine drop, and
48
61
  * `connected: true` would refill the retry budget forever — "attempt 1/N" printed
49
62
  * on every single cycle, the terminal filling with aborted-TTY escape-code
50
- * garbage, `MAX_ATTEMPTS` never actually bounding anything.
63
+ * garbage, the retry window never actually bounding anything.
51
64
  *
52
65
  * The resume fall-through only widens that surface — the peer's focus now runs a
53
66
  * full recovery path (`resumeSessionInPlace` / `runOnPeer`) on a dead pane, any
@@ -63,8 +76,8 @@
63
76
  * remap alone did not close it (agents-cli#1884): `connected` was set by the
64
77
  * preflight probe and said nothing about whether the attach that followed held, so
65
78
  * 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
79
+ * single time — refilled the budget on every cycle, printed "attempt 1" forever,
80
+ * and left the retry window bounding nothing. The {@link MIN_HOLD_MS} floor above is
68
81
  * what closes it: an attach that dies immediately is not a reconnection, so the
69
82
  * budget drains and the loop gives up with {@link unstableNotice}. A flat total-
70
83
  * attempt or wall-clock ceiling that ignored `connected` was the alternative and is
@@ -72,21 +85,37 @@
72
85
  * session this feature exists for, while the hold floor only ever stops a loop that
73
86
  * is failing to put the user back into the agent.
74
87
  */
75
- import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
88
+ import { sshExec, sshStream, shellQuote, SSH_CONN_FAILURE_CODE } from '../ssh-exec.js';
76
89
  import { hostIdentityArgs, sshTargetFor } from './types.js';
90
+ import { RUN_AUTO_KEYWORD } from '../types.js';
77
91
  /** ssh's connection-layer failure code — the signal that the link dropped rather
78
- * than the remote command exiting on its own. Mirrors ssh-exec.ts `sshStream`. */
79
- export const SSH_CONN_FAILURE = 255;
92
+ * than the remote command exiting on its own. Re-exported from ssh-exec.ts, which
93
+ * owns the ssh invocation, so the two cannot drift apart. */
94
+ export const SSH_CONN_FAILURE = SSH_CONN_FAILURE_CODE;
80
95
  /** What a would-be-255 remote-origin exit code is remapped to by
81
96
  * {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
82
97
  * transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
83
98
  export const REMOTE_EXIT_255_REMAPPED = 254;
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. */
89
- export const MAX_ATTEMPTS = 6;
99
+ /**
100
+ * How long the loop keeps trying across an UNPRODUCTIVE streak before giving up.
101
+ *
102
+ * A wall-clock window, not an attempt count, because what it has to outlast is
103
+ * measured in minutes: a laptop lid close, a Wi-Fi handoff, a VPN or Tailscale
104
+ * re-auth, a router reboot. The previous `MAX_ATTEMPTS = 6` over a 2/4/8/16/30/30
105
+ * backoff gave up after about **90 seconds** — shorter than any of them
106
+ * (RUSH-3125). Worse, timers are suspended across sleep, so on wake the whole
107
+ * backoff fired back-to-back before the network was up and the budget was gone in
108
+ * seconds.
109
+ *
110
+ * It bounds the STREAK, not the session: a reattach that reconnects and HOLDS
111
+ * resets it to zero ({@link refillsBudget}), so a session that blinks all day
112
+ * still reconnects every time. That is the property the file header insists on,
113
+ * and a flat total would break it — this changes only how a *streak* is bounded.
114
+ */
115
+ export const RECONNECT_WINDOW_MS = 15 * 60_000;
116
+ /** Backoff curve for a streak: 2s, 4s, 8s, 16s, 30s, then 30s until the window
117
+ * closes. Capped so a long outage keeps probing at a useful cadence instead of
118
+ * drifting out to multi-minute gaps and missing the moment the link returns. */
90
119
  const BASE_BACKOFF_MS = 2_000;
91
120
  const MAX_BACKOFF_MS = 30_000;
92
121
  /** How long a reattach must hold the remote pane before it counts as a genuine
@@ -98,7 +127,7 @@ const MAX_BACKOFF_MS = 30_000;
98
127
  * should stop retrying, not one it should keep re-entering. */
99
128
  export const MIN_HOLD_MS = 10_000;
100
129
  export function initialReconnectState() {
101
- return { attempt: 0 };
130
+ return { attempt: 0, unproductiveMs: 0 };
102
131
  }
103
132
  /** Exponential backoff capped at {@link MAX_BACKOFF_MS}: 2s, 4s, 8s, 16s, 30s… */
104
133
  export function backoffMs(attempt) {
@@ -124,27 +153,55 @@ export function refillsBudget(outcome) {
124
153
  * - a 255 from an attempt that reconnected AND held ({@link refillsBudget})
125
154
  * refills the budget first; every other 255 counts against it, so a host that
126
155
  * stays unreachable — and a link that keeps dropping the attach immediately —
127
- * both give up after MAX_ATTEMPTS.
156
+ * both give up once the retry window closes.
128
157
  */
129
158
  export function reconnectStep(state, outcome) {
130
159
  if (outcome.code !== SSH_CONN_FAILURE)
131
160
  return { action: 'stop', code: outcome.code };
132
- const attempts = refillsBudget(outcome) ? 0 : state.attempt;
133
- if (attempts >= MAX_ATTEMPTS)
161
+ const productive = refillsBudget(outcome);
162
+ const attempts = productive ? 0 : state.attempt;
163
+ // The attach's own duration counts against the window: a 10s connect timeout
164
+ // burned on every attempt is real elapsed time the user is waiting, and
165
+ // ignoring it would stretch a "15 minute" window well past fifteen minutes.
166
+ const burned = productive ? 0 : state.unproductiveMs + outcome.heldMs;
167
+ if (burned >= RECONNECT_WINDOW_MS)
134
168
  return { action: 'stop', code: SSH_CONN_FAILURE };
135
- return { action: 'retry', waitMs: backoffMs(attempts), state: { attempt: attempts + 1 } };
169
+ const waitMs = backoffMs(attempts);
170
+ return {
171
+ action: 'retry',
172
+ waitMs,
173
+ state: { attempt: attempts + 1, unproductiveMs: burned + waitMs },
174
+ remainingMs: Math.max(0, RECONNECT_WINDOW_MS - (burned + waitMs)),
175
+ };
136
176
  }
137
- /** Human-readable notice shown before each reconnect wait. "13 seconds", not "12.8s". */
138
- export function reconnectNotice(sessionId, host, attempt, waitMs) {
177
+ /** "14m51s", "51s" a duration a waiting human can read at a glance. */
178
+ export function formatDuration(ms) {
179
+ const total = Math.max(0, Math.round(ms / 1000));
180
+ const m = Math.floor(total / 60);
181
+ const sec = total % 60;
182
+ return m > 0 ? `${m}m${String(sec).padStart(2, '0')}s` : `${sec}s`;
183
+ }
184
+ /**
185
+ * Notice shown before each reconnect wait.
186
+ *
187
+ * Says how long is left in the window rather than "attempt 2/6": with a
188
+ * wall-clock budget the attempt number no longer tells the user when this stops,
189
+ * and "how much longer will it keep trying" is the actual question during an
190
+ * outage. Ctrl-C is advertised because a user who wants their shell back should
191
+ * not have to guess whether interrupting is safe — it is (the agent keeps
192
+ * running on the peer, which is the whole point).
193
+ */
194
+ export function reconnectNotice(target, host, attempt, waitMs, remainingMs) {
139
195
  const secs = Math.round(waitMs / 1000);
140
- const when = secs <= 1 ? 'now' : `in ${secs} seconds`;
141
- return `\nConnection to ${host} dropped — the agent is still running there. Reconnecting to ${sessionId.slice(0, 8)} ${when} (attempt ${attempt}/${MAX_ATTEMPTS})…\n`;
196
+ const when = secs <= 1 ? 'now' : `in ${secs}s`;
197
+ return `\nConnection to ${host} dropped — ${targetLabel(target)} is still running there.`
198
+ + `\n Reconnecting ${when} · ${formatDuration(remainingMs)} left · attempt ${attempt} · Ctrl-C to stop\n`;
142
199
  }
143
200
  /** Notice shown once the retry budget is spent on a host that stayed UNREACHABLE.
144
201
  * Hands back the one verb that re-enters the terminal — attach the live pane if it
145
202
  * survived, else resume. */
146
- export function exhaustedNotice(sessionId, host) {
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`;
203
+ export function exhaustedNotice(target, host) {
204
+ return `\nCouldn't reconnect to ${host} after ${formatDuration(RECONNECT_WINDOW_MS)}. The agent may still be running — get back in when the network is back:\n${recoveryHint(target, host)}`;
148
205
  }
149
206
  /** Notice shown when the budget is spent the OTHER way: the last reattach reached
150
207
  * the host and the connection dropped again within {@link MIN_HOLD_MS}. Saying
@@ -152,17 +209,23 @@ export function exhaustedNotice(sessionId, host) {
152
209
  * — and the user needs to know the link, not the host, is the problem. It claims
153
210
  * no count of successful reconnections: the budget can also be spent by a run of
154
211
  * unreachable attempts followed by one that reconnected and dropped straight out. */
155
- export function unstableNotice(sessionId, host) {
212
+ export function unstableNotice(target, host) {
156
213
  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`;
214
+ return `\nGave up reconnecting to ${host} after ${formatDuration(RECONNECT_WINDOW_MS)} — 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${recoveryHint(target, host)}`;
158
215
  }
159
216
  /** Notice shown when a reattach stops on a remapped remote-side exit
160
217
  * ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
161
218
  * on for its own reasons, not the ssh transport dropping; see
162
219
  * {@link wrapRemoteExitCode}). Distinct from {@link exhaustedNotice}, which is
163
220
  * only for a genuinely spent retry budget. */
164
- export function remoteExitNotice(sessionId, host) {
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`;
221
+ export function remoteExitNotice(target, host) {
222
+ return `\nReattach to ${targetLabel(target)} on ${host} ended (not a network drop) — get back in, or check whether it's still live:\n${recoveryHint(target, host)}`;
223
+ }
224
+ /** Shown when the user Ctrl-Cs out of the wait. The agent is untouched — it is
225
+ * detached on the peer — so this says how to get back rather than implying the
226
+ * work was lost. */
227
+ export function interruptedNotice(target, host) {
228
+ return `\nStopped reconnecting. ${targetLabel(target)} is still running on ${host}:\n${recoveryHint(target, host)}`;
166
229
  }
167
230
  /**
168
231
  * Wrap `cmd` in `bash -lc` (the login-shell pattern `buildRemoteAgentsInvocation`
@@ -185,18 +248,138 @@ export function wrapRemoteExitCode(cmd) {
185
248
  const guarded = `${cmd}; rc=$?; [ "$rc" = "${SSH_CONN_FAILURE}" ] && rc=${REMOTE_EXIT_255_REMAPPED}; exit "$rc"`;
186
249
  return `bash -lc ${shellQuote(guarded)}`;
187
250
  }
251
+ /** The short form shown to a human. Both kinds are uuid-shaped, so 8 chars reads the same. */
252
+ export function targetLabel(target) {
253
+ return target.id.slice(0, 8);
254
+ }
255
+ /**
256
+ * The command to hand a user whose reconnect gave up.
257
+ *
258
+ * A `session` target has a real id, so `agents sessions resume <id>` does the
259
+ * same attach-else-recover the loop was attempting. The full id is labeled on
260
+ * its own `Session` line, not only inside the command (RUSH-3227). (It used to
261
+ * say `agents reconnect`, which is deprecated and hidden — `commands/reconnect.ts`
262
+ * — so the advice printed at the worst possible moment was itself stale; RUSH-3125.)
263
+ *
264
+ * A `launch` target has no id a user-facing verb accepts: the mapping lives in
265
+ * the peer's hook records, which is exactly why reconnect uses it. So point at
266
+ * the peer's own resolver rather than inventing a launch-id selector on every
267
+ * local command for a string no human ever types.
268
+ */
269
+ export function recoveryHint(target, host) {
270
+ // The full id is labeled on its own line: burying it only inside a command is
271
+ // how a dropped SSH tab used to land on a bare shell with nothing copyable
272
+ // (RUSH-3227). Resume still sits underneath so the user can paste one verb.
273
+ if (target.kind === 'session') {
274
+ return ` Session ${target.id}\n Resume: agents sessions resume ${target.id}\n`;
275
+ }
276
+ return ` Launch ${target.id}\n agents ssh ${host} 'agents sessions focus --launch-id ${target.id} --local'\n`
277
+ + ` or pick it: agents sessions --active\n`;
278
+ }
279
+ /**
280
+ * Notice shown when an interactive remote connection has ended and the user is
281
+ * back at a local shell — clean detach, agent exit, or a drop that is NOT
282
+ * about to auto-reconnect. The OpenSSH close line (`Shared connection to …
283
+ * closed.`) names the host and nothing else; this is the handle they need to
284
+ * get back in (RUSH-3227).
285
+ */
286
+ export function connectionEndedNotice(target, host, opts = {}) {
287
+ const verb = opts.dropped ? 'dropped' : 'closed';
288
+ return `\nConnection to ${host} ${verb}.\n${recoveryHint(target, host)}`;
289
+ }
290
+ /**
291
+ * Banner printed when an interactive `--device` run whose session id is already
292
+ * known (Claude's forced id, or a resume) is about to take the TTY. The TUI
293
+ * will cover this; it survives in scrollback so the id exists *while* the
294
+ * connection exists, not only after it dies (RUSH-3227 plan B). Launch-id
295
+ * targets are not a resume handle — do not print them here.
296
+ */
297
+ export function connectionStartedNotice(target, host) {
298
+ if (target.kind !== 'session')
299
+ return undefined;
300
+ return `Session ${target.id} on ${host}\n Resume later: agents sessions resume ${target.id}\n`;
301
+ }
302
+ /**
303
+ * The id to print as an interactive `--device` stream starts. Resume is a
304
+ * real session id (`resolveHostSessionId` returns undefined on `--resume`);
305
+ * Claude's forced id is the other. `run auto` is excluded: its forwarded
306
+ * `--session-id` is only real if the remote pick is Claude.
307
+ */
308
+ export function startConnectionTarget(opts) {
309
+ if (opts.agent === RUN_AUTO_KEYWORD)
310
+ return undefined;
311
+ const id = opts.hostSessionId ?? opts.resumeId;
312
+ return id ? { kind: 'session', id } : undefined;
313
+ }
314
+ /**
315
+ * Decide what happens after an interactive `--device` stream returns.
316
+ *
317
+ * Auto-reconnect only when the link dropped (255) AND the run is tmux-hosted
318
+ * (`willReconnect`). `--raw` is not wrapped, so it never reconnects — but it
319
+ * still prints the session id: the user is at a shell, and EXEC-55 does not
320
+ * exempt raw (RUSH-3227). Bundling the notice behind `!isRaw` was the miss.
321
+ */
322
+ export function afterInteractiveRemoteExit(opts) {
323
+ if (!opts.target)
324
+ return { reconnect: false, notice: undefined };
325
+ if (opts.willReconnect)
326
+ return { reconnect: true, notice: undefined };
327
+ return {
328
+ reconnect: false,
329
+ notice: connectionEndedNotice(opts.target, opts.host, {
330
+ dropped: opts.exitCode === SSH_CONN_FAILURE,
331
+ }),
332
+ };
333
+ }
334
+ /**
335
+ * Choose how to name the dropped run when going back for it.
336
+ *
337
+ * Order matters, and the last clause is the fix (RUSH-3125):
338
+ *
339
+ * 1. `run auto` prefers `resolvedId` — the harness the remote ACTUALLY picked,
340
+ * which the launcher's own `--session-id` (adopted only by Claude) may not
341
+ * name. Every other agent prefers the id the launcher forced.
342
+ * 2. `resumeId` covers a run that was continuing a known session.
343
+ * 3. **`launchId` last, and it is what makes this work at all off-Claude.**
344
+ * Every id above either came from the launcher or was read back over SSH —
345
+ * and that read happens after the stream returned, i.e. over the link that
346
+ * just died, so on a real drop it yields nothing. Falling through to the
347
+ * launch id means the reconnect no longer depends on reaching the host to
348
+ * learn what to reconnect to; the peer resolves it locally instead.
349
+ *
350
+ * Returns undefined only when the launcher has no handle at all (a hookless
351
+ * harness with no forced id), which is the one case reconnect genuinely cannot
352
+ * serve. Pure, so the precedence is unit-tested without SSH.
353
+ */
354
+ export function pickReconnectTarget(inputs) {
355
+ const { agent, sessionId, resolvedId, resumeId, launchId } = inputs;
356
+ const preferred = agent === RUN_AUTO_KEYWORD
357
+ ? resolvedId ?? sessionId
358
+ : sessionId ?? resolvedId;
359
+ const id = preferred ?? resumeId;
360
+ if (id)
361
+ return { kind: 'session', id };
362
+ return launchId ? { kind: 'launch', id: launchId } : undefined;
363
+ }
188
364
  /**
189
365
  * The remote command a reattach runs — the peer's own recovery verb
190
- * (`agents sessions focus <id> --local`), wrapped by {@link wrapRemoteExitCode}
366
+ * (`agents sessions focus --local`), wrapped by {@link wrapRemoteExitCode}
191
367
  * so a stray remote-origin 255 (from this command, whatever produces it — see the
192
368
  * file header) can never masquerade as a network drop. No `--attach-only`: focus
193
369
  * joins the live pane when it survived, else RESUMES the session in place, so a
194
370
  * reattach landing after the pane died recovers the agent instead of dead-ending
195
371
  * (RUSH-2085). Split out from {@link reattachRemoteSession} so it is unit-tested
196
372
  * without SSH — mirrors `remoteAgentsJsonCommand` in lib/remote-agents-json.ts.
373
+ *
374
+ * A `launch` target passes `--launch-id`, which focus resolves against the hook
375
+ * records on the peer itself — no network read from this side, which is the
376
+ * whole point (see {@link ReconnectTarget}).
197
377
  */
198
- export function reattachRemoteCommand(sessionId) {
199
- const inner = ['agents', 'sessions', 'focus', sessionId, '--local']
378
+ export function reattachRemoteCommand(target) {
379
+ const selector = target.kind === 'launch'
380
+ ? ['--launch-id', target.id]
381
+ : [target.id];
382
+ const inner = ['agents', 'sessions', 'focus', ...selector, '--local', '--reconnect-reattach']
200
383
  .map(shellQuote)
201
384
  .join(' ');
202
385
  return wrapRemoteExitCode(inner);
@@ -212,22 +395,49 @@ export function reattachRemoteCommand(sessionId) {
212
395
  * clean detach; other = session ended), whether this attempt connected, and how
213
396
  * long the attach held — the two inputs {@link refillsBudget} decides on.
214
397
  */
215
- export function reattachRemoteSession(host, sessionId) {
216
- const target = sshTargetFor(host);
398
+ export function reattachRemoteSession(host, target) {
399
+ const sshTarget = sshTargetFor(host);
217
400
  const extraSshArgs = hostIdentityArgs(host);
218
401
  // Fresh (non-multiplexed) reachability probe: code 0 means the handshake actually
219
402
  // completed, so a hung/failed connect is never mistaken for a live reconnection.
220
403
  // RUSH-2265: pass host identity on every hop (probe + stream), not only the first.
221
- const probe = sshExec(target, 'true', { multiplex: false, extraSshArgs });
404
+ const probe = sshExec(sshTarget, 'true', { multiplex: false, extraSshArgs });
222
405
  if (probe.code !== 0)
223
406
  return { code: SSH_CONN_FAILURE, connected: false, heldMs: 0 };
224
407
  // Timed from AFTER the probe returned, so this is the attach's own duration and
225
408
  // carries none of the connect phase the file header rules out as a signal.
226
409
  const startedAt = Date.now();
227
- const code = sshStream(target, reattachRemoteCommand(sessionId), { tty: true, extraSshArgs });
410
+ const code = sshStream(sshTarget, reattachRemoteCommand(target), { tty: true, extraSshArgs });
228
411
  return { code, connected: true, heldMs: Date.now() - startedAt };
229
412
  }
230
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
413
+ /**
414
+ * Wait `ms`, but return early if the user interrupts.
415
+ *
416
+ * Without this, Ctrl-C during the backoff killed the whole `agents` process:
417
+ * node's default SIGINT handler exits, so the loop never got to say what
418
+ * happened, the terminal was left mid-notice, and the user was dropped at a bare
419
+ * shell with no hint that the agent was still alive on the peer — the same
420
+ * dead-end the reconnect exists to prevent. The handler is installed only for
421
+ * the duration of the wait and always removed, so it can never swallow a Ctrl-C
422
+ * meant for the attached agent (during an attach, ssh owns the tty and this
423
+ * process is not in the foreground group anyway).
424
+ */
425
+ async function waitOrInterrupt(ms) {
426
+ return new Promise((resolve) => {
427
+ let done = false;
428
+ const finish = (how) => {
429
+ if (done)
430
+ return;
431
+ done = true;
432
+ clearTimeout(timer);
433
+ process.removeListener('SIGINT', onSigint);
434
+ resolve(how);
435
+ };
436
+ const onSigint = () => finish('interrupted');
437
+ const timer = setTimeout(() => finish('elapsed'), ms);
438
+ process.on('SIGINT', onSigint);
439
+ });
440
+ }
231
441
  /**
232
442
  * Drive the reconnect loop from the initial run's outcome to a terminal code.
233
443
  * Only the real SSH re-attach and the wait are side effects; the decision is
@@ -235,7 +445,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
235
445
  */
236
446
  export async function reconnectInteractiveSession(opts) {
237
447
  const write = opts.write ?? ((s) => process.stderr.write(s));
238
- const wait = opts.wait ?? sleep;
448
+ const wait = opts.wait ?? waitOrInterrupt;
239
449
  const reattach = opts.reattach ?? reattachRemoteSession;
240
450
  let state = initialReconnectState();
241
451
  // The initial run reached the point of running the agent, so it connected. Its
@@ -251,17 +461,28 @@ export async function reconnectInteractiveSession(opts) {
251
461
  // and a link that reconnected but kept dropping reads as exactly that.
252
462
  if (decision.code === SSH_CONN_FAILURE) {
253
463
  write(outcome.connected
254
- ? unstableNotice(opts.sessionId, opts.host.name)
255
- : exhaustedNotice(opts.sessionId, opts.host.name));
464
+ ? unstableNotice(opts.target, opts.host.name)
465
+ : exhaustedNotice(opts.target, opts.host.name));
256
466
  }
257
467
  else if (decision.code === REMOTE_EXIT_255_REMAPPED) {
258
- write(remoteExitNotice(opts.sessionId, opts.host.name));
468
+ write(remoteExitNotice(opts.target, opts.host.name));
469
+ }
470
+ else {
471
+ // Clean detach / agent exit after a reattach: the user is at a local
472
+ // shell and still needs the session id (RUSH-3227). Spent-budget and
473
+ // remapped-255 notices already carry recoveryHint.
474
+ write(connectionEndedNotice(opts.target, opts.host.name));
259
475
  }
260
476
  return decision.code;
261
477
  }
262
- write(reconnectNotice(opts.sessionId, opts.host.name, decision.state.attempt, decision.waitMs));
263
- await wait(decision.waitMs);
478
+ write(reconnectNotice(opts.target, opts.host.name, decision.state.attempt, decision.waitMs, decision.remainingMs));
479
+ if (await wait(decision.waitMs) === 'interrupted') {
480
+ // The user asked for their shell back. Nothing was lost — say where the
481
+ // agent is and how to return, then exit as an interrupt (128 + SIGINT).
482
+ write(interruptedNotice(opts.target, opts.host.name));
483
+ return 130;
484
+ }
264
485
  state = decision.state;
265
- outcome = reattach(opts.host, opts.sessionId);
486
+ outcome = reattach(opts.host, opts.target);
266
487
  }
267
488
  }
@@ -27,6 +27,7 @@ import { resolveRemoteOsSync } from './remote-os.js';
27
27
  import { loadDevices } from '../devices/registry.js';
28
28
  import { resolveDeviceProfile } from '../devices/resolve-profile.js';
29
29
  import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
30
+ import { isDeviceInteractive, resolveInteractiveDevice, interactiveUnsetError, } from '../devices/interactive-host.js';
30
31
  import { localMachineId } from '../session/origin-machine.js';
31
32
  // Re-export so existing importers (tests, commands) keep their path; the class
32
33
  // itself lives in types.ts so providers can throw it without a circular import.
@@ -161,6 +162,12 @@ export async function matchHost(name, opts = {}) {
161
162
  // returning nothing — callers that already special-case "target is this
162
163
  // machine" (teams add/create, the passthrough self-host check) then treat it as
163
164
  // local exactly as they would if the user had typed the local name.
165
+ if (isDeviceInteractive(name)) {
166
+ const pinned = resolveInteractiveDevice();
167
+ if (!pinned)
168
+ throw new Error(interactiveUnsetError());
169
+ name = pinned;
170
+ }
164
171
  if (isDeviceAuto(name)) {
165
172
  const plan = (opts.resolveAuto ?? (() => resolveDeviceAffinity({})))();
166
173
  const picked = plan.host ?? normalizeHost(localMachineId());
@@ -187,11 +187,10 @@ export declare function migrateWatchdogSentinelToConfig(sentinelPath?: string, e
187
187
  */
188
188
  export declare function migrateCliDirToClis(agentsDirs: string[]): void;
189
189
  /**
190
- * Cursor's OAuth token historically lived only in the global
191
- * ~/.config/cursor/auth.json, shared across every version home. Cursor runs now
192
- * pin XDG_CONFIG_HOME per version home (real per-account isolation — see
193
- * buildExecEnv), so the current login must be copied into the active account's
194
- * version home or it would read as logged out after upgrade. The active account
190
+ * An earlier agents-cli release put Cursor's file-backed OAuth token at
191
+ * ~/.config/cursor/auth.json. Current Cursor actually writes the file store to
192
+ * HOME-relative ~/.cursor/auth.json. Copy that legacy token into the active
193
+ * account's version home. The active account
195
194
  * is the home the ~/.cursor symlink currently targets. Idempotent: skips when
196
195
  * the home already has its own token, and a no-op for unmanaged Cursor installs
197
196
  * (where ~/.cursor is a real dir, not a symlink into a version home).
@@ -1641,7 +1641,7 @@ function migrateVersionResourcesToPatterns() {
1641
1641
  }
1642
1642
  }
1643
1643
  if (changed) {
1644
- const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/apps/cli/schema/agents-yaml.schema.json\n\n';
1644
+ const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n# yaml-language-server: $schema=https://raw.githubusercontent.com/phnx-labs/agents-cli/main/cli/schema/agents-yaml.schema.json\n\n';
1645
1645
  fs.writeFileSync(metaFile, META_HEADER + yaml.stringify(meta), 'utf-8');
1646
1646
  console.error('Migrated agents.yaml versions: entries to pattern format');
1647
1647
  }
@@ -2278,11 +2278,10 @@ function migrateHumans() {
2278
2278
  }
2279
2279
  }
2280
2280
  /**
2281
- * Cursor's OAuth token historically lived only in the global
2282
- * ~/.config/cursor/auth.json, shared across every version home. Cursor runs now
2283
- * pin XDG_CONFIG_HOME per version home (real per-account isolation — see
2284
- * buildExecEnv), so the current login must be copied into the active account's
2285
- * version home or it would read as logged out after upgrade. The active account
2281
+ * An earlier agents-cli release put Cursor's file-backed OAuth token at
2282
+ * ~/.config/cursor/auth.json. Current Cursor actually writes the file store to
2283
+ * HOME-relative ~/.cursor/auth.json. Copy that legacy token into the active
2284
+ * account's version home. The active account
2286
2285
  * is the home the ~/.cursor symlink currently targets. Idempotent: skips when
2287
2286
  * the home already has its own token, and a no-op for unmanaged Cursor installs
2288
2287
  * (where ~/.cursor is a real dir, not a symlink into a version home).
@@ -2305,7 +2304,7 @@ export function seedActiveCursorLoginPerVersion() {
2305
2304
  }
2306
2305
  if (!versionHome.includes(path.join('versions', 'cursor')))
2307
2306
  return;
2308
- const dest = path.join(versionHome, '.config', 'cursor', 'auth.json');
2307
+ const dest = path.join(versionHome, '.cursor', 'auth.json');
2309
2308
  try {
2310
2309
  if (fs.existsSync(dest))
2311
2310
  return;
@@ -174,7 +174,7 @@ export declare function removeShim(agent: AgentId): boolean;
174
174
  * sorted alphabetically before the real self-updated grok binary and
175
175
  * was silently launched instead.
176
176
  */
177
- export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 16;
177
+ export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 18;
178
178
  /**
179
179
  * Agents whose config directory can be relocated by an environment variable
180
180
  * (the per-agent `managedEnv` block in `generateVersionedAliasScript` below).
@@ -942,7 +942,13 @@ export function removeShim(agent) {
942
942
  * sorted alphabetically before the real self-updated grok binary and
943
943
  * was silently launched instead.
944
944
  */
945
- export const VERSIONED_ALIAS_SCHEMA_VERSION = 16;
945
+ // v17 the claude alias env now reuses the adapter's shimConfigEnvBash (adds the
946
+ // Linux .oauth_token setup-token fallback the hand-copied subset had dropped),
947
+ // so existing alias shims must regenerate to pick it up.
948
+ // v18 — Cursor aliases select the file credential store and swap HOME to the
949
+ // version home because current Cursor writes auth.json under ~/.cursor
950
+ // and ignores XDG_CONFIG_HOME for credential storage.
951
+ export const VERSIONED_ALIAS_SCHEMA_VERSION = 18;
946
952
  /** Internal marker string used to embed the schema version in versioned alias scripts. */
947
953
  const VERSIONED_ALIAS_VERSION_MARKER = 'agents-versioned-alias-version:';
948
954
  // The version string is interpolated into a generated bash script and into
@@ -990,14 +996,16 @@ export function generateVersionedAliasScript(agent, version) {
990
996
  const configDirName = path.relative(os.homedir(), agentConfig.configDir);
991
997
  const managedEnv = agent === 'claude'
992
998
  ? `
993
- # Claude stores OAuth credentials in the macOS keychain. Scope them to this
994
- # version's config directory so direct aliases also switch the live account.
995
- export CLAUDE_CONFIG_DIR="$HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
996
- # Managed installs are pinned in a per-version dir; Claude Code's background
997
- # auto-updater would rewrite the pinned binary in place. Disable it so a pin
998
- # stays a pin. An explicit user value always wins.
999
- export DISABLE_AUTOUPDATER="\${DISABLE_AUTOUPDATER:-1}"
1000
- `
999
+ # Reuse the main shim's Claude config-env verbatim (CLAUDE_CONFIG_DIR pin,
1000
+ # autoupdater off, AND the Linux .oauth_token setup-token fallback), keyed to this
1001
+ # version's home via VERSION_DIR. A hand-copied subset here had drifted and lost the
1002
+ # .oauth_token fallback, so interactive runs on a keychain-less worker could not
1003
+ # authenticate from the attached setup-token the main shim (generateShimScript)
1004
+ # already sources this same adapter block, so sharing it keeps the two in sync.
1005
+ # plain (not exported) so it feeds shimConfigEnvBash below but never leaks into the
1006
+ # launched agent process — matching the main shim's convention.
1007
+ VERSION_DIR="$HOME/.agents/.history/versions/${agent}/${version}"
1008
+ ${resolveHarnessAdapter(agent).shimConfigEnvBash?.({ configDirName }) ?? ''}`
1001
1009
  : agent === 'codex'
1002
1010
  ? codexHomeShimBash(`$HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}`, `$HOME/.agents/.codex-homes/${version}`)
1003
1011
  : agent === 'copilot'
@@ -1037,10 +1045,12 @@ export XDG_DATA_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/.
1037
1045
  `
1038
1046
  : agent === 'cursor'
1039
1047
  ? `
1040
- # Cursor: no config-dir env var. Its OAuth token (the login gate) lives at
1041
- # $XDG_CONFIG_HOME/cursor/auth.json, so pin XDG_CONFIG_HOME at the version home
1042
- # to isolate each account's login for direct aliases (parity with buildExecEnv).
1043
- export XDG_CONFIG_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/.config"
1048
+ # Cursor defaults to one machine-global OS-keychain login on macOS. Force its
1049
+ # file store and swap HOME, so ~/.cursor/auth.json belongs to this
1050
+ # version and direct aliases cannot fall through to the shared keychain login.
1051
+ export AGENTS_REAL_HOME="\${AGENTS_REAL_HOME:-$HOME}"
1052
+ export HOME="$HOME/.agents/.history/versions/${agent}/${version}/home"
1053
+ export AGENT_CLI_CREDENTIAL_STORE="file"
1044
1054
  `
1045
1055
  : '';
1046
1056
  const launchArgs = resolveHarnessAdapter(agent).shimLaunchArgs?.() ?? '';