@phnx-labs/agents-cli 1.22.47 → 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 (249) hide show
  1. package/CHANGELOG.md +401 -17
  2. package/README.md +66 -32
  3. package/dist/bootstrap.js +4 -2
  4. package/dist/cli/command-registry.d.ts +1 -0
  5. package/dist/cli/command-registry.js +2 -0
  6. package/dist/commands/accounts.d.ts +58 -0
  7. package/dist/commands/accounts.js +206 -32
  8. package/dist/commands/artifacts.js +7 -6
  9. package/dist/commands/attach.js +1 -1
  10. package/dist/commands/browser.js +501 -103
  11. package/dist/commands/config.d.ts +0 -1
  12. package/dist/commands/config.js +0 -20
  13. package/dist/commands/daemon.d.ts +21 -0
  14. package/dist/commands/daemon.js +147 -12
  15. package/dist/commands/exec.js +52 -15
  16. package/dist/commands/focus.d.ts +14 -0
  17. package/dist/commands/focus.js +48 -5
  18. package/dist/commands/go.d.ts +3 -0
  19. package/dist/commands/go.js +16 -3
  20. package/dist/commands/inspect.js +2 -2
  21. package/dist/commands/lease.d.ts +1 -1
  22. package/dist/commands/lease.js +4 -3
  23. package/dist/commands/mailboxes.js +4 -3
  24. package/dist/commands/menubar.js +11 -4
  25. package/dist/commands/projects.d.ts +16 -1
  26. package/dist/commands/projects.js +58 -64
  27. package/dist/commands/resume.js +1 -1
  28. package/dist/commands/route.js +4 -4
  29. package/dist/commands/routines.js +3 -2
  30. package/dist/commands/run-broadcast.d.ts +1 -1
  31. package/dist/commands/run-broadcast.js +1 -1
  32. package/dist/commands/sessions-picker.d.ts +22 -0
  33. package/dist/commands/sessions-picker.js +48 -9
  34. package/dist/commands/sessions-trace.js +1 -1
  35. package/dist/commands/sessions.js +1 -0
  36. package/dist/commands/setup-secrets.js +23 -0
  37. package/dist/commands/share.d.ts +20 -5
  38. package/dist/commands/share.js +152 -52
  39. package/dist/commands/ssh.js +54 -7
  40. package/dist/commands/traces.d.ts +2 -0
  41. package/dist/commands/traces.js +184 -0
  42. package/dist/commands/view.d.ts +1 -1
  43. package/dist/commands/view.js +17 -34
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +5 -0
  46. package/dist/lib/accounting/usage.d.ts +14 -3
  47. package/dist/lib/accounting/usage.js +64 -18
  48. package/dist/lib/agent-spec/agents.js +11 -9
  49. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  50. package/dist/lib/browser/drivers/ssh.js +36 -9
  51. package/dist/lib/browser/ipc.js +8 -3
  52. package/dist/lib/browser/profiles.d.ts +35 -101
  53. package/dist/lib/browser/profiles.js +130 -301
  54. package/dist/lib/browser/registry.d.ts +39 -0
  55. package/dist/lib/browser/registry.js +149 -0
  56. package/dist/lib/browser/remote-control.d.ts +8 -6
  57. package/dist/lib/browser/remote-control.js +8 -6
  58. package/dist/lib/browser/resolve-target.d.ts +68 -0
  59. package/dist/lib/browser/resolve-target.js +250 -0
  60. package/dist/lib/browser/runtime-state.d.ts +40 -16
  61. package/dist/lib/browser/runtime-state.js +92 -25
  62. package/dist/lib/browser/service.d.ts +28 -9
  63. package/dist/lib/browser/service.js +139 -62
  64. package/dist/lib/browser/task-index.d.ts +70 -0
  65. package/dist/lib/browser/task-index.js +180 -0
  66. package/dist/lib/byok-usage.js +2 -2
  67. package/dist/lib/claude-statusline.d.ts +29 -0
  68. package/dist/lib/claude-statusline.js +139 -0
  69. package/dist/lib/cloud/host.d.ts +1 -1
  70. package/dist/lib/cloud/host.js +1 -1
  71. package/dist/lib/cloud/rush.js +1 -1
  72. package/dist/lib/computer/computer-rpc.js +2 -2
  73. package/dist/lib/computer/download.d.ts +15 -15
  74. package/dist/lib/computer/download.js +35 -98
  75. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  76. package/dist/lib/computer/ssh-tunnel.js +12 -22
  77. package/dist/lib/config-keys.d.ts +2 -8
  78. package/dist/lib/config-keys.js +2 -13
  79. package/dist/lib/crabbox/cli.d.ts +1 -1
  80. package/dist/lib/crabbox/cli.js +2 -2
  81. package/dist/lib/crabbox/lease.d.ts +9 -0
  82. package/dist/lib/crabbox/lease.js +70 -15
  83. package/dist/lib/crabbox/runtimes.js +1 -1
  84. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  85. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  86. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  87. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  88. package/dist/lib/daemon/daemon.d.ts +3 -0
  89. package/dist/lib/daemon/daemon.js +212 -444
  90. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  91. package/dist/lib/daemon/device-probe-service.js +47 -0
  92. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  93. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  94. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  95. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  96. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  97. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  98. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  99. package/dist/lib/daemon/self-heal-service.js +48 -0
  100. package/dist/lib/daemon/service.d.ts +102 -0
  101. package/dist/lib/daemon/service.js +98 -0
  102. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  103. package/dist/lib/daemon/session-index-service.js +40 -0
  104. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  105. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  106. package/dist/lib/daemon/supervisor.d.ts +87 -0
  107. package/dist/lib/daemon/supervisor.js +298 -0
  108. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  109. package/dist/lib/daemon/watchdog-service.js +40 -0
  110. package/dist/lib/daemon-health.d.ts +15 -0
  111. package/dist/lib/daemon-health.js +29 -5
  112. package/dist/lib/daemon-services.d.ts +8 -1
  113. package/dist/lib/daemon-services.js +55 -0
  114. package/dist/lib/daemon-ticks.d.ts +13 -2
  115. package/dist/lib/daemon-ticks.js +20 -18
  116. package/dist/lib/device-config.d.ts +0 -2
  117. package/dist/lib/device-config.js +1 -23
  118. package/dist/lib/devices/registry.d.ts +1 -1
  119. package/dist/lib/devices/registry.js +1 -1
  120. package/dist/lib/devices/stats-cache.d.ts +20 -0
  121. package/dist/lib/devices/stats-cache.js +49 -5
  122. package/dist/lib/devices/worker-pick.d.ts +48 -0
  123. package/dist/lib/devices/worker-pick.js +113 -0
  124. package/dist/lib/exec.d.ts +67 -13
  125. package/dist/lib/exec.js +85 -29
  126. package/dist/lib/harness/adapters/cursor.js +8 -11
  127. package/dist/lib/helper-download.d.ts +108 -0
  128. package/dist/lib/helper-download.js +194 -0
  129. package/dist/lib/helper-versions.d.ts +52 -0
  130. package/dist/lib/helper-versions.js +58 -0
  131. package/dist/lib/hooks/cache.js +11 -0
  132. package/dist/lib/hooks/install.js +2 -2
  133. package/dist/lib/hosts/credentials.d.ts +1 -1
  134. package/dist/lib/hosts/credentials.js +1 -1
  135. package/dist/lib/hosts/dispatch.d.ts +7 -1
  136. package/dist/lib/hosts/dispatch.js +24 -5
  137. package/dist/lib/hosts/passthrough.d.ts +8 -15
  138. package/dist/lib/hosts/passthrough.js +19 -16
  139. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  140. package/dist/lib/hosts/providers/devices.js +1 -1
  141. package/dist/lib/hosts/reconnect.d.ts +179 -21
  142. package/dist/lib/hosts/reconnect.js +266 -45
  143. package/dist/lib/installations/migrate.d.ts +4 -5
  144. package/dist/lib/installations/migrate.js +6 -7
  145. package/dist/lib/installations/shims.d.ts +1 -1
  146. package/dist/lib/installations/shims.js +23 -13
  147. package/dist/lib/installations/versions.js +25 -2
  148. package/dist/lib/linear-autoclose.d.ts +1 -1
  149. package/dist/lib/linear-autoclose.js +1 -1
  150. package/dist/lib/loop.d.ts +2 -2
  151. package/dist/lib/loop.js +1 -1
  152. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  153. package/dist/lib/menubar/download-menubar.js +60 -0
  154. package/dist/lib/menubar/install-menubar.d.ts +31 -7
  155. package/dist/lib/menubar/install-menubar.js +79 -21
  156. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  157. package/dist/lib/menubar/notify-desktop.js +6 -6
  158. package/dist/lib/model-tiers.d.ts +1 -1
  159. package/dist/lib/project-probe.d.ts +1 -1
  160. package/dist/lib/project-probe.js +1 -1
  161. package/dist/lib/project-pull.d.ts +1 -1
  162. package/dist/lib/project-pull.js +2 -2
  163. package/dist/lib/projects.d.ts +1 -1
  164. package/dist/lib/projects.js +1 -1
  165. package/dist/lib/sandbox.js +6 -7
  166. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  167. package/dist/lib/secrets/download-keychain.js +70 -0
  168. package/dist/lib/secrets/install-helper.d.ts +20 -1
  169. package/dist/lib/secrets/install-helper.js +44 -3
  170. package/dist/lib/session/active.js +17 -1
  171. package/dist/lib/session/db.d.ts +11 -1
  172. package/dist/lib/session/db.js +138 -6
  173. package/dist/lib/session/discover.d.ts +21 -1
  174. package/dist/lib/session/discover.js +33 -5
  175. package/dist/lib/session/highlights.d.ts +49 -1
  176. package/dist/lib/session/highlights.js +95 -0
  177. package/dist/lib/session/host-link.d.ts +16 -2
  178. package/dist/lib/session/host-link.js +7 -1
  179. package/dist/lib/session/parse.js +33 -1
  180. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  181. package/dist/lib/session/remote/remote-list.js +19 -2
  182. package/dist/lib/session/remote/watch.js +11 -2
  183. package/dist/lib/session/session-cache.d.ts +53 -0
  184. package/dist/lib/session/session-cache.js +88 -0
  185. package/dist/lib/session/state.js +1 -1
  186. package/dist/lib/session/tool-calls.d.ts +2 -0
  187. package/dist/lib/session/tool-calls.js +1 -1
  188. package/dist/lib/session/trajectory-html.d.ts +5 -1
  189. package/dist/lib/session/trajectory-html.js +42 -22
  190. package/dist/lib/session/trajectory-text.js +9 -2
  191. package/dist/lib/session/trajectory.js +53 -4
  192. package/dist/lib/session/types.d.ts +15 -0
  193. package/dist/lib/sha256-asset.d.ts +26 -0
  194. package/dist/lib/sha256-asset.js +41 -0
  195. package/dist/lib/share/backend.d.ts +95 -0
  196. package/dist/lib/share/backend.js +139 -0
  197. package/dist/lib/share/delete.d.ts +6 -2
  198. package/dist/lib/share/delete.js +8 -9
  199. package/dist/lib/share/html.d.ts +22 -0
  200. package/dist/lib/share/html.js +88 -0
  201. package/dist/lib/share/provision.d.ts +18 -4
  202. package/dist/lib/share/provision.js +38 -4
  203. package/dist/lib/share/publish.d.ts +39 -15
  204. package/dist/lib/share/publish.js +55 -26
  205. package/dist/lib/share/worker-template.js +171 -11
  206. package/dist/lib/sqlite.js +16 -3
  207. package/dist/lib/ssh-exec.d.ts +65 -0
  208. package/dist/lib/ssh-exec.js +120 -4
  209. package/dist/lib/startup/command-registry.js +1 -1
  210. package/dist/lib/startup/dev-build.js +3 -3
  211. package/dist/lib/state.d.ts +1 -1
  212. package/dist/lib/state.js +7 -3
  213. package/dist/lib/subagents-registry.js +1 -1
  214. package/dist/lib/terminal/index.d.ts +1 -1
  215. package/dist/lib/terminal/types.d.ts +1 -1
  216. package/dist/lib/terminal/types.js +1 -1
  217. package/dist/lib/tmux/orphan-reap.js +1 -1
  218. package/dist/lib/tmux/session.js +31 -0
  219. package/dist/lib/traces/backend.d.ts +29 -0
  220. package/dist/lib/traces/backend.js +46 -0
  221. package/dist/lib/traces/classify.d.ts +52 -0
  222. package/dist/lib/traces/classify.js +72 -0
  223. package/dist/lib/traces/config.d.ts +10 -0
  224. package/dist/lib/traces/config.js +2 -0
  225. package/dist/lib/traces/provision.d.ts +19 -0
  226. package/dist/lib/traces/provision.js +30 -0
  227. package/dist/lib/traces/sync.d.ts +150 -0
  228. package/dist/lib/traces/sync.js +447 -0
  229. package/dist/lib/traces/worker-template.d.ts +4 -0
  230. package/dist/lib/traces/worker-template.js +159 -0
  231. package/dist/lib/types.d.ts +18 -19
  232. package/dist/lib/types.js +14 -0
  233. package/dist/lib/usage-refresh.d.ts +1 -1
  234. package/dist/lib/usage-refresh.js +11 -8
  235. package/dist/lib/workflows.d.ts +1 -1
  236. package/package.json +4 -6
  237. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  238. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  239. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  240. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  241. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  242. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  243. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -26
  244. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  245. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  246. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -135
  247. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  248. package/dist/lib/usage-fleet.d.ts +0 -32
  249. package/dist/lib/usage-fleet.js +0 -125
