@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Auto-reconnect for an interactive `agents run --device/--host` session whose
3
+ * SSH link dropped.
4
+ *
5
+ * A remote interactive agent runs in a DETACHED tmux session on the peer (see
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
8
+ * own connection-layer failure; see ssh-exec.ts). Without this, exec.ts would
9
+ * `process.exit(255)` and the user would have to notice, find the session id, and
10
+ * `agents sessions focus` by hand. Instead we re-attach the live remote pane over
11
+ * 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).
13
+ *
14
+ * The re-attach reuses the peer's OWN reconnect verb — `agents sessions focus <id>
15
+ * --local --attach-only` joins the live local tmux pane there (no fork, no resumed
16
+ * copy) — so there is no second re-attach implementation to keep in sync.
17
+ *
18
+ * **Why the budget refills on `connected`, not on call duration.** ssh returns 255
19
+ * for BOTH "couldn't connect at all" and "connected, then the link dropped." A
20
+ * failed connect still takes up to `ConnectTimeout` (10s) to return, so timing
21
+ * alone can't tell the two apart — a duration threshold below the connect timeout
22
+ * would classify every hung connect as a live session and retry forever under a
23
+ * sustained outage (the exact failure this feature exists to survive). Instead each
24
+ * attempt runs a fast preflight probe: a reattach that genuinely reconnected (and
25
+ * then dropped again) refills the retry budget; one that never reached the host
26
+ * counts against it, so a sustained outage gives up after MAX_ATTEMPTS.
27
+ *
28
+ * The retry policy is a pure state machine (`reconnectStep`) so it is unit-tested
29
+ * without touching SSH; the loop (`reconnectInteractiveSession`) only adds the real
30
+ * preflight + `sshStream` re-attach and the wait.
31
+ */
32
+ import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
33
+ import { sshTargetFor } from './types.js';
34
+ /** ssh's connection-layer failure code — the signal that the link dropped rather
35
+ * than the remote command exiting on its own. Mirrors ssh-exec.ts `sshStream`. */
36
+ export const SSH_CONN_FAILURE = 255;
37
+ /** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
38
+ * {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
39
+ * refills the budget, so a long session that blinks all day reconnects every time
40
+ * — only consecutive UNREACHABLE attempts exhaust it. */
41
+ export const MAX_ATTEMPTS = 6;
42
+ const BASE_BACKOFF_MS = 2_000;
43
+ const MAX_BACKOFF_MS = 30_000;
44
+ export function initialReconnectState() {
45
+ return { attempt: 0 };
46
+ }
47
+ /** Exponential backoff capped at {@link MAX_BACKOFF_MS}: 2s, 4s, 8s, 16s, 30s… */
48
+ export function backoffMs(attempt) {
49
+ return Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
50
+ }
51
+ /**
52
+ * Decide what to do after a run/re-attach returned `outcome`. Pure — the only
53
+ * input is the prior state and the outcome, the only output is the next action.
54
+ *
55
+ * - a non-255 code means the remote command spoke for itself (clean detach = 0,
56
+ * agent exit / no live session = non-zero) → stop and surface that code.
57
+ * - a 255 means the link dropped → retry, unless the budget is spent.
58
+ * - a 255 from an attempt that DID connect (a genuine reconnection that then
59
+ * dropped) refills the budget first; a 255 that never connected counts against
60
+ * it, so a host that stays unreachable gives up after MAX_ATTEMPTS.
61
+ */
62
+ export function reconnectStep(state, outcome) {
63
+ if (outcome.code !== SSH_CONN_FAILURE)
64
+ return { action: 'stop', code: outcome.code };
65
+ const attempts = outcome.connected ? 0 : state.attempt;
66
+ if (attempts >= MAX_ATTEMPTS)
67
+ return { action: 'stop', code: SSH_CONN_FAILURE };
68
+ return { action: 'retry', waitMs: backoffMs(attempts), state: { attempt: attempts + 1 } };
69
+ }
70
+ /** Human-readable notice shown before each reconnect wait. "13 seconds", not "12.8s". */
71
+ export function reconnectNotice(sessionId, host, attempt, waitMs) {
72
+ const secs = Math.round(waitMs / 1000);
73
+ const when = secs <= 1 ? 'now' : `in ${secs} seconds`;
74
+ return `\nConnection to ${host} dropped — the agent is still running there. Reconnecting to ${sessionId.slice(0, 8)} ${when} (attempt ${attempt}/${MAX_ATTEMPTS})…\n`;
75
+ }
76
+ /** Notice shown once the retry budget is spent. */
77
+ export function exhaustedNotice(sessionId, host) {
78
+ return `\nCouldn't reconnect to ${host} after ${MAX_ATTEMPTS} attempts. The agent may still be running — reattach when the network is back:\n agents sessions focus ${sessionId.slice(0, 8)}\n`;
79
+ }
80
+ /**
81
+ * Re-attach the live remote tmux pane for `sessionId` by driving the peer's own
82
+ * `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
83
+ * first establishes whether the host is actually reachable this attempt — that
84
+ * `connected` bit, not the call duration, is what the retry policy keys on. Only on
85
+ * a reachable host do we run the interactive attach (which carries no credentials —
86
+ * the agent already runs on the peer — so it rides the normal transport). Returns
87
+ * the ssh exit code (255 = dropped again / unreachable; 0 = clean detach; other =
88
+ * session ended) plus whether this attempt connected.
89
+ */
90
+ export function reattachRemoteSession(host, sessionId) {
91
+ const target = sshTargetFor(host);
92
+ // Fresh (non-multiplexed) reachability probe: code 0 means the handshake actually
93
+ // completed, so a hung/failed connect is never mistaken for a live reconnection.
94
+ const probe = sshExec(target, 'true', { multiplex: false });
95
+ if (probe.code !== 0)
96
+ return { code: SSH_CONN_FAILURE, connected: false };
97
+ const remoteCmd = ['agents', 'sessions', 'focus', sessionId, '--local', '--attach-only']
98
+ .map(shellQuote)
99
+ .join(' ');
100
+ return { code: sshStream(target, remoteCmd, { tty: true }), connected: true };
101
+ }
102
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
103
+ /**
104
+ * Drive the reconnect loop from the initial run's outcome to a terminal code.
105
+ * Only the real SSH re-attach and the wait are side effects; the decision is
106
+ * {@link reconnectStep}. Returns the exit code the process should ultimately use.
107
+ */
108
+ export async function reconnectInteractiveSession(opts) {
109
+ const write = opts.write ?? ((s) => process.stderr.write(s));
110
+ const wait = opts.wait ?? sleep;
111
+ const reattach = opts.reattach ?? reattachRemoteSession;
112
+ let state = initialReconnectState();
113
+ // The initial run reached the point of running the agent, so it connected.
114
+ let outcome = { code: opts.initialExit, connected: true };
115
+ for (;;) {
116
+ const decision = reconnectStep(state, outcome);
117
+ if (decision.action === 'stop') {
118
+ if (decision.code === SSH_CONN_FAILURE)
119
+ write(exhaustedNotice(opts.sessionId, opts.host.name));
120
+ return decision.code;
121
+ }
122
+ write(reconnectNotice(opts.sessionId, opts.host.name, decision.state.attempt, decision.waitMs));
123
+ await wait(decision.waitMs);
124
+ state = decision.state;
125
+ outcome = reattach(opts.host, opts.sessionId);
126
+ }
127
+ }
@@ -1,29 +1,90 @@
1
1
  /**
2
- * Host provider registry.
2
+ * Host provider registry + the single host/device resolver.
3
3
  *
4
4
  * Mirrors the cloud provider registry: a Map of provider id → implementation,
5
- * instantiated once. Registers `local` then `devices` (order is precedence:
6
- * an enrolled host shadows a same-name device); adding `rush`/`crabbox` later
7
- * is a one-line `providers.set(...)` with no caller changes.
5
+ * instantiated once. Registers `local` then `devices`; adding `rush`/`crabbox`
6
+ * later is a one-line `providers.set(...)`.
7
+ *
8
+ * Resolution used to fork into two disagreeing chains — a local-provider-first
9
+ * `resolveHost` (this file) and a devices-only `resolveSshTarget`
10
+ * (`../devices/resolve-target.ts`). They dialed different boxes for the same
11
+ * token (RUSH-1967). Now every caller goes through one core, {@link matchHost}:
12
+ * it merges the two directories per-FIELD instead of letting one provider
13
+ * shadow the other — the live devices registry supplies address/OS/presence,
14
+ * the agents.yaml overlay supplies caps/hints, and ssh_config supplies hosts
15
+ * Tailscale has never seen. The typed wrappers below (`resolveHost`) and in
16
+ * resolve-target.ts (`resolveExplicitTargets`, `resolveDeviceTarget`) differ
17
+ * only in what shape they hand back and which literal fallbacks they permit.
8
18
  */
