@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
@@ -182,7 +182,10 @@ export function probeDeviceStats(device, opts = {}) {
182
182
  // remote login shell, which evaluates the snippet's `;`/`||` directly — no
183
183
  // `sh -c` wrapper needed (and a wrapper would only re-quote the first token).
184
184
  // For powershell devices it base64-encodes the snippet instead.
185
- ({ args, env } = buildSshInvocation(device, [isWin ? WIN_PROBE_SNIPPET : PROBE_SNIPPET], shim));
185
+ // agentOnly: this is a read-only stats probe. A password-auth device must
186
+ // resolve its bundle broker-only — never force a foreground Touch ID sheet
187
+ // just to render the load/mem columns of `agents devices` (RUSH-1970).
188
+ ({ args, env } = buildSshInvocation(device, [isWin ? WIN_PROBE_SNIPPET : PROBE_SNIPPET], shim, {}, { agentOnly: true }));
186
189
  }
187
190
  catch {
188
191
  return Promise.resolve({ host, reachable: false, fetchedAt });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * One reachability resolver for the fleet render (RUSH-1965).
3
+ *
4
+ * "Is this device online?" had three disagreeing answers: the live SSH probe
5
+ * (`DeviceStats.reachable`, display-only), the persisted registry verdict, and
6
+ * the cached `tailscale.online` snapshot that the online/offline word actually
7
+ * read. A box reachable every day but whose tailscale snapshot was stale (or a
8
+ * `via:"manual"` device that never got a tailscale field at all) rendered
9
+ * "offline forever". This module is the single ordering the render + the
10
+ * write-back both go through, so the word and the persisted truth agree.
11
+ */
12
+ import type { DeviceProfile, DeviceReachability, DeviceRegistry } from './registry.js';
13
+ import type { DeviceStats } from './health.js';
14
+ export type OnlineState = 'online' | 'offline' | 'unknown';
15
+ /**
16
+ * Resolve a device's online/offline state, preferring the freshest signal.
17
+ *
18
+ * Precedence: a live stat from THIS run (the probe that just ran) > the
19
+ * persisted reachability verdict written back by a prior probe > the cached
20
+ * `tailscale.online` snapshot. 'unknown' only when nothing at all is known.
21
+ */
22
+ export declare function deviceOnlineState(d: DeviceProfile, stats?: DeviceStats): OnlineState;
23
+ /** Build the reachability verdict to persist from a fresh probe stat. Pure. */
24
+ export declare function reachabilityFromStats(d: DeviceProfile, stats: DeviceStats): DeviceReachability;
25
+ /**
26
+ * Collect the reachability verdicts to write back for a fleet's freshly-read
27
+ * stats map. Only devices present in the registry are included (a stat for a
28
+ * name we don't track is dropped rather than resurrected). Pure — the caller
29
+ * hands the result to {@link writeReachability}.
30
+ */
31
+ export declare function collectReachabilityWriteBacks(reg: DeviceRegistry, statsMap: Map<string, DeviceStats>): Record<string, DeviceReachability>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Resolve a device's online/offline state, preferring the freshest signal.
3
+ *
4
+ * Precedence: a live stat from THIS run (the probe that just ran) > the
5
+ * persisted reachability verdict written back by a prior probe > the cached
6
+ * `tailscale.online` snapshot. 'unknown' only when nothing at all is known.
7
+ */
8
+ export function deviceOnlineState(d, stats) {
9
+ if (stats)
10
+ return stats.reachable ? 'online' : 'offline';
11
+ if (d.reachability)
12
+ return d.reachability.reachable ? 'online' : 'offline';
13
+ if (d.tailscale)
14
+ return d.tailscale.online ? 'online' : 'offline';
15
+ return 'unknown';
16
+ }
17
+ /** Build the reachability verdict to persist from a fresh probe stat. Pure. */
18
+ export function reachabilityFromStats(d, stats) {
19
+ return {
20
+ reachable: stats.reachable,
21
+ via: d.address?.via,
22
+ checkedAt: new Date(stats.fetchedAt).toISOString(),
23
+ };
24
+ }
25
+ /**
26
+ * Collect the reachability verdicts to write back for a fleet's freshly-read
27
+ * stats map. Only devices present in the registry are included (a stat for a
28
+ * name we don't track is dropped rather than resurrected). Pure — the caller
29
+ * hands the result to {@link writeReachability}.
30
+ */
31
+ export function collectReachabilityWriteBacks(reg, statsMap) {
32
+ const out = {};
33
+ for (const [name, stats] of statsMap) {
34
+ const d = reg[name];
35
+ if (!d)
36
+ continue;
37
+ out[name] = reachabilityFromStats(d, stats);
38
+ }
39
+ return out;
40
+ }
@@ -40,6 +40,24 @@ export interface DeviceTailscale {
40
40
  relay?: string;
41
41
  lastSeen?: string;
42
42
  }
43
+ /**
44
+ * Verdict of the last live SSH reachability probe (RUSH-1965).
45
+ *
46
+ * The live `agents devices` probe already learns whether a box answered right
47
+ * now; this persists that answer so the online/offline word is read from a
48
+ * fresh probe instead of the potentially-stale {@link DeviceTailscale.online}
49
+ * snapshot (which only `agents devices sync` / the daemon ever writes). A
50
+ * `via:"manual"` device — which never gets a tailscale peer entry — gets a
51
+ * reachability verdict this way too, so it stops rendering "offline forever".
52
+ */
53
+ export interface DeviceReachability {
54
+ /** Whether the last live probe reached the device. */
55
+ reachable: boolean;
56
+ /** Transport the verdict came through — the address kind used to dial it. */
57
+ via?: DeviceAddress['via'];
58
+ /** ISO-8601 timestamp of the probe that produced this verdict. */
59
+ checkedAt: string;
60
+ }
43
61
  /** A single registered device. */
44
62
  export interface DeviceProfile {
45
63
  name: string;
@@ -49,6 +67,10 @@ export interface DeviceProfile {
49
67
  address: DeviceAddress;
50
68
  auth: DeviceAuth;
51
69
  tailscale?: DeviceTailscale;
70
+ /** Last live SSH-probe reachability verdict (RUSH-1965). Preferred over the
71
+ * cached {@link DeviceTailscale.online} snapshot when rendering online/offline,
72
+ * because the live probe reflects whether the box answered right now. */
73
+ reachability?: DeviceReachability;
52
74
  /** What the device is for. Absent means `worker` (see {@link DeviceRole}). */
53
75
  role?: DeviceRole;
54
76
  createdAt: string;
@@ -88,6 +110,7 @@ export interface DeviceInput {
88
110
  address?: DeviceAddress;
89
111
  auth?: DeviceAuth;
90
112
  tailscale?: DeviceTailscale;
113
+ reachability?: DeviceReachability;
91
114
  role?: DeviceRole;
92
115
  }
93
116
  /**
@@ -96,6 +119,16 @@ export interface DeviceInput {
96
119
  * platform so the two can never drift. Returns the resulting profile.
97
120
  */
98
121
  export declare function upsertDevice(name: string, input: DeviceInput): Promise<DeviceProfile>;
122
+ /**
123
+ * Persist live reachability verdicts for many devices in one locked pass
124
+ * (RUSH-1965). A no-op verdict (same `reachable` and not newer than what's
125
+ * stored) is skipped, so a cache-served `agents devices` render doesn't churn
126
+ * the registry every call; only a flip or a fresher probe writes. Unlike
127
+ * {@link upsertDevice} this does NOT bump `updatedAt` — reachability is
128
+ * transient liveness (like the tailscale snapshot), not a profile edit. Unknown
129
+ * device names are ignored. Returns the names actually updated.
130
+ */
131
+ export declare function writeReachability(updates: Record<string, DeviceReachability>): Promise<string[]>;
99
132
  /** Remove a device. Returns false if it was not registered. */
100
133
  export declare function removeDevice(name: string): Promise<boolean>;
101
134
  /** Load the set of ignored node names. Missing file => empty set. A malformed
@@ -178,6 +178,7 @@ export async function upsertDevice(name, input) {
178
178
  address: input.address ?? prev?.address ?? { via: 'manual' },
179
179
  auth: input.auth ?? prev?.auth ?? { method: 'key' },
180
180
  tailscale: input.tailscale ?? prev?.tailscale,
181
+ reachability: input.reachability ?? prev?.reachability,
181
182
  role: input.role ?? prev?.role,
182
183
  createdAt: prev?.createdAt ?? now,
183
184
  updatedAt: now,
@@ -187,6 +188,42 @@ export async function upsertDevice(name, input) {
187
188
  return merged;
188
189
  });
189
190
  }
191
+ /**
192
+ * Persist live reachability verdicts for many devices in one locked pass
193
+ * (RUSH-1965). A no-op verdict (same `reachable` and not newer than what's
194
+ * stored) is skipped, so a cache-served `agents devices` render doesn't churn
195
+ * the registry every call; only a flip or a fresher probe writes. Unlike
196
+ * {@link upsertDevice} this does NOT bump `updatedAt` — reachability is
197
+ * transient liveness (like the tailscale snapshot), not a profile edit. Unknown
198
+ * device names are ignored. Returns the names actually updated.
199
+ */
200
+ export async function writeReachability(updates) {
201
+ const names = Object.keys(updates);
202
+ if (names.length === 0)
203
+ return [];
204
+ const p = registryPath();
205
+ return withRegistryLock(p, async () => {
206
+ const reg = await loadDevices();
207
+ const changed = [];
208
+ for (const name of names) {
209
+ const prev = reg[name];
210
+ if (!prev)
211
+ continue; // never resurrect a device the user removed
212
+ const next = updates[name];
213
+ const cur = prev.reachability;
214
+ if (cur &&
215
+ cur.reachable === next.reachable &&
216
+ Date.parse(cur.checkedAt) >= Date.parse(next.checkedAt)) {
217
+ continue; // unchanged verdict, no fresher timestamp — skip the write
218
+ }
219
+ reg[name] = { ...prev, reachability: next };
220
+ changed.push(name);
221
+ }
222
+ if (changed.length > 0)
223
+ await saveDevices(reg);
224
+ return changed;
225
+ });
226
+ }
190
227
  /** Remove a device. Returns false if it was not registered. */
191
228
  export async function removeDevice(name) {
192
229
  const p = registryPath();
@@ -1,4 +1,6 @@
1
- import { type DeviceProfile, type DeviceRegistry } from './registry.js';
1
+ import { splitUserHost } from '../hosts/registry.js';
2
+ import { type DeviceProfile } from './registry.js';
3
+ export { splitUserHost };
2
4
  /** A dialable peer: the ssh target, the machine id used to tag its rows, a
3
5
  * display name, and the OS family that picks the remote shell dialect. */
4
6
  export interface ResolvedSshTarget {
@@ -7,35 +9,21 @@ export interface ResolvedSshTarget {
7
9
  name: string;
8
10
  os?: string;
9
11
  }
10
- /** Split a `user@host` / `host` token into its login user (if any) and host part. */
11
- export declare function splitUserHost(token: string): {
12
- user?: string;
13
- host: string;
14
- };
15
- /**
16
- * Resolve one `--host`/`--device` token to a concrete ssh target through the
17
- * registry. Registry hit → the device's real address + platform (so the machine
18
- * id, route, and OS all match the auto-discovery sweep), with any `user@`
19
- * overriding the login account. Miss → a literal `user@host` fallback, its OS
20
- * taken from the host overlay if enrolled. Returns undefined only when the token
21
- * fails the shared ssh-target injection guard.
22
- */
23
- export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
24
12
  /**
25
13
  * Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
26
- * grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
27
- * shell, tailscale metadata) `buildSshInvocation` needs not just a target
28
- * string. A registered `name` or `user@device` yields that profile (with the
29
- * login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
30
- * yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
31
- * returns undefined so the caller reports "Unknown device" rather than dialing a
32
- * literal the strict behaviour the interactive wrapper has always had.
14
+ * grammar as the fan-out, but returns the whole profile (auth, shell, tailscale
15
+ * metadata) `buildSshInvocation` needs. A registered `name` / `user@device`
16
+ * yields that profile (login user overridden by any `user@`); an ad-hoc
17
+ * `user@host` / IP / FQDN literal yields a synthesized key-auth profile. A bare
18
+ * unregistered alias (no `@`/dot) or an ssh_config-only alias, which `agents
19
+ * ssh` has never dialed — returns undefined so the caller reports "Unknown
20
+ * device" rather than dialing a literal.
33
21
  */
34
- export declare function resolveDeviceTarget(token: string, reg: DeviceRegistry): DeviceProfile | undefined;
22
+ export declare function resolveDeviceTarget(token: string): Promise<DeviceProfile | undefined>;
35
23
  /**
36
- * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
37
- * registry once. A token that fails the injection guard is skipped with a
38
- * stderr note (never fatal — one bad token must not blank the fan-out). Shared
39
- * by every cross-machine fan-out so they can never diverge onto two routes.
24
+ * Resolve an explicit `--host`/`--device` list to dialable targets. A token that
25
+ * fails the injection guard or names nothing reachable is skipped with a stderr
26
+ * note (never fatal — one bad token must not blank the fan-out). Shared by every
27
+ * cross-machine fan-out so they can never diverge onto two routes.
40
28
  */
41
29
  export declare function resolveExplicitTargets(hosts: string[]): Promise<ResolvedSshTarget[]>;
@@ -1,86 +1,31 @@
1
1
  /**
2
- * The one place a `--host` / `--device` token becomes a real ssh target.
2
+ * Fan-out + `agents ssh` adapters over the single host/device resolver.
3
3
  *
4
- * A device and a host are the same thing addressed two ways, so resolution must
5
- * go through the device registry — the single source of truth. A token that
6
- * names a registered device dials that device's real address (its Tailscale
7
- * dnsName/ip + user, via `sshTargetFor`), *identical* to the auto-discovery
8
- * sweep and `agents ssh`. Before this module, the explicit `--host` fan-out
9
- * instead passed the bare token straight to `ssh`, so `--host yosemite-s0`
10
- * dialed whatever `~/.ssh/config`/LAN DNS resolved `yosemite-s0` to — a
11
- * different route than the sweep's `yosemite-s0.<tailnet>.ts.net`. That
12
- * divergence broke ControlMaster socket reuse (different target different
13
- * `%C` hash a cold dial every time) and could read a perfectly reachable box
14
- * as "unreachable" when only the non-Tailscale route was down.
4
+ * Every `--host` / `--device` token becomes a concrete target through one core,
5
+ * {@link matchHost} in ../hosts/registry.ts — the merged directory that reads the
6
+ * devices registry, the agents.yaml overlay, AND ssh_config (RUSH-1967). This
7
+ * module is only the two thin adapters the fan-out and interactive-ssh paths
8
+ * need on top of that core:
9
+ * - {@link resolveExplicitTargets} a token list dialable `{target, machine,
10
+ * name, os}` rows, so `sessions --host` / session bundles / remote agents-json
11
+ * dial the exact same address (and machine id) `run --host` does.
12
+ * - {@link resolveDeviceTarget} a token the full {@link DeviceProfile}
13
+ * `agents ssh` needs (auth, shell, tailscale metadata), with the same
14
+ * grammar; a bare unregistered alias returns undefined ("Unknown device").
15
15
  *
16
- * The grammar is uniform across the fleet: `mac-mini` (device name), and
17
- * `muqsit@mac-mini` (same device, login user overridden) both resolve through
18
- * the registry to the device's Tailscale route the `user@` form no longer
19
- * short-circuits to a bare `ssh muqsit@mac-mini` (LAN DNS). A `user@host` that
20
- * matches no registered device falls back to a literal target so ad-hoc boxes
21
- * still work.
16
+ * The grammar is uniform across the fleet: `name`, `user@name` (same device,
17
+ * login user overridden), a tailnet FQDN, an ssh_config alias, and an ad-hoc
18
+ * `user@host` all resolve identically no matter which subcommand called.
22
19
  */
23
20
  import chalk from 'chalk';
24
- import { assertValidSshTarget } from '../ssh-exec.js';
21
+ import { sshTargetFor } from '../hosts/types.js';
22
+ import { matchHost, splitUserHost } from '../hosts/registry.js';
25
23
  import { normalizeHost } from '../machine-id.js';
26
24
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
27
- import { sshTargetFor } from './connect.js';
28
- import { loadDevices } from './registry.js';
29
- /** Split a `user@host` / `host` token into its login user (if any) and host part. */
30
- export function splitUserHost(token) {
31
- const at = token.indexOf('@');
32
- return at === -1 ? { host: token } : { user: token.slice(0, at), host: token.slice(at + 1) };
33
- }
34
- /**
35
- * Match a host part (the piece after any `user@`) to a registered device: exact
36
- * registry key first, then a normalized-host match so `yosemite-s0` and
37
- * `yosemite-s0.<tailnet>.ts.net` land on the same profile. The single source of
38
- * truth both `resolveSshTarget` (fan-out) and `resolveDeviceTarget` (`agents
39
- * ssh`) share, so a `user@device` can never resolve two different routes.
40
- */
41
- function matchDevice(host, reg) {
42
- return reg[host] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(host));
43
- }
44
- /**
45
- * Resolve one `--host`/`--device` token to a concrete ssh target through the
46
- * registry. Registry hit → the device's real address + platform (so the machine
47
- * id, route, and OS all match the auto-discovery sweep), with any `user@`
48
- * overriding the login account. Miss → a literal `user@host` fallback, its OS
49
- * taken from the host overlay if enrolled. Returns undefined only when the token
50
- * fails the shared ssh-target injection guard.
51
- */
52
- export function resolveSshTarget(token, reg) {
53
- try {
54
- assertValidSshTarget(token);
55
- }
56
- catch {
57
- return undefined;
58
- }
59
- const { user, host } = splitUserHost(token);
60
- // A device and a `user@device` are the same box; resolve the host part through
61
- // the registry so both dial the Tailscale route, and let an explicit `user@`
62
- // override only the login account.
63
- const device = matchDevice(host, reg);
64
- if (device) {
65
- try {
66
- const effective = user ? { ...device, user } : device;
67
- return { target: sshTargetFor(effective), machine: normalizeHost(device.name), name: device.name, os: device.platform };
68
- }
69
- catch {
70
- // Registered but has no address to dial — fall through to the literal token.
71
- }
72
- }
73
- return { target: token, machine: normalizeHost(host), name: token, os: resolveRemoteOsSync(token) };
74
- }
25
+ export { splitUserHost };
75
26
  /** Timestamps for a synthesized ad-hoc profile — never persisted, so a constant
76
27
  * keeps the value deterministic (and side-effect free) without reading the clock. */
77
28
  const SYNTH_TS = '1970-01-01T00:00:00.000Z';
78
- /** True when a token is clearly a network target (a `user@`, or a dotted/IPv6
79
- * host / IP) rather than a bare alias. A bare unknown word is a typo, so `agents
80
- * ssh foo` still says "Unknown device" instead of dialing a literal `foo`. */
81
- function looksLikeHostLiteral(token) {
82
- return token.includes('@') || token.includes('.') || token.includes(':');
83
- }
84
29
  /** Synthesize a throwaway device profile for an ad-hoc `user@host` / `host`
85
30
  * literal so `agents ssh` can dial a box that was never registered. */
86
31
  function adHocDevice(token, host, user) {
@@ -97,49 +42,65 @@ function adHocDevice(token, host, user) {
97
42
  };
98
43
  }
99
44
  /**
100
- * Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
101
- * grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
102
- * shell, tailscale metadata) `buildSshInvocation` needs not just a target
103
- * string. A registered `name` or `user@device` yields that profile (with the
104
- * login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
105
- * yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
106
- * returns undefined so the caller reports "Unknown device" rather than dialing a
107
- * literal — the strict behaviour the interactive wrapper has always had.
45
+ * Resolve one token to a dialable {@link ResolvedSshTarget}, or undefined when it
46
+ * fails the injection guard or names nothing reachable. A registered device (or
47
+ * `user@device`) dials its live Tailscale route; an ssh_config-only alias dials
48
+ * by its bare name (ssh applies the stanza) now visible to the fan-out too; an
49
+ * ad-hoc `user@host` dials the literal.
108
50
  */
109
- export function resolveDeviceTarget(token, reg) {
51
+ async function toResolvedTarget(token) {
52
+ const host = await matchHost(token);
53
+ if (!host)
54
+ return undefined;
55
+ let target;
110
56
  try {
111
- assertValidSshTarget(token);
57
+ target = sshTargetFor(host);
112
58
  }
113
59
  catch {
60
+ return undefined; // matched a device/host with no address to dial
61
+ }
62
+ const hostPart = host.device ? host.device.name : splitUserHost(host.name).host;
63
+ const name = host.device ? host.device.name : host.name;
64
+ return { target, machine: normalizeHost(hostPart), name, os: host.os ?? resolveRemoteOsSync(name) };
65
+ }
66
+ /**
67
+ * Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
68
+ * grammar as the fan-out, but returns the whole profile (auth, shell, tailscale
69
+ * metadata) `buildSshInvocation` needs. A registered `name` / `user@device`
70
+ * yields that profile (login user overridden by any `user@`); an ad-hoc
71
+ * `user@host` / IP / FQDN literal yields a synthesized key-auth profile. A bare
72
+ * unregistered alias (no `@`/dot) — or an ssh_config-only alias, which `agents
73
+ * ssh` has never dialed — returns undefined so the caller reports "Unknown
74
+ * device" rather than dialing a literal.
75
+ */
76
+ export async function resolveDeviceTarget(token) {
77
+ const host = await matchHost(token, { allowBareLiteral: true });
78
+ if (!host)
114
79
  return undefined;
80
+ if (host.device) {
81
+ const { user } = splitUserHost(token);
82
+ return user ? { ...host.device, user } : host.device;
115
83
  }
116
- const { user, host } = splitUserHost(token);
117
- const device = matchDevice(host, reg);
118
- if (device)
119
- return user ? { ...device, user } : device;
120
- if (looksLikeHostLiteral(token))
121
- return adHocDevice(token, host, user);
84
+ if (host.adhoc) {
85
+ const { user, host: hostPart } = splitUserHost(token);
86
+ return adHocDevice(token, hostPart, user);
87
+ }
88
+ // An overlay / ssh_config-only match is not a device — `agents ssh` stays
89
+ // devices-and-literals only, so report it as unknown.
122
90
  return undefined;
123
91
  }
124
92
  /**
125
- * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
126
- * registry once. A token that fails the injection guard is skipped with a
127
- * stderr note (never fatal — one bad token must not blank the fan-out). Shared
128
- * by every cross-machine fan-out so they can never diverge onto two routes.
93
+ * Resolve an explicit `--host`/`--device` list to dialable targets. A token that
94
+ * fails the injection guard or names nothing reachable is skipped with a stderr
95
+ * note (never fatal — one bad token must not blank the fan-out). Shared by every
96
+ * cross-machine fan-out so they can never diverge onto two routes.
129
97
  */
130
98
  export async function resolveExplicitTargets(hosts) {
131
- let reg;
132
- try {
133
- reg = await loadDevices();
134
- }
135
- catch {
136
- reg = {};
137
- }
138
99
  const out = [];
139
100
  for (const h of hosts) {
140
- const resolved = resolveSshTarget(h, reg);
101
+ const resolved = await toResolvedTarget(h);
141
102
  if (!resolved) {
142
- process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
103
+ process.stderr.write(chalk.gray(` ${h}: not a resolvable ssh target — skipped\n`));
143
104
  continue;
144
105
  }
145
106
  out.push(resolved);
@@ -45,6 +45,24 @@ export interface DeviceSyncResult {
45
45
  /** Populated when ok is false: why discovery was skipped. */
46
46
  reason?: string;
47
47
  }
48
+ /**
49
+ * The nodes automatic discovery is allowed to see: sharee nodes (shared INTO
50
+ * the tailnet by another user, e.g. a funnel ingress relay) are not the
51
+ * operator's machines, so they must never be bootstrap-registered nor surfaced
52
+ * as pending. Explicit paths (`devices register <name>`, `devices add`, the
53
+ * `fleet:` manifest bootstrap) are deliberate user actions and stay unfiltered.
54
+ * Pure so the policy is unit-testable without a tailnet.
55
+ */
56
+ export declare function discoverableNodes(nodes: TailscaleNode[]): TailscaleNode[];
57
+ /**
58
+ * Default checked-state for a node in the interactive sync picker. Pressing
59
+ * Enter registers every checked node, so "checked" must mean "auto-sync would
60
+ * register this": not dismissed, and not a sharee node — unless the user
61
+ * already deliberately registered that sharee node, in which case Enter must
62
+ * keep the fleet as-is. Pure so the default matrix is unit-testable without a
63
+ * prompt.
64
+ */
65
+ export declare function defaultPickerChecked(node: TailscaleNode, registered: Set<string>, ignored: Set<string>): boolean;
48
66
  /**
49
67
  * Node names present on the tailnet but neither already in the registry nor on
50
68
  * the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
@@ -59,6 +59,28 @@ export function withDefaultUser(input, prevUser, localUser) {
59
59
  return input;
60
60
  return { ...input, user: localUser };
61
61
  }
62
+ /**
63
+ * The nodes automatic discovery is allowed to see: sharee nodes (shared INTO
64
+ * the tailnet by another user, e.g. a funnel ingress relay) are not the
65
+ * operator's machines, so they must never be bootstrap-registered nor surfaced
66
+ * as pending. Explicit paths (`devices register <name>`, `devices add`, the
67
+ * `fleet:` manifest bootstrap) are deliberate user actions and stay unfiltered.
68
+ * Pure so the policy is unit-testable without a tailnet.
69
+ */
70
+ export function discoverableNodes(nodes) {
71
+ return nodes.filter((n) => !n.sharee);
72
+ }
73
+ /**
74
+ * Default checked-state for a node in the interactive sync picker. Pressing
75
+ * Enter registers every checked node, so "checked" must mean "auto-sync would
76
+ * register this": not dismissed, and not a sharee node — unless the user
77
+ * already deliberately registered that sharee node, in which case Enter must
78
+ * keep the fleet as-is. Pure so the default matrix is unit-testable without a
79
+ * prompt.
80
+ */
81
+ export function defaultPickerChecked(node, registered, ignored) {
82
+ return !ignored.has(node.name) && (!node.sharee || registered.has(node.name));
83
+ }
62
84
  /**
63
85
  * Node names present on the tailnet but neither already in the registry nor on
64
86
  * the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
@@ -101,7 +123,7 @@ export async function runDeviceSync(opts = {}) {
101
123
  // the same host at once) would otherwise abort the whole `agents sync`. The
102
124
  // whole body is inside the guard so the "never a sync failure" promise holds.
103
125
  try {
104
- const nodes = parseTailscaleStatus(tailscaleStatusJson());
126
+ const nodes = discoverableNodes(parseTailscaleStatus(tailscaleStatusJson()));
105
127
  const [registeredBefore, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
106
128
  const registered = new Set(Object.keys(registeredBefore));
107
129
  const pendingNames = computePendingDevices(nodes, registered, ignored);
@@ -9,6 +9,9 @@ export interface TailscaleNode {
9
9
  direct: boolean;
10
10
  relay?: string;
11
11
  lastSeen?: string;
12
+ /** True for a node another user shared INTO this tailnet — not the operator's
13
+ * own machine, so discovery must never auto-register or suggest it. */
14
+ sharee: boolean;
12
15
  }
13
16
  /**
14
17
  * Slugify a raw Tailscale HostName into a valid logical device name (ssh alias
@@ -69,6 +69,7 @@ function toNode(raw) {
69
69
  direct,
70
70
  relay: raw.Relay || undefined,
71
71
  lastSeen: raw.LastSeen,
72
+ sharee: Boolean(raw.ShareeNode),
72
73
  };
73
74
  }
74
75
  /**
@@ -12,7 +12,7 @@
12
12
  * - Performance tracking: withTiming() wrapper for any async function
13
13
  */
14
14
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
15
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
15
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
16
16
  export declare function levelFor(event: EventType): EventLevel;
17
17
  export interface EventMeta {
18
18
  ts: string;