@@ -5,7 +5,6 @@
5
5
  * - run defaults (model, mode, effort)
6
6
  * - tier overrides (folded into the run namespace)
7
7
  * - interactive host
8
- * - usage primary host
9
8
  * - browser profile
10
9
  * - local projects root
11
10
  * - per-device config keys
@@ -5,7 +5,6 @@
5
5
  * - run defaults (model, mode, effort)
6
6
  * - tier overrides (folded into the run namespace)
7
7
  * - interactive host
8
- * - usage primary host
9
8
  * - browser profile
10
9
  * - local projects root
11
10
  * - per-device config keys
@@ -40,8 +39,6 @@ function parseValue(key, parsed, raw) {
40
39
  return raw.trim();
41
40
  case 'interactive':
42
41
  return raw.trim();
43
- case 'usage':
44
- return raw.trim();
45
42
  case 'auto':
46
43
  return raw.trim();
47
44
  case 'browser':
@@ -109,10 +106,6 @@ function setConfig(parsed, value) {
109
106
  setConfigValue('interactive.host', value);
110
107
  return;
111
108
  }
112
- case 'usage': {
113
- setConfigValue('usage.primary-host', value);
114
- return;
115
- }
116
109
  case 'auto': {
117
110
  setConfigValue('auto.pool', value);
118
111
  return;
@@ -156,11 +149,6 @@ function unsetConfig(parsed) {
156
149
  unsetConfigValue('interactive.host');
157
150
  return had;
158
151
  }
159
- case 'usage': {
160
- const had = getConfigValue('usage.primary-host').value !== undefined;
161
- unsetConfigValue('usage.primary-host');
162
- return had;
163
- }
164
152
  case 'auto': {
165
153
  const had = getConfigValue('auto.pool').value !== undefined;
166
154
  unsetConfigValue('auto.pool');
@@ -204,8 +192,6 @@ function getConfig(parsed) {
204
192
  }
205
193
  case 'interactive':
206
194
  return getConfigValue('interactive.host').value;
207
- case 'usage':
208
- return getConfigValue('usage.primary-host').value;
209
195
  case 'auto':
210
196
  return getConfigValue('auto.pool').value;
211
197
  case 'browser': {
@@ -263,9 +249,6 @@ function* listCentralConfigEntries() {
263
249
  if (meta.config?.interactiveHost !== undefined) {
264
250
  yield { key: 'interactive.host', value: meta.config.interactiveHost, hint: 'config.interactiveHost' };
265
251
  }
266
- if (meta.config?.usagePrimaryHost !== undefined) {
267
- yield { key: 'usage.primary-host', value: meta.config.usagePrimaryHost, hint: 'config.usagePrimaryHost' };
268
- }
269
252
  if (meta.config?.autoPool !== undefined) {
270
253
  yield { key: 'auto.pool', value: meta.config.autoPool, hint: 'config.autoPool' };
271
254
  }
@@ -353,14 +336,11 @@ export function registerConfigCommand(program) {
353
336
  agents config set run.claude@2.1.45.model claude-opus-4-8
354
337
  agents config set run.claude@*.mode auto
355
338
  agents config set interactive.host zion
356
- agents config set usage.primary-host zion
357
339
  agents config set browser.profile work
358
340
  agents config set project.root ~/src/github.com/<you>
359
341
  agents config set devices.mac-mini.max-agents 4
360
342
  agents config get run.claude@*.model
361
- agents config get usage.primary-host
362
343
  agents config unset run.claude@*.tier.best
363
- agents config unset usage.primary-host
364
344
  agents config list
365
345
  agents config budget
366
346
  agents config budget set per_day 50
@@ -15,4 +15,25 @@
15
15
  * duplicating it.
16
16
  */
17
17
  import type { Command } from 'commander';
18
+ import { type DaemonServiceId } from '../lib/daemon-services.js';
19
+ /** One row of the roster every registered daemon service — supervisor-managed or legacy — renders in `agents daemon services`. */
20
+ export interface DaemonServiceRow {
21
+ id: DaemonServiceId;
22
+ title: string;
23
+ description: string;
24
+ enabled: boolean;
25
+ /**
26
+ * `ServiceSupervisor`'s real lifecycle state when `supervised` is true
27
+ * (`idle`/`running`/`parked`/`stopped`, written cross-process via
28
+ * `recordSubsystemState`). A legacy `setInterval`-driven service has no such
29
+ * record, so its state is inferred from `enabled` + whether the daemon
30
+ * process is up — labelled distinctly so a reader can't mistake it for a
31
+ * measured value.
32
+ */
33
+ state: string;
34
+ supervised: boolean;
35
+ lastRunMs: number | null;
36
+ lastError: string | null;
37
+ consecutiveFailures: number;
38
+ }
18
39
  export declare function registerDaemonCommand(program: Command): void;
@@ -21,8 +21,8 @@ import * as path from 'path';
21
21
  import { setHelpSections } from '../lib/help.js';
22
22
  import { getDaemonStatus, isDaemonRunning, isDaemonWedged, readDaemonLog, startDaemon, stopDaemon, signalDaemonReload, findSurvivingStateDirDaemons, getDaemonLogPath, isDaemonAutostartCircuitOpen, } from '../lib/daemon/daemon.js';
23
23
  import { getConfigValue, setConfigValue, isDaemonEnabled } from '../lib/device-config.js';
24
- import { readSubsystemHealth, SUBSYSTEM_SECRETS_BROKER, SUBSYSTEM_BROWSER_IPC, SUBSYSTEM_DAEMON_START, } from '../lib/daemon-health.js';
25
- import { DAEMON_SERVICE_IDS, listDaemonServiceStates, setDaemonServiceEnabled, getDaemonServicesConfigPath, } from '../lib/daemon-services.js';
24
+ import { readSubsystemHealth, readAllSubsystemHealth, SUBSYSTEM_SECRETS_BROKER, SUBSYSTEM_BROWSER_IPC, SUBSYSTEM_DAEMON_START, } from '../lib/daemon-health.js';
25
+ import { DAEMON_SERVICE_IDS, listDaemonServiceStates, setDaemonServiceEnabled, getDaemonServicesConfigPath, queueDaemonServiceRestart, } from '../lib/daemon-services.js';
26
26
  import { listJobs, getLatestRun } from '../lib/scheduling/routines.js';
27
27
  import { JobScheduler } from '../lib/scheduler.js';
28
28
  import { followFile } from '../lib/log-follow.js';
@@ -474,19 +474,76 @@ async function runStatus(opts) {
474
474
  console.log(chalk.red('\nThe daemon is wedged (heartbeat stale). Restart: agents daemon restart'));
475
475
  }
476
476
  }
477
+ /**
478
+ * Combine the persisted enable/disable toggles with whatever health the
479
+ * `ServiceSupervisor` (or a legacy subsystem) has reported. Pure — reads two
480
+ * on-disk files, makes no probes — so it's cheap to call from both the
481
+ * non-interactive renderer and the interactive picker's refresh tick.
482
+ */
483
+ function buildServiceRows(daemonRunning) {
484
+ const states = listDaemonServiceStates();
485
+ const healthById = new Map(readAllSubsystemHealth().map((h) => [h.subsystem, h]));
486
+ return states.map((s) => {
487
+ const h = healthById.get(s.id);
488
+ const supervised = h?.state !== undefined;
489
+ // A persisted supervisor state (health.json) is only trustworthy while the
490
+ // daemon is actually up. If it is not running (crash / kill -9 / never
491
+ // started), every service is stopped no matter what the last-written record
492
+ // says — trusting a stale 'running'/'parked'/'idle' here would print a
493
+ // report that contradicts the live-probed socket rows below (RUSH-2368).
494
+ const state = daemonRunning
495
+ ? (h?.state ?? (s.enabled ? 'running (unsupervised)' : 'stopped'))
496
+ : 'stopped';
497
+ return {
498
+ id: s.id,
499
+ title: s.title,
500
+ description: s.description,
501
+ enabled: s.enabled,
502
+ state,
503
+ supervised,
504
+ lastRunMs: h?.lastOkAt ? Date.parse(h.lastOkAt) : null,
505
+ lastError: h?.lastError ?? null,
506
+ consecutiveFailures: h?.consecutiveFailures ?? 0,
507
+ };
508
+ });
509
+ }
510
+ function serviceStateLabel(state) {
511
+ if (state === 'running')
512
+ return chalk.green('running');
513
+ if (state === 'parked')
514
+ return chalk.red('parked');
515
+ if (state === 'stopped')
516
+ return chalk.gray('stopped');
517
+ if (state === 'idle')
518
+ return chalk.gray('idle');
519
+ return chalk.yellow(state); // 'running (unsupervised)' or any future label
520
+ }
477
521
  async function runServices(opts) {
478
522
  const [secrets, browserIpc] = await Promise.all([probeSecretsBroker(), probeBrowserIPC()]);
523
+ const rows = buildServiceRows(isDaemonRunning());
479
524
  if (opts.json) {
480
525
  console.log(JSON.stringify({
526
+ // Existing fields — unchanged shape, agents/CI consume these directly.
481
527
  secretsBroker: { reachable: secrets.reachable, socketPath: secrets.socketPath, heldBundles: secrets.heldBundles, health: secrets.record },
482
528
  browserIpc: { bound: browserIpc.bound, socketPath: browserIpc.socketPath, sessionCount: browserIpc.sessionCount, health: browserIpc.record },
529
+ // Additive: every registered service, supervised or legacy.
530
+ services: rows,
483
531
  }, null, 2));
484
532
  return;
485
533
  }
486
- console.log(chalk.bold('Hosted services\n'));
534
+ console.log(chalk.bold('Daemon services\n'));
535
+ for (const row of rows) {
536
+ const lastRun = row.lastRunMs ? new Date(row.lastRunMs).toLocaleString() : '-';
537
+ console.log(` ${row.id.padEnd(16)} ${serviceStateLabel(row.state).padEnd(20)} `
538
+ + `enabled=${row.enabled ? 'yes' : 'no '} fails=${row.consecutiveFailures} last-run=${lastRun}`);
539
+ if (row.lastError)
540
+ console.log(chalk.gray(` last-error: ${row.lastError}`));
541
+ }
542
+ console.log(chalk.bold('\nHosted sockets\n'));
487
543
  console.log(healthLine(`secrets broker ${secrets.reachable ? `(${secrets.socketPath}, ${secrets.heldBundles} bundle(s) held)` : '(unreachable)'}`, secrets.reachable, secrets.record));
488
544
  console.log(healthLine(`browser IPC ${browserIpc.bound ? `(${browserIpc.socketPath}, ${browserIpc.sessionCount} session(s))` : '(unbound)'}`, browserIpc.bound, browserIpc.record));
489
545
  console.log(chalk.gray('\nScheduled routines run through `agents routines` — see: agents routines stats'));
546
+ console.log(chalk.gray('agents daemon services enable|disable|restart <id> apply live for supervised services.'));
490
547
  }
491
548
  function parseLogLines(raw) {
492
549
  const out = [];
@@ -795,9 +852,17 @@ export function registerDaemonCommand(program) {
795
852
  # Reload config (SIGHUP) without restarting — picks up routine/scheduler-gate changes
796
853
  agents daemon reload
797
854
 
798
- # Every hosted service (secrets broker, browser IPC, webhook receiver, ...)
855
+ # Every registered service state, enabled, failures, last error
799
856
  agents daemon services
800
857
 
858
+ # Toggle or restart a service live — applies without a daemon restart
859
+ # for supervisor-managed services (secrets-broker, browser-ipc,
860
+ # account-state, session-index, monitors' off-transition, watchdog,
861
+ # device-probe, self-heal, keychain-reap, state-dir-check)
862
+ agents daemon services disable secrets-broker
863
+ agents daemon services enable secrets-broker
864
+ agents daemon services restart secrets-broker
865
+
801
866
  # Host a signed webhook receiver here, supervised and restarted on crash
802
867
  agents daemon webhooks add --secrets-bundle linear-webhook
803
868
  agents daemon webhooks list
@@ -821,6 +886,16 @@ export function registerDaemonCommand(program) {
821
886
  the daemon (daemon.enabled: false in ~/.agents/devices/<host>/agents.yaml).
822
887
  'agents daemon start' still starts it explicitly, same as
823
888
  'systemctl start' on a disabled unit.
889
+
890
+ 'agents daemon services enable|disable|restart <id>' applies live (no
891
+ daemon restart) for the 10 supervisor-managed services: secrets-broker,
892
+ browser-ipc, account-state, session-index, monitors (off only — turning
893
+ monitors back on still needs a restart), watchdog, device-probe,
894
+ self-heal, keychain-reap, and state-dir-check. The other 2 services
895
+ (scheduler, webhook-receiver) are still bare interval/socket setups with
896
+ no live start/stop hook, so a toggle there — and 'enable' on any service
897
+ that was disabled at daemon boot — still needs 'agents daemon restart'.
898
+ 'agents daemon services' names which case you're in per row.
824
899
  `,
825
900
  });
826
901
  cmd.command('status')
@@ -917,11 +992,38 @@ export function registerDaemonCommand(program) {
917
992
  });
918
993
  const servicesCmd = cmd
919
994
  .command('services')
920
- .description('The hosted services: health, bound state, socket path, and per-service toggles.')
995
+ .description('Every registered daemon service: live health, enabled state, and live enable/disable/restart.')
921
996
  .option('--json', 'Emit as JSON')
922
997
  .action(async (opts, command) => {
923
998
  await runServices({ json: command.optsWithGlobals().json === true });
924
999
  });
1000
+ setHelpSections(servicesCmd, {
1001
+ examples: `
1002
+ # State, enabled, consecutive failures, last error for every service
1003
+ agents daemon services
1004
+
1005
+ # Machine-readable — additive: also carries the pre-existing
1006
+ # secretsBroker/browserIpc hosted-socket fields
1007
+ agents daemon services --json
1008
+
1009
+ # Just the enable/disable metadata, no health probe
1010
+ agents daemon services list
1011
+
1012
+ # Toggle or restart live — no daemon restart for a supervisor-managed
1013
+ # service (secrets-broker, browser-ipc, account-state, session-index)
1014
+ agents daemon services disable secrets-broker
1015
+ agents daemon services enable secrets-broker
1016
+ agents daemon services restart secrets-broker
1017
+ `,
1018
+ notes: `
1019
+ A service disabled at daemon boot was never registered on the
1020
+ supervisor, so 'enable' on it — and any toggle/restart on the 2
1021
+ services the supervisor doesn't manage yet (scheduler,
1022
+ webhook-receiver) — falls back to 'restart the daemon to apply'. Each
1023
+ row in the plain-text view names which case it is; 'supervised:
1024
+ true/false' does the same in --json.
1025
+ `,
1026
+ });
925
1027
  servicesCmd
926
1028
  .command('list')
927
1029
  .description('List every daemon service and whether it is enabled.')
@@ -947,9 +1049,28 @@ export function registerDaemonCommand(program) {
947
1049
  console.log(chalk.gray(`\nConfig: ${getDaemonServicesConfigPath()}`));
948
1050
  console.log(chalk.gray('Changes take effect on the next daemon reload or restart.'));
949
1051
  });
1052
+ /**
1053
+ * Apply an enable/disable toggle live (RUSH-3193 P4): persist it, then signal
1054
+ * the running daemon to reload — its handler diffs the toggle and drives
1055
+ * `supervisor.start/stop(id)` for a supervised service, so no restart is
1056
+ * needed for the 10 supervisor-managed services (secrets-broker,
1057
+ * browser-ipc, account-state, session-index, monitors' off-transition,
1058
+ * watchdog, device-probe, self-heal, keychain-reap, state-dir-check). The 2
1059
+ * remaining `setInterval`/socket-driven services (scheduler,
1060
+ * webhook-receiver) and monitors' on-transition still need a restart — the
1061
+ * daemon's own reload log says so.
1062
+ */
1063
+ function applyServiceToggleLive(service) {
1064
+ if (!isDaemonRunning())
1065
+ return;
1066
+ const ok = signalDaemonReload();
1067
+ console.log(ok
1068
+ ? chalk.gray('Signalled the daemon to apply live. Confirm: agents daemon services')
1069
+ : chalk.yellow('Reload signal not delivered — restart the daemon to apply: agents daemon restart'));
1070
+ }
950
1071
  servicesCmd
951
1072
  .command('enable <service>')
952
- .description('Enable a daemon service.')
1073
+ .description('Enable a daemon service. Applies live for supervised services; a legacy service needs a restart.')
953
1074
  .action((service) => {
954
1075
  if (!DAEMON_SERVICE_IDS.includes(service)) {
955
1076
  console.error(chalk.red(`Unknown service '${service}'. Run 'agents daemon services list' for valid services.`));
@@ -957,13 +1078,11 @@ export function registerDaemonCommand(program) {
957
1078
  }
958
1079
  setDaemonServiceEnabled(service, true);
959
1080
  console.log(chalk.green(`Enabled '${service}'.`));
960
- if (isDaemonRunning()) {
961
- console.log(chalk.gray('Run `agents daemon reload` (or restart) to apply.'));
962
- }
1081
+ applyServiceToggleLive(service);
963
1082
  });
964
1083
  servicesCmd
965
1084
  .command('disable <service>')
966
- .description('Disable a daemon service.')
1085
+ .description('Disable a daemon service. Applies live for supervised services; a legacy service needs a restart.')
967
1086
  .action((service) => {
968
1087
  if (!DAEMON_SERVICE_IDS.includes(service)) {
969
1088
  console.error(chalk.red(`Unknown service '${service}'. Run 'agents daemon services list' for valid services.`));
@@ -971,9 +1090,25 @@ export function registerDaemonCommand(program) {
971
1090
  }
972
1091
  setDaemonServiceEnabled(service, false);
973
1092
  console.log(chalk.green(`Disabled '${service}'.`));
974
- if (isDaemonRunning()) {
975
- console.log(chalk.gray('Run `agents daemon reload` (or restart) to apply.'));
1093
+ applyServiceToggleLive(service);
1094
+ });
1095
+ servicesCmd
1096
+ .command('restart <service>')
1097
+ .description('Restart a supervised daemon service live, right now, outside its normal backoff schedule.')
1098
+ .action((service) => {
1099
+ if (!DAEMON_SERVICE_IDS.includes(service)) {
1100
+ console.error(chalk.red(`Unknown service '${service}'. Run 'agents daemon services list' for valid services.`));
1101
+ process.exit(1);
976
1102
  }
1103
+ if (!isDaemonRunning()) {
1104
+ console.log(chalk.yellow('Daemon is not running — nothing to restart. Start it: agents daemon start'));
1105
+ process.exit(1);
1106
+ }
1107
+ queueDaemonServiceRestart(service);
1108
+ const ok = signalDaemonReload();
1109
+ console.log(ok
1110
+ ? chalk.green(`Restart requested for '${service}'.`) + chalk.gray(' Confirm: agents daemon services')
1111
+ : chalk.yellow('Reload signal not delivered (unsupported on this platform, or the daemon just exited).'));
977
1112
  });
978
1113
  registerWebhooksSubcommand(cmd);
979
1114
  registerFunnelCommand(cmd);
@@ -508,7 +508,7 @@ export function registerRunCommand(program) {
508
508
  .option('--no-auth-check', 'Skip the pre-launch "looks logged out" warning on an interactive run (advisory; never blocks anyway). Also silenced by AGENTS_NO_AUTH_CHECK=1.')
509
509
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided. Mutually exclusive with --headless.')
510
510
  .option('--broadcast', 'Run the same prompt or --task across multiple agents (comma-separated [agent]) × --model cells. Replaces the former `agents bench` group.')
511
- .option('--task <id>', 'With --broadcast: house benchmark task id under apps/cli/bench/tasks/')
511
+ .option('--task <id>', 'With --broadcast: house benchmark task id under cli/bench/tasks/')
512
512
  .option('--list-tasks', 'With --broadcast: list available broadcast task ids')
513
513
  .option('--results [run-id]', 'With --broadcast: show one saved matrix run, or list saved runs newest first')
514
514
  .option('--concurrency <n>', 'With --broadcast: maximum cells running at once', '3')
@@ -1501,6 +1501,19 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1501
1501
  if (process.env.AGENTS_DISPATCH_DEBUG || options.verbose) {
1502
1502
  process.stderr.write(chalk.gray(`[hosts] dispatch interactive ${runAgent}${runVersion ? `@${runVersion}` : ''} -> ${host.name}\n`));
1503
1503
  }
1504
+ {
1505
+ const { connectionStartedNotice, startConnectionTarget } = await import('../lib/hosts/reconnect.js');
1506
+ const startTarget = startConnectionTarget({
1507
+ agent: runAgent,
1508
+ hostSessionId,
1509
+ resumeId,
1510
+ });
1511
+ if (startTarget) {
1512
+ const started = connectionStartedNotice(startTarget, host.name);
1513
+ if (started)
1514
+ process.stderr.write(chalk.gray(started));
1515
+ }
1516
+ }
1504
1517
  const exitCode = await runInteractiveOnHost(host, {
1505
1518
  agent: runAgent,
1506
1519
  version: resumeId ? undefined : runVersion,
@@ -1565,19 +1578,32 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1565
1578
  // `raw` runs aren't tmux wrapped, so there is nothing to reconnect to.
1566
1579
  // For `run auto` prefer the join-resolved id (the harness the remote
1567
1580
  // ACTUALLY picked) over the explicit --session-id only claude adopts.
1568
- const reconnectId = (runAgent === RUN_AUTO_KEYWORD
1569
- ? resolvedRemoteId ?? hostSessionId
1570
- : hostSessionId ?? resolvedRemoteId) ?? resumeId;
1571
- if (reconnectId && !isRaw) {
1572
- const { reconnectInteractiveSession, SSH_CONN_FAILURE } = await import('../lib/hosts/reconnect.js');
1573
- if (exitCode === SSH_CONN_FAILURE) {
1574
- process.exit(await reconnectInteractiveSession({
1575
- host,
1576
- sessionId: reconnectId,
1577
- initialExit: exitCode,
1578
- }));
1579
- }
1581
+ const { pickReconnectTarget, reconnectInteractiveSession, afterInteractiveRemoteExit, SSH_CONN_FAILURE } = await import('../lib/hosts/reconnect.js');
1582
+ const reconnectTarget = pickReconnectTarget({
1583
+ agent: runAgent,
1584
+ sessionId: hostSessionId,
1585
+ resolvedId: resolvedRemoteId,
1586
+ resumeId,
1587
+ launchId: correlationLaunchId,
1588
+ });
1589
+ // Raw is not tmux-wrapped, so it never auto-reconnects — but it
1590
+ // still prints the session id (EXEC-55). Bundling the notice
1591
+ // behind `!isRaw` left a dropped `--raw` tab as a bare shell.
1592
+ const next = afterInteractiveRemoteExit({
1593
+ target: reconnectTarget,
1594
+ host: host.name,
1595
+ exitCode,
1596
+ willReconnect: exitCode === SSH_CONN_FAILURE && !isRaw,
1597
+ });
1598
+ if (next.reconnect && reconnectTarget) {
1599
+ process.exit(await reconnectInteractiveSession({
1600
+ host,
1601
+ target: reconnectTarget,
1602
+ initialExit: exitCode,
1603
+ }));
1580
1604
  }
1605
+ if (next.notice)
1606
+ process.stderr.write(next.notice);
1581
1607
  process.exit(exitCode);
1582
1608
  }
1583
1609
  // Headless host run: launch detached, tail the remote log, and follow
@@ -2268,7 +2294,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2268
2294
  const peer = sessionRecoveryPeer(session);
2269
2295
  if (peer) {
2270
2296
  const { runOnPeer } = await import('../lib/session/remote-list.js');
2271
- const routed = await runOnPeer(sessionRecoveryRunArgs(session), peer, { tty: true });
2297
+ const routed = await runOnPeer(sessionRecoveryRunArgs(session), peer, { tty: true, sessionId: session.id });
2272
2298
  if (routed === 'no-target') {
2273
2299
  console.error(chalk.red(`Cannot recover session ${session.shortId}: origin device ${peer} is not a registered reachable peer.`));
2274
2300
  process.exitCode = 1;
@@ -2523,7 +2549,18 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2523
2549
  try {
2524
2550
  const { getAccountInfo } = await import('../lib/agents.js');
2525
2551
  const info = await getAccountInfo(agent);
2526
- if (!info.signedIn) {
2552
+ // Claude authenticates interactively from a per-version setup-token on a
2553
+ // keychain-less worker (the shim's .oauth_token fallback), which the
2554
+ // native-credential probe above can't see — so don't warn "logged out" when
2555
+ // a setup-token resolves for this version. No-op on macOS, where the
2556
+ // credential lives in the keychain and resolveClaudeSetupToken returns null.
2557
+ let authedViaSetupToken = false;
2558
+ if (!info.signedIn && agent === 'claude' && version) {
2559
+ const { resolveClaudeSetupToken } = await import('../lib/claude-account-token.js');
2560
+ const { getVersionHomePath } = await import('../lib/installations/versions.js');
2561
+ authedViaSetupToken = resolveClaudeSetupToken(getVersionHomePath('claude', version)) !== null;
2562
+ }
2563
+ if (!info.signedIn && !authedViaSetupToken) {
2527
2564
  process.stderr.write(chalk.yellow(`⚠ ${agent} looks logged out — log in with: ${loginHint(agent)}. Launching anyway...\n`));
2528
2565
  }
2529
2566
  }
@@ -20,6 +20,19 @@ import { type LiveStatusFilter } from './sessions.js';
20
20
  import { openSurfaces, type Backend } from '../lib/terminal/index.js';
21
21
  /** Options for `sessions focus` — device scope + the `--active` live-state filters. */
22
22
  export interface FocusOptions {
23
+ /**
24
+ * Resolve the target from the launcher's AGENT_LAUNCH_ID instead of a session
25
+ * id (RUSH-3125).
26
+ *
27
+ * A `--device` launcher mints this id BEFORE it opens the connection and
28
+ * forwards it, so it is the one handle that is still known after the link
29
+ * dies. A session id is not: only Claude is handed one up front, and every
30
+ * other harness's real id had to be read back off the peer — over the very
31
+ * link that just dropped — which is why a blink left a Grok or Codex tab with
32
+ * nothing to reconnect to. The lookup runs HERE, on the box that owns the hook
33
+ * records, so it needs no network at the moment it is needed most.
34
+ */
35
+ launchId?: string;
23
36
  local?: boolean;
24
37
  attachOnly?: boolean;
25
38
  device?: string[];
@@ -54,6 +67,7 @@ export interface FocusOptions {
54
67
  abandoned?: boolean;
55
68
  queued?: boolean;
56
69
  unknown?: boolean;
70
+ reconnectReattach?: boolean;
57
71
  }
58
72
  /** Commander gives overlapping `sessions` flags to the parent command, even
59
73
  * when they appear after `focus`. Fold only values explicitly provided there
@@ -80,8 +80,10 @@ export function registerFocusCommand(program) {
80
80
  // console.warn here without first removing that delegation.
81
81
  .command('focus', { hidden: true })
82
82
  .argument('[selector]', 'Session id/prefix, agent@version, or topic/path search')
83
+ .option('--launch-id <id>', 'Target the run by its launcher AGENT_LAUNCH_ID instead of a session id (resolved from this machine\'s hook records)')
83
84
  .option('--local', 'Only this machine (skip the cross-host sweep)')
84
85
  .option('--attach-only', 'Attach only — never open a new tab / resume a copy (the old `go` behavior)')
86
+ .option('--reconnect-reattach', '(internal) Set by the reconnect loop — warns when the expected pane is dead and a fresh copy starts instead', false)
85
87
  .option('-D, --device <target...>', 'Scope the picker to live sessions on these devices (device alias from `agents devices`, user@host; repeatable)')
86
88
  .option('--active', 'Only sessions present in the live roster')
87
89
  .option('-a, --agent <agent>', 'Filter by harness and recorded version (for example claude@latest)')
@@ -162,6 +164,38 @@ export function selectFallback(attachOnly) {
162
164
  return attachOnly ? refuseFallback : resumeInNewTab;
163
165
  }
164
166
  export async function focusAction(id, opts) {
167
+ // RUSH-3125: `--launch-id` names the run by the handle its launcher minted,
168
+ // and this box holds the hook record that maps it to the real session id — a
169
+ // local read, so it still works when the network that started the run is gone.
170
+ // Resolved up front into the ordinary id path: everything below, including the
171
+ // attach-else-resume recovery, is identical once we know which session it is.
172
+ if (opts.launchId) {
173
+ if (id) {
174
+ console.error(chalk.red('Pass a session id or --launch-id, not both — they name the same thing two ways.'));
175
+ process.exitCode = 1;
176
+ return;
177
+ }
178
+ // Read the launch-id index directly rather than through
179
+ // `resolveHookSessionRecord`: that helper kind-guards every hit, which exists
180
+ // to stop a REUSED PID crossing harnesses. A launch id is a per-launch uuid
181
+ // and cannot collide, and the caller here is a reattach that deliberately
182
+ // does not assume which harness the peer picked (`run auto` chooses it
183
+ // remotely), so demanding a kind would reject the exact case this serves.
184
+ const { loadHookSessionIndex } = await import('../lib/session/hook-sessions.js');
185
+ const resolved = loadHookSessionIndex().byLaunchId.get(opts.launchId)?.session_id;
186
+ if (!resolved) {
187
+ // Fail loud: a launch id with no record means the harness never got far
188
+ // enough to write one (it died at startup, or it has no SessionStart
189
+ // hook). Silently opening the picker here would strand an automated
190
+ // reattach in an interactive prompt nobody is watching.
191
+ console.error(chalk.red(`No session recorded for launch id ${opts.launchId} on ${machineId()}.`));
192
+ console.error(chalk.gray(' The run may have failed before its SessionStart hook fired.'));
193
+ console.error(chalk.gray(' Look for it: agents sessions --active'));
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+ id = resolved;
198
+ }
165
199
  const hosts = mergeFocusHosts(opts);
166
200
  const statuses = requestedLiveStatuses(opts);
167
201
  // A device scope needs the cross-host sweep; --local only wins when no host is named.
@@ -204,7 +238,7 @@ export async function focusAction(id, opts) {
204
238
  // still joined instead of resumed as a copy; a crashed session has no live
205
239
  // row and recovers in place.
206
240
  const { activeById } = await gatherLiveTargets(true, { statuses: [] });
207
- await focusResolvedSession(localMatch[0], activeById, self, fallback, opts.attachOnly === true);
241
+ await focusResolvedSession(localMatch[0], activeById, self, fallback, opts.attachOnly === true, opts.reconnectReattach === true);
208
242
  return;
209
243
  }
210
244
  }
@@ -290,7 +324,7 @@ export async function focusAction(id, opts) {
290
324
  exact = [focusTargetForResolved(filteredMatch, outcome.session)];
291
325
  }
292
326
  if (exact.length === 1) {
293
- await focusResolvedSession(exact[0], liveById, self, fallback, opts.attachOnly === true);
327
+ await focusResolvedSession(exact[0], liveById, self, fallback, opts.attachOnly === true, opts.reconnectReattach === true);
294
328
  return;
295
329
  }
296
330
  if (exact.length > 1) {
@@ -524,7 +558,7 @@ async function pickFocusCandidates(sessions, liveById, initialSearch) {
524
558
  export function focusTargetForResolved(poolMatch, resolved) {
525
559
  return poolMatch ?? resolved;
526
560
  }
527
- async function focusResolvedSession(meta, liveById, self, fallback, attachOnly) {
561
+ async function focusResolvedSession(meta, liveById, self, fallback, attachOnly, reconnectReattach = false) {
528
562
  const active = liveById.get(meta.id);
529
563
  if (active && isAttachableLiveSession(active)) {
530
564
  await jumpTo(active, self, fallback);
@@ -535,10 +569,19 @@ async function focusResolvedSession(meta, liveById, self, fallback, attachOnly)
535
569
  process.exitCode = 1;
536
570
  return;
537
571
  }
572
+ if (reconnectReattach) {
573
+ // The reconnect loop expected this pane to be alive. It isn't — the peer
574
+ // rebooted, tmux crashed, or the agent exited while the link was down.
575
+ // Warn before any recovery attempt (local resume OR remote hop) so the
576
+ // user always knows their original context was not directly recovered.
577
+ console.error(chalk.yellow(`\nWarning: ${meta.shortId}'s pane is gone (reboot, tmux crash, or agent exited).`));
578
+ console.error(chalk.gray(` Prior context is in the transcript — starting recovery.\n` +
579
+ ` To see what happened: agents sessions preview ${meta.shortId}`));
580
+ }
538
581
  const remote = sessionProcessHost(meta, self);
539
582
  if (remote) {
540
583
  console.log(chalk.gray(`Recovering ${meta.shortId} on ${remote}…`));
541
- const rc = await runOnPeer(sessionRecoveryRunArgs(meta), remote, { tty: true });
584
+ const rc = await runOnPeer(sessionRecoveryRunArgs(meta), remote, { tty: true, sessionId: meta.id });
542
585
  if (rc === 'no-target') {
543
586
  console.error(chalk.red(`Cannot recover ${meta.shortId}: ${remote} is unreachable.`));
544
587
  process.exitCode = 1;
@@ -765,7 +808,7 @@ const resumeInNewTab = async (s, remote) => {
765
808
  // it actually delivers you to the session (the peer picks the right version + HOME).
766
809
  if (remote) {
767
810
  console.log(chalk.gray(`${shortId(s)} has no live terminal on ${remote} — resuming it there over SSH…`));
768
- const rc = await runOnPeer(sessionRecoveryRunArgs({ id }), remote, { tty: true });
811
+ const rc = await runOnPeer(sessionRecoveryRunArgs({ id }), remote, { tty: true, sessionId: id });
769
812
  if (rc === 'no-target') {
770
813
  console.log(chalk.red(`${remote} isn't reachable as a device. Try: agents devices sync`));
771
814
  console.log(chalk.gray(` recovery must run on ${remote}, where the indexed session originated`));
@@ -66,6 +66,9 @@ export interface Where {
66
66
  label: string;
67
67
  action: string;
68
68
  }
69
+ /** What `jumpTo` writes after a remote tmux attach's SSH stream returns. Pure so
70
+ * the ControlMaster close path is unit-tested without SSH (RUSH-3227). */
71
+ export declare function remoteAttachEndedNotice(sessionId: string | undefined, host: string, code: number): string | undefined;
69
72
  /**
70
73
  * Pure, testable mirror of `jumpTo`'s path selection (jumpTo itself has side
71
74
  * effects — process.exit / ssh / osascript). Keep the branch ORDER in sync with
@@ -30,7 +30,8 @@ import { machineId } from '../lib/session/sync/config.js';
30
30
  import { attachTmux, runTmux } from '../lib/tmux/binary.js';
31
31
  import { ensureSessionHookRepaired, paneExitStatus } from '../lib/tmux/session.js';
32
32
  import { getDefaultSocketPath } from '../lib/tmux/paths.js';
33
- import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
33
+ import { sshExec, sshStream, assertValidSshTarget, shellQuote, SSH_CONN_FAILURE_CODE } from '../lib/ssh-exec.js';
34
+ import { connectionEndedNotice } from '../lib/hosts/reconnect.js';
34
35
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
35
36
  const execFileAsync = promisify(execFile);
36
37
  export function registerGoCommand(program) {
@@ -191,6 +192,13 @@ function synthMeta(s, self) {
191
192
  function shortId(s) {
192
193
  return (s.sessionId ?? '').slice(0, 8) || '-';
193
194
  }
195
+ /** What `jumpTo` writes after a remote tmux attach's SSH stream returns. Pure so
196
+ * the ControlMaster close path is unit-tested without SSH (RUSH-3227). */
197
+ export function remoteAttachEndedNotice(sessionId, host, code) {
198
+ if (!sessionId)
199
+ return undefined;
200
+ return connectionEndedNotice({ kind: 'session', id: sessionId }, host, { dropped: code === SSH_CONN_FAILURE_CODE });
201
+ }
194
202
  /**
195
203
  * Pure, testable mirror of `jumpTo`'s path selection (jumpTo itself has side
196
204
  * effects — process.exit / ssh / osascript). Keep the branch ORDER in sync with
@@ -276,8 +284,13 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
276
284
  `sess=$(tmux ${sock}display-message -pt ${p} '#{session_name}' 2>/dev/null); ` +
277
285
  `[ -n "$w" ] && tmux ${sock}select-window -t "$w" 2>/dev/null; ` +
278
286
  `exec tmux ${sock}attach-session -t "\${sess:-${p}}"`;
279
- console.log(chalk.gray(`Attaching ${shortId(s)} on ${remote} over SSH — Ctrl-b d to detach.`));
280
- process.exit(sshStream(remote, remoteCmd, { tty: true }));
287
+ const attachId = s.sessionId || shortId(s);
288
+ console.log(chalk.gray(`Attaching ${attachId} on ${remote} over SSH — Ctrl-b d to detach.`));
289
+ const code = sshStream(remote, remoteCmd, { tty: true });
290
+ const notice = remoteAttachEndedNotice(s.sessionId, remote, code);
291
+ if (notice)
292
+ process.stderr.write(notice);
293
+ process.exit(code);
281
294
  }
282
295
  // Remote, not in tmux → hand off to the fallback (go: shell; focus: resume in a tab).
283
296
  await fallback(s, remote);
@@ -274,8 +274,8 @@ function isDotAgentsRoot(dir) {
274
274
  for (const kind of DRILLABLE_KINDS) {
275
275
  // `routines/` alone is NOT a marker. Shipping example routine YAML for
276
276
  // `agents routines add` is a documented pattern (this repo's own
277
- // apps/cli/routines/), and that directory is the ONLY kind dir there — so
278
- // counting it would make `agents inspect apps/cli` resolve a source tree as
277
+ // cli/routines/), and that directory is the ONLY kind dir there — so
278
+ // counting it would make `agents inspect cli` resolve a source tree as
279
279
  // a DotAgents repo. Same reasoning as the nested-`.agents/` case above.
280
280
  if (kind === 'routines')
281
281
  continue;