9
19
  import type { Host, HostProvider, HostProviderId } from './types.js';
10
20
  import { DeviceOffloadUnsupportedError } from './types.js';
21
+ import { type DeviceProfile } from '../devices/registry.js';
11
22
  export { DeviceOffloadUnsupportedError };
12
23
  export declare function getProvider(id: HostProviderId): HostProvider;
13
24
  export declare function getAllProviders(): HostProvider[];
14
- /** Every host across all registered providers, deduped by name (first wins). */
25
+ /**
26
+ * A resolved host, carrying the live {@link DeviceProfile} when the token
27
+ * matched a registered device (by normalized name). The device reference lets
28
+ * the `agents ssh` wrapper reach auth/shell/tailscale metadata a plain `Host`
29
+ * doesn't hold, and lets dispatch callers apply the device-only refusals
30
+ * (control device, password auth) without re-reading the registry.
31
+ */
32
+ export interface ResolvedHost extends Host {
33
+ device?: DeviceProfile;
34
+ /** True when the host is a synthesized ad-hoc `user@host` / IP / FQDN literal
35
+ * (never registered) rather than a device or overlay/ssh-config match. Lets the
36
+ * strict `agents ssh` wrapper accept devices + literals but still report
37
+ * "Unknown device" for a bare ssh-config-only alias, as it always has. */
38
+ adhoc?: boolean;
39
+ }
40
+ /** Split a `user@host` / `host` token into its login user (if any) and host part. */
41
+ export declare function splitUserHost(token: string): {
42
+ user?: string;
43
+ host: string;
44
+ };
45
+ /** Literal-fallback policy for {@link matchHost}. */
46
+ export interface MatchHostOptions {
47
+ /**
48
+ * Also treat an unmatched dotted/colon host literal (a raw IP or FQDN with no
49
+ * `user@`) as an ad-hoc target. `agents ssh 1.2.3.4` sets this; dispatch and
50
+ * fan-out leave it off, so only a `user@host` is taken as an ad-hoc literal
51
+ * (a bare/dotted unknown is a miss, keeping capability-tag routing and the
52
+ * "Unknown device" verdict reachable).
53
+ */
54
+ allowBareLiteral?: boolean;
55
+ }
56
+ /**
57
+ * The one place a `--host` / `--device` token becomes a resolved host. Reads the
58
+ * devices registry, the agents.yaml overlay, and ssh_config, and merges them
59
+ * per-field (see {@link deviceHost}). One grammar for every caller: `name`,
60
+ * `user@name`, a tailnet FQDN, an ssh_config alias, and a literal `user@host`
61
+ * all resolve the same way regardless of which subcommand called.
62
+ *
63
+ * Non-throwing: returns null on an injection-guard failure or an unresolved bare
64
+ * token. Device-only refusals (control device, password auth) are NOT applied
65
+ * here — that is the dispatch wrapper's job ({@link resolveHost}), so the fan-out
66
+ * and `agents ssh` paths, which handle those cases differently, aren't forced
67
+ * into a dispatch verdict.
68
+ */
69
+ export declare function matchHost(name: string, opts?: MatchHostOptions): Promise<ResolvedHost | null>;
70
+ /** Every host across all registered providers, merged by name so a device's
71
+ * presence/dispatchable/address and an overlay's caps coexist on one row
72
+ * (RUSH-1967: first-wins dedup used to drop the device row, and its status/caps
73
+ * with it). Provider order still decides the base row (`local` first). */
15
74
  export declare function listAllHosts(): Promise<Host[]>;
16
75
  /**
17
- * Resolve a host name to a single host, or null if unknown. Resolution order:
18
- * 1. host providers, in registration order the `agents hosts` registry
19
- * (`local`: agents.yaml overlay + ssh-config), then the devices registry
20
- * (`devices`: a machine registered once with `agents devices sync` is
21
- * reachable by `--host`/`--device` with no second enroll)
22
- * 2. an ad-hoc `user@host` (must contain `@`, validated) nothing to register
76
+ * Resolve a `--host`/`--device` token for DISPATCH the shape every offload
77
+ * caller consumes (`run --host`, the generic passthrough, teams placement, the
78
+ * cloud host provider, doctor, funnel, remote secrets). Grammar and merge come
79
+ * from {@link matchHost}; on top, this layer applies the device-only dispatch
80
+ * refusals so they hold even when an inline overlay shadows the device:
81
+ * - a control device (a paired iPhone) can never be a dispatch target;
82
+ * - a password-auth device throws the typed {@link DeviceOffloadUnsupportedError}
83
+ * (offload rides `BatchMode=yes` ssh, which can't answer a prompt);
84
+ * - an addressless device has nothing to dial.
23
85
  *
24
- * A bare unknown name returns null so capability-tag routing (`resolveHostByCap`)
25
- * stays reachable: `--host gpu` must fall through to a cap lookup, not be misread
26
- * as an ad-hoc target.
86
+ * A bare unknown name returns null so capability-tag routing (`resolveHostByCap`,
87
+ * e.g. `--host gpu`) stays reachable, and an ad-hoc `user@host` still resolves.
27
88
  */
28
89
  export declare function resolveHost(name: string): Promise<Host | null>;
29
90
  /**
@@ -1,15 +1,30 @@
1
1
  /**
2
- * Host provider registry.
2
+ * Host provider registry + the single host/device resolver.
3
3
  *
4
4
  * Mirrors the cloud provider registry: a Map of provider id → implementation,
5
- * instantiated once. Registers `local` then `devices` (order is precedence:
6
- * an enrolled host shadows a same-name device); adding `rush`/`crabbox` later
7
- * is a one-line `providers.set(...)` with no caller changes.
5
+ * instantiated once. Registers `local` then `devices`; adding `rush`/`crabbox`
6
+ * later is a one-line `providers.set(...)`.
7
+ *
8
+ * Resolution used to fork into two disagreeing chains — a local-provider-first
9
+ * `resolveHost` (this file) and a devices-only `resolveSshTarget`
10
+ * (`../devices/resolve-target.ts`). They dialed different boxes for the same
11
+ * token (RUSH-1967). Now every caller goes through one core, {@link matchHost}:
12
+ * it merges the two directories per-FIELD instead of letting one provider
13
+ * shadow the other — the live devices registry supplies address/OS/presence,
14
+ * the agents.yaml overlay supplies caps/hints, and ssh_config supplies hosts
15
+ * Tailscale has never seen. The typed wrappers below (`resolveHost`) and in
16
+ * resolve-target.ts (`resolveExplicitTargets`, `resolveDeviceTarget`) differ
17
+ * only in what shape they hand back and which literal fallbacks they permit.
8
18
  */
9
19
  import { DeviceOffloadUnsupportedError } from './types.js';
10
20
  import { LocalHostProvider } from './providers/local.js';
11
21
  import { DevicesHostProvider } from './providers/devices.js';
12
22
  import { assertValidSshTarget } from '../ssh-exec.js';
23
+ import { normalizeHost } from '../machine-id.js';
24
+ import { readMeta } from '../state.js';
25
+ import { isSshConfigHost } from './ssh-config.js';
26
+ import { resolveRemoteOsSync } from './remote-os.js';
27
+ import { loadDevices, isControlDevice } from '../devices/registry.js';
13
28
  // Re-export so existing importers (tests, commands) keep their path; the class
14
29
  // itself lives in types.ts so providers can throw it without a circular import.
15
30
  export { DeviceOffloadUnsupportedError };
@@ -32,44 +47,204 @@ export function getAllProviders() {
32
47
  initProviders();
33
48
  return [...providers.values()];
34
49
  }
35
- /** Every host across all registered providers, deduped by name (first wins). */
50
+ /** Split a `user@host` / `host` token into its login user (if any) and host part. */
51
+ export function splitUserHost(token) {
52
+ const at = token.indexOf('@');
53
+ return at === -1 ? { host: token } : { user: token.slice(0, at), host: token.slice(at + 1) };
54
+ }
55
+ /** True when a token is clearly a network target (a `user@`, or a dotted/IPv6
56
+ * host / IP) rather than a bare alias. A bare unknown word is a typo, so a
57
+ * strict caller (`agents ssh foo`) reports "Unknown device" instead of dialing
58
+ * a literal `foo`. */
59
+ function looksLikeHostLiteral(token) {
60
+ return token.includes('@') || token.includes('.') || token.includes(':');
61
+ }
62
+ /**
63
+ * Match a host part (the piece after any `user@`) to a registered device: exact
64
+ * registry key first, then a normalized-host match so `yosemite-s0` and
65
+ * `yosemite-s0.<tailnet>.ts.net` land on the same profile. This normalized
66
+ * grammar used to live only in the devices chain (RUSH-1967 divergence #3/#4);
67
+ * it is now shared by every caller.
68
+ */
69
+ function matchDevice(host, reg) {
70
+ return reg[host] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(host));
71
+ }
72
+ /** Tailscale's own presence bit for a device, when the sync captured one. */
73
+ function deviceStatus(device) {
74
+ if (!device.tailscale)
75
+ return 'unknown';
76
+ return device.tailscale.online ? 'online' : 'offline';
77
+ }
78
+ /**
79
+ * Merge a matched device with any same-name agents.yaml overlay into one Host,
80
+ * per-FIELD (the core of the RUSH-1967 fix). The live registry owns address, OS
81
+ * and presence — so `agents devices sync` takes effect without re-enrolling and
82
+ * an enrolled route can never freeze — while the overlay contributes capability
83
+ * tags (the reason to enroll at all) and an OS hint when the device platform is
84
+ * unknown. `dispatchable` follows the device's auth method, so a password-auth
85
+ * device can never be made dispatchable by shadowing it with an inline entry.
86
+ */
87
+ function deviceHost(device, user, overlay) {
88
+ const address = device.address.dnsName ?? device.address.ip;
89
+ return {
90
+ name: device.name,
91
+ provider: 'devices',
92
+ source: 'inline',
93
+ ...(address ? { address } : {}),
94
+ user: user ?? device.user,
95
+ os: device.platform !== 'unknown' ? device.platform : overlay?.os,
96
+ ...(overlay?.caps?.length ? { caps: overlay.caps } : {}),
97
+ enrolled: true,
98
+ status: deviceStatus(device),
99
+ dispatchable: device.auth.method !== 'password',
100
+ device,
101
+ };
102
+ }
103
+ /** Build a Host from a bare overlay entry (inline or ssh-config), applying any
104
+ * `user@` override. Inline/ssh-config hosts authenticate over key / ssh-config,
105
+ * so they are dispatchable; presence is unknown without an explicit probe. */
106
+ function overlayHost(name, entry, user) {
107
+ return {
108
+ name,
109
+ provider: 'local',
110
+ enrolled: true,
111
+ source: entry.source,
112
+ ...(entry.address ? { address: entry.address } : {}),
113
+ user: user ?? entry.user,
114
+ ...(entry.os ? { os: entry.os } : {}),
115
+ ...(entry.caps?.length ? { caps: entry.caps } : {}),
116
+ ...(entry.addedAt ? { addedAt: entry.addedAt } : {}),
117
+ status: 'unknown',
118
+ dispatchable: true,
119
+ };
120
+ }
121
+ /** Synthesize an ad-hoc inline Host for a `user@host` / `host` literal so a box
122
+ * that was never registered still dials. sshTargetFor emits `user@address`. */
123
+ function literalHost(token, host, user) {
124
+ return {
125
+ name: token,
126
+ provider: 'local',
127
+ source: 'inline',
128
+ address: host,
129
+ ...(user ? { user } : {}),
130
+ status: 'unknown',
131
+ dispatchable: true,
132
+ adhoc: true,
133
+ };
134
+ }
135
+ /**
136
+ * The one place a `--host` / `--device` token becomes a resolved host. Reads the
137
+ * devices registry, the agents.yaml overlay, and ssh_config, and merges them
138
+ * per-field (see {@link deviceHost}). One grammar for every caller: `name`,
139
+ * `user@name`, a tailnet FQDN, an ssh_config alias, and a literal `user@host`
140
+ * all resolve the same way regardless of which subcommand called.
141
+ *
142
+ * Non-throwing: returns null on an injection-guard failure or an unresolved bare
143
+ * token. Device-only refusals (control device, password auth) are NOT applied
144
+ * here — that is the dispatch wrapper's job ({@link resolveHost}), so the fan-out
145
+ * and `agents ssh` paths, which handle those cases differently, aren't forced
146
+ * into a dispatch verdict.
147
+ */
148
+ export async function matchHost(name, opts = {}) {
149
+ try {
150
+ assertValidSshTarget(name);
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ const { user, host } = splitUserHost(name);
156
+ let reg;
157
+ try {
158
+ reg = await loadDevices();
159
+ }
160
+ catch {
161
+ reg = {};
162
+ }
163
+ const overlay = readMeta().hosts?.[host];
164
+ // 1. A registered device (normalized match) — its live address/OS/presence win.
165
+ const device = matchDevice(host, reg);
166
+ if (device)
167
+ return deviceHost(device, user, overlay);
168
+ // 2. An agents.yaml overlay entry keyed by the host part (inline or ssh-config).
169
+ if (overlay)
170
+ return overlayHost(host, overlay, user);
171
+ // 3. A bare ssh_config alias Tailscale has never seen — dial by name (ssh
172
+ // applies the stanza). A `user@alias` is handled as a literal below so the
173
+ // `user@` reaches ssh, which overrides the stanza's User.
174
+ if (!user && isSshConfigHost(host)) {
175
+ return { name: host, provider: 'local', source: 'ssh-config', os: resolveRemoteOsSync(host), status: 'unknown', dispatchable: true };
176
+ }
177
+ // 4. An ad-hoc literal target. `user@host` always; a bare IP/FQDN only when the
178
+ // caller opted in (`agents ssh`). A bare unknown word is a miss (null).
179
+ if (name.includes('@') || (opts.allowBareLiteral && looksLikeHostLiteral(name))) {
180
+ assertValidSshTarget(name);
181
+ return literalHost(name, host, user);
182
+ }
183
+ return null;
184
+ }
185
+ /** Every host across all registered providers, merged by name so a device's
186
+ * presence/dispatchable/address and an overlay's caps coexist on one row
187
+ * (RUSH-1967: first-wins dedup used to drop the device row, and its status/caps
188
+ * with it). Provider order still decides the base row (`local` first). */
36
189
  export async function listAllHosts() {
37
- const seen = new Set();
38
- const out = [];
190
+ const byName = new Map();
39
191
  for (const provider of getAllProviders()) {
40
192
  for (const host of await provider.list()) {
41
- if (seen.has(host.name))
193
+ const prev = byName.get(host.name);
194
+ if (!prev) {
195
+ byName.set(host.name, host);
42
196
  continue;
43
- seen.add(host.name);
44
- out.push(host);
197
+ }
198
+ // Same name from a later provider (a device behind an enrolled overlay):
199
+ // `prev` is the local/overlay row (`local` registers first), `host` is the
200
+ // live device row. Keep the overlay's caps/source/addedAt as the base, but
201
+ // the DEVICE wins every field it owns — address, user, OS, presence and
202
+ // dispatchable — so this matches `deviceHost()`'s per-field precedence and
203
+ // an enrolled device can never serve a frozen address. Getting this
204
+ // backwards reintroduced the frozen route through `resolveHostByCap`,
205
+ // which hands a `listAllHosts()` row straight to dispatch.
206
+ byName.set(host.name, {
207
+ ...prev,
208
+ address: host.address ?? prev.address,
209
+ user: host.user ?? prev.user,
210
+ os: host.os ?? prev.os,
211
+ status: host.status ?? prev.status,
212
+ dispatchable: host.dispatchable ?? prev.dispatchable,
213
+ });
45
214
  }
46
215
  }
47
- return out;
216
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
48
217
  }
49
218
  /**
50
- * Resolve a host name to a single host, or null if unknown. Resolution order:
51
- * 1. host providers, in registration order the `agents hosts` registry
52
- * (`local`: agents.yaml overlay + ssh-config), then the devices registry
53
- * (`devices`: a machine registered once with `agents devices sync` is
54
- * reachable by `--host`/`--device` with no second enroll)
55
- * 2. an ad-hoc `user@host` (must contain `@`, validated) nothing to register
219
+ * Resolve a `--host`/`--device` token for DISPATCH the shape every offload
220
+ * caller consumes (`run --host`, the generic passthrough, teams placement, the
221
+ * cloud host provider, doctor, funnel, remote secrets). Grammar and merge come
222
+ * from {@link matchHost}; on top, this layer applies the device-only dispatch
223
+ * refusals so they hold even when an inline overlay shadows the device:
224
+ * - a control device (a paired iPhone) can never be a dispatch target;
225
+ * - a password-auth device throws the typed {@link DeviceOffloadUnsupportedError}
226
+ * (offload rides `BatchMode=yes` ssh, which can't answer a prompt);
227
+ * - an addressless device has nothing to dial.
56
228
  *
57
- * A bare unknown name returns null so capability-tag routing (`resolveHostByCap`)
58
- * stays reachable: `--host gpu` must fall through to a cap lookup, not be misread
59
- * as an ad-hoc target.
229
+ * A bare unknown name returns null so capability-tag routing (`resolveHostByCap`,
230
+ * e.g. `--host gpu`) stays reachable, and an ad-hoc `user@host` still resolves.
60
231
  */
61
232
  export async function resolveHost(name) {
62
- for (const provider of getAllProviders()) {
63
- const host = await provider.resolve(name);
64
- if (host)
65
- return host;
66
- }
67
- if (name.includes('@')) {
68
- assertValidSshTarget(name);
69
- const at = name.indexOf('@');
70
- return { name, provider: 'local', source: 'inline', user: name.slice(0, at), address: name.slice(at + 1) };
233
+ const host = await matchHost(name);
234
+ if (!host)
235
+ return null;
236
+ if (host.device) {
237
+ if (isControlDevice(host.device)) {
238
+ throw new Error(`Device "${host.device.name}" is a control device (a cockpit), not an executor — it can't run agents. Dispatch to a worker device instead.`);
239
+ }
240
+ if (host.device.auth.method === 'password') {
241
+ throw new DeviceOffloadUnsupportedError(host.device.name);
242
+ }
243
+ if (!host.address) {
244
+ throw new Error(`Device "${host.device.name}" has no address (Tailscale DNS name or IP) to reach it by.`);
245
+ }
71
246
  }
72
- return null;
247
+ return host;
73
248
  }
74
249
  /**
75
250
  * Resolve a host by capability tag (e.g. `--host gpu`). Returns the single
@@ -68,6 +68,17 @@ export declare const RUN_OPTION_REJECT_MESSAGES: Record<string, string>;
68
68
  * POSIX form, so linux/macos are byte-for-byte unchanged.
69
69
  */
70
70
  export declare function buildRemoteAgentsInvocation(forwardedArgs: string[], remoteCwd?: string, os?: string, env?: Record<string, string>): string;
71
+ /**
72
+ * Build a POSIX `export K=V; …` prefix from an env map — empty string when the
73
+ * map is missing or empty. Values are rendered as shell LITERALS by default
74
+ * (single-quoted via {@link shellQuote}), so a `$(...)` or backtick in a value
75
+ * can never execute on the SSH target. Only {@link EXPAND_KEYS} (`PATH`) keep the
76
+ * expanding double-quote form (`\` and `"` escaped) so the remote `$HOME`/`$PATH`
77
+ * still resolve. Shared by {@link buildRemoteAgentsInvocation} and the
78
+ * detached/interactive dispatch builders (dispatch.ts) so every remote path
79
+ * exports env identically.
80
+ */
81
+ export declare function posixEnvExports(env?: Record<string, string>): string;
71
82
  /** The two remote shell dialects we build commands for. */
72
83
  export type RemoteShell = 'posix' | 'powershell';
73
84
  /**
@@ -86,6 +86,7 @@ export const RUN_OPTION_FORWARDING = {
86
86
  yes: 'forward', // a detached remote run can't answer the budget-confirm prompt
87
87
  acp: 'forward', // the remote CLI routes through ACP on ITS side of the wire
88
88
  autoSecrets: 'forward', // workflow frontmatter secrets resolve on the REMOTE keychain
89
+ emitSessionId: 'forward', // remote prints its session id as a stdout sentinel the launcher captures (session-marker.ts)
89
90
  // rejected — cannot cross the SSH boundary; fail loud, never degrade
90
91
  secrets: 'reject',
91
92
  secretsKeys: 'reject',
@@ -143,18 +144,41 @@ export function buildRemoteAgentsInvocation(forwardedArgs, remoteCwd, os, env) {
143
144
  }
144
145
  const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
145
146
  const withCwd = remoteCwd ? `cd ${shellQuote(remoteCwd)} && ${inner}` : inner;
146
- if (!env || Object.keys(env).length === 0) {
147
- return `bash -lc ${shellQuote(withCwd)}`;
148
- }
149
147
  // Prepend env exports so the remote command sees the shims dir even when the
150
148
  // login shell hasn't sourced the interactive rc files that usually add it.
151
- // Values are double-quoted (not single-quoted) so remote variables like
152
- // $HOME and $PATH are expanded by the login shell.
153
- const exports = Object.entries(env)
154
- .map(([k, v]) => `export ${shellQuote(k)}="${v.replace(/[\\"]/g, '\\$&')}"`)
155
- .join('; ');
149
+ const exports = posixEnvExports(env);
150
+ if (!exports) {
151
+ return `bash -lc ${shellQuote(withCwd)}`;
152
+ }
156
153
  return `bash -lc ${shellQuote(`${exports}; ${withCwd}`)}`;
157
154
  }
155
+ /**
156
+ * Keys whose values are trusted-static and legitimately need remote shell
157
+ * expansion — `PATH` references the remote `$HOME`/`$PATH`. Every other key is
158
+ * rendered as a shell literal, so an attacker-influenceable value (notably actor
159
+ * provenance, whose name/email can come from a tailnet peer's whois or an
160
+ * unvalidated `AGENTS_ACTOR_*` env var) can never inject shell into a dispatch.
161
+ */
162
+ const EXPAND_KEYS = new Set(['PATH']);
163
+ /**
164
+ * Build a POSIX `export K=V; …` prefix from an env map — empty string when the
165
+ * map is missing or empty. Values are rendered as shell LITERALS by default
166
+ * (single-quoted via {@link shellQuote}), so a `$(...)` or backtick in a value
167
+ * can never execute on the SSH target. Only {@link EXPAND_KEYS} (`PATH`) keep the
168
+ * expanding double-quote form (`\` and `"` escaped) so the remote `$HOME`/`$PATH`
169
+ * still resolve. Shared by {@link buildRemoteAgentsInvocation} and the
170
+ * detached/interactive dispatch builders (dispatch.ts) so every remote path
171
+ * exports env identically.
172
+ */
173
+ export function posixEnvExports(env) {
174
+ if (!env || Object.keys(env).length === 0)
175
+ return '';
176
+ return Object.entries(env)
177
+ .map(([k, v]) => EXPAND_KEYS.has(k)
178
+ ? `export ${shellQuote(k)}="${v.replace(/[\\"]/g, '\\$&')}"`
179
+ : `export ${shellQuote(k)}=${shellQuote(v)}`)
180
+ .join('; ');
181
+ }
158
182
  /**
159
183
  * Pick the remote shell dialect from a recorded OS/platform string. A Windows
160
184
  * host (device-registry `platform: 'windows'`, or an enrolled `HostEntry.os`
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Resolve a remote-created session id back to an interactive `--host` launcher.
3
+ *
4
+ * A headless `--host` run captures the remote-coined id from the followed log
5
+ * (the `--emit-session-id` marker — see session-marker.ts). An INTERACTIVE run
6
+ * has no followed log: the local TTY is wired straight through `sshStream`
7
+ * (stdio:'inherit'), so nothing can tap the stream for a marker without breaking
8
+ * the agent's raw-mode TUI. Instead we correlate by AGENT_LAUNCH_ID.
9
+ *
10
+ * The launcher forwards a launch id it controls (`--env AGENT_LAUNCH_ID=<id>`);
11
+ * the remote `agents run` adopts it (exec.ts `resolveLaunchId`), so the remote
12
+ * SessionStart hook records the agent's REAL session id under that exact key in
13
+ * `~/.agents/.cache/terminals/sessions/<pid>.json`. After the stream returns we
14
+ * do ONE ssh read of that dir and pick the record whose `launch_id` matches —
15
+ * the same launch-id join `agents sessions --active` uses locally
16
+ * (session/hook-sessions.ts), just across the SSH hop.
17
+ *
18
+ * This gives the interactive path a real per-agent id to register in the local
19
+ * session index and to reconnect against, for Codex/Kimi/Grok/Gemini — closing
20
+ * the gap RUSH-2033 left for every non-Claude agent (Claude still forces its own
21
+ * id up front and never reaches here).
22
+ */
23
+ /**
24
+ * Pick the real session id for `launchId` from the remote hook records.
25
+ *
26
+ * `recordsJson` is the newline-delimited JSON the remote read emits — one record
27
+ * per line (a `cat` of every `sessions/*.json`). Scan for the record whose
28
+ * `launch_id` matches and return its `session_id`; when several match (pid reuse,
29
+ * a lingering file) the NEWEST by `ts` wins, mirroring the local reader's
30
+ * keep-newest tie-break (session/hook-sessions.ts). Pure so the correlation is
31
+ * unit-testable from fixtures with no SSH. Returns undefined when no record
32
+ * carries the launch id (the hook hasn't landed yet, or a hookless harness).
33
+ */
34
+ export declare function pickRemoteSessionId(recordsJson: string, launchId: string): string | undefined;
35
+ /**
36
+ * Read the remote hook-session dir over ssh and resolve the real session id the
37
+ * remote run recorded under `launchId`. One round-trip, best-effort: any ssh
38
+ * failure, a missing dir, or an absent record yields undefined — the caller then
39
+ * keeps the un-mapped run rather than a fabricated id.
40
+ *
41
+ * `$HOME` expands on the REMOTE login shell (never the local box). The glob is a
42
+ * literal path with no user input, so it is injection-safe; a `2>/dev/null` on a
43
+ * globless dir keeps the command quiet when the dir is empty or absent.
44
+ */
45
+ export declare function resolveRemoteSessionId(target: string, launchId: string, timeoutMs?: number): string | undefined;