@phnx-labs/agents-cli 1.20.74 → 1.20.76

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 (199) hide show
  1. package/CHANGELOG.md +893 -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 +31 -3
  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 +15 -2
  26. package/dist/commands/secrets.js +44 -21
  27. package/dist/commands/sessions-browser.js +1 -1
  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.js +16 -1
  32. package/dist/commands/sessions.d.ts +36 -13
  33. package/dist/commands/sessions.js +210 -40
  34. package/dist/commands/setup-computer.js +2 -2
  35. package/dist/commands/ssh.js +71 -35
  36. package/dist/commands/status.js +10 -2
  37. package/dist/commands/versions.js +15 -6
  38. package/dist/commands/view.d.ts +5 -0
  39. package/dist/commands/view.js +24 -4
  40. package/dist/commands/watchdog.d.ts +7 -2
  41. package/dist/commands/watchdog.js +73 -57
  42. package/dist/index.js +58 -7
  43. package/dist/lib/activity.d.ts +14 -4
  44. package/dist/lib/activity.js +17 -5
  45. package/dist/lib/actor.d.ts +50 -0
  46. package/dist/lib/actor.js +166 -0
  47. package/dist/lib/agent-spec/provider.js +2 -1
  48. package/dist/lib/agent-spec/resolve.js +19 -5
  49. package/dist/lib/agent-spec/types.d.ts +9 -1
  50. package/dist/lib/agents.d.ts +21 -0
  51. package/dist/lib/agents.js +70 -6
  52. package/dist/lib/cloud/codex.d.ts +2 -0
  53. package/dist/lib/cloud/codex.js +14 -3
  54. package/dist/lib/cloud/session-index.d.ts +32 -0
  55. package/dist/lib/cloud/session-index.js +58 -0
  56. package/dist/lib/cloud/store.d.ts +7 -0
  57. package/dist/lib/cloud/store.js +25 -0
  58. package/dist/lib/config-transfer.js +4 -0
  59. package/dist/lib/daemon.js +18 -2
  60. package/dist/lib/devices/connect.d.ts +15 -1
  61. package/dist/lib/devices/connect.js +15 -1
  62. package/dist/lib/devices/fleet.d.ts +21 -0
  63. package/dist/lib/devices/fleet.js +26 -0
  64. package/dist/lib/devices/health-report.d.ts +38 -0
  65. package/dist/lib/devices/health-report.js +214 -0
  66. package/dist/lib/devices/health.js +4 -1
  67. package/dist/lib/devices/reachability.d.ts +31 -0
  68. package/dist/lib/devices/reachability.js +40 -0
  69. package/dist/lib/devices/registry.d.ts +33 -0
  70. package/dist/lib/devices/registry.js +37 -0
  71. package/dist/lib/devices/resolve-target.d.ts +15 -27
  72. package/dist/lib/devices/resolve-target.js +63 -102
  73. package/dist/lib/devices/sync.d.ts +18 -0
  74. package/dist/lib/devices/sync.js +23 -1
  75. package/dist/lib/devices/tailscale.d.ts +3 -0
  76. package/dist/lib/devices/tailscale.js +1 -0
  77. package/dist/lib/events.d.ts +1 -1
  78. package/dist/lib/exec.d.ts +66 -0
  79. package/dist/lib/exec.js +195 -6
  80. package/dist/lib/feed-post.d.ts +63 -0
  81. package/dist/lib/feed-post.js +204 -0
  82. package/dist/lib/fleet/apply.d.ts +32 -2
  83. package/dist/lib/fleet/apply.js +97 -10
  84. package/dist/lib/fleet/types.d.ts +11 -0
  85. package/dist/lib/fs-walk.d.ts +13 -0
  86. package/dist/lib/fs-walk.js +16 -7
  87. package/dist/lib/hooks.d.ts +1 -0
  88. package/dist/lib/hooks.js +156 -0
  89. package/dist/lib/hosts/dispatch.d.ts +7 -0
  90. package/dist/lib/hosts/dispatch.js +2 -0
  91. package/dist/lib/hosts/passthrough.js +2 -0
  92. package/dist/lib/hosts/reconnect.d.ts +78 -0
  93. package/dist/lib/hosts/reconnect.js +127 -0
  94. package/dist/lib/hosts/registry.d.ts +75 -14
  95. package/dist/lib/hosts/registry.js +205 -30
  96. package/dist/lib/hosts/remote-cmd.js +1 -0
  97. package/dist/lib/hosts/run-target.d.ts +13 -5
  98. package/dist/lib/hosts/run-target.js +26 -10
  99. package/dist/lib/hosts/session-index.d.ts +18 -1
  100. package/dist/lib/hosts/session-index.js +37 -2
  101. package/dist/lib/hosts/session-marker.d.ts +33 -0
  102. package/dist/lib/hosts/session-marker.js +51 -0
  103. package/dist/lib/hosts/tasks.d.ts +8 -4
  104. package/dist/lib/import.d.ts +2 -0
  105. package/dist/lib/import.js +35 -2
  106. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  107. package/dist/lib/menubar/install-menubar.js +45 -1
  108. package/dist/lib/migrate.d.ts +16 -0
  109. package/dist/lib/migrate.js +36 -0
  110. package/dist/lib/models.d.ts +27 -0
  111. package/dist/lib/models.js +54 -1
  112. package/dist/lib/overdue.d.ts +7 -2
  113. package/dist/lib/overdue.js +18 -11
  114. package/dist/lib/picker.js +4 -1
  115. package/dist/lib/platform/process.d.ts +17 -0
  116. package/dist/lib/platform/process.js +70 -0
  117. package/dist/lib/profiles-presets.js +9 -7
  118. package/dist/lib/profiles.d.ts +31 -0
  119. package/dist/lib/profiles.js +70 -0
  120. package/dist/lib/pty-server.d.ts +2 -10
  121. package/dist/lib/pty-server.js +4 -38
  122. package/dist/lib/registry.d.ts +1 -1
  123. package/dist/lib/registry.js +48 -8
  124. package/dist/lib/rotate.d.ts +18 -0
  125. package/dist/lib/rotate.js +28 -0
  126. package/dist/lib/routines.d.ts +26 -0
  127. package/dist/lib/routines.js +19 -2
  128. package/dist/lib/runner.js +104 -7
  129. package/dist/lib/secrets/agent.d.ts +66 -1
  130. package/dist/lib/secrets/agent.js +154 -90
  131. package/dist/lib/secrets/bundles.d.ts +34 -0
  132. package/dist/lib/secrets/bundles.js +179 -19
  133. package/dist/lib/secrets/index.d.ts +15 -1
  134. package/dist/lib/secrets/index.js +21 -3
  135. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  136. package/dist/lib/secrets/rc-hygiene.js +154 -0
  137. package/dist/lib/secrets/remote.d.ts +7 -4
  138. package/dist/lib/secrets/remote.js +7 -4
  139. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  140. package/dist/lib/secrets/sync-commands.js +21 -0
  141. package/dist/lib/self-update.d.ts +23 -2
  142. package/dist/lib/self-update.js +86 -5
  143. package/dist/lib/session/active.d.ts +114 -7
  144. package/dist/lib/session/active.js +323 -52
  145. package/dist/lib/session/cloud.js +2 -1
  146. package/dist/lib/session/db.d.ts +48 -0
  147. package/dist/lib/session/db.js +238 -14
  148. package/dist/lib/session/detached.d.ts +30 -0
  149. package/dist/lib/session/detached.js +92 -0
  150. package/dist/lib/session/discover.d.ts +465 -2
  151. package/dist/lib/session/discover.js +1648 -468
  152. package/dist/lib/session/fork.js +2 -1
  153. package/dist/lib/session/hook-sessions.d.ts +43 -0
  154. package/dist/lib/session/hook-sessions.js +135 -0
  155. package/dist/lib/session/linear.d.ts +6 -0
  156. package/dist/lib/session/linear.js +55 -0
  157. package/dist/lib/session/migrate-targets.d.ts +65 -0
  158. package/dist/lib/session/migrate-targets.js +94 -0
  159. package/dist/lib/session/migrations.d.ts +37 -0
  160. package/dist/lib/session/migrations.js +60 -0
  161. package/dist/lib/session/parse.d.ts +18 -0
  162. package/dist/lib/session/parse.js +130 -30
  163. package/dist/lib/session/pid-registry.d.ts +34 -2
  164. package/dist/lib/session/pid-registry.js +49 -2
  165. package/dist/lib/session/render.d.ts +7 -2
  166. package/dist/lib/session/render.js +22 -10
  167. package/dist/lib/session/short-id.d.ts +17 -0
  168. package/dist/lib/session/short-id.js +20 -0
  169. package/dist/lib/session/state.js +6 -2
  170. package/dist/lib/shims.d.ts +1 -1
  171. package/dist/lib/shims.js +20 -6
  172. package/dist/lib/sqlite.d.ts +3 -2
  173. package/dist/lib/sqlite.js +27 -4
  174. package/dist/lib/staleness/detectors/commands.js +1 -1
  175. package/dist/lib/staleness/detectors/workflows.js +13 -2
  176. package/dist/lib/staleness/writers/commands.js +7 -7
  177. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  178. package/dist/lib/startup/command-registry.d.ts +1 -0
  179. package/dist/lib/startup/command-registry.js +3 -0
  180. package/dist/lib/state.d.ts +1 -1
  181. package/dist/lib/state.js +1 -32
  182. package/dist/lib/teams/agents.d.ts +19 -10
  183. package/dist/lib/teams/agents.js +40 -39
  184. package/dist/lib/types.d.ts +37 -2
  185. package/dist/lib/usage.d.ts +33 -1
  186. package/dist/lib/usage.js +172 -12
  187. package/dist/lib/versions.d.ts +9 -2
  188. package/dist/lib/versions.js +37 -8
  189. package/dist/lib/watchdog/log.d.ts +43 -0
  190. package/dist/lib/watchdog/log.js +69 -0
  191. package/dist/lib/watchdog/routine.d.ts +44 -0
  192. package/dist/lib/watchdog/routine.js +69 -0
  193. package/dist/lib/watchdog/runner.d.ts +51 -7
  194. package/dist/lib/watchdog/runner.js +239 -64
  195. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  196. package/dist/lib/watchdog/watchdog.js +31 -16
  197. package/dist/lib/workflows.d.ts +16 -0
  198. package/dist/lib/workflows.js +110 -1
  199. package/package.json +1 -1
@@ -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' | '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;
@@ -144,6 +144,15 @@ export interface ExecOptions {
144
144
  * (output is mirrored to the parent's stdout exactly like stdio:'inherit').
145
145
  */
146
146
  captureStdoutTail?: boolean;
147
+ /**
148
+ * Print the run's resolved session id to stdout as a one-line sentinel once the
149
+ * child exits (see hosts/session-marker.ts). Set by the `--host` dispatch so the
150
+ * LAUNCHER can relate the remote-created session back to itself — Claude's id is
151
+ * forced up front, but every other agent coins its own id on the remote box, and
152
+ * this marker is how that id rides the followed log home. Headless-only and inert
153
+ * for interactive runs (no combined log to parse).
154
+ */
155
+ emitSessionId?: boolean;
147
156
  /**
148
157
  * Escape hatch for the interactive tmux spawn-wrap (see shouldWrapInTmux):
149
158
  * when true, spawn the agent directly instead of inside a shared-socket tmux
@@ -339,6 +348,63 @@ export declare const BUDGET_KILL_EXIT_CODE = 7;
339
348
  export declare const RATE_LIMIT_PATTERNS: RegExp[];
340
349
  /** Return true if the text contains any known rate-limit or overload indicator. */
341
350
  export declare function detectRateLimit(text: string): boolean;
351
+ /**
352
+ * Patterns that indicate an authentication failure — the agent is logged out,
353
+ * its token was revoked, or the session expired. These are the user-visible
354
+ * strings a logged-out agent surfaces (observed across the routine-run corpus).
355
+ * Unlike a rate limit, an auth failure is NOT self-healing by failover — every
356
+ * chain entry on the same account fails identically — so it is classified
357
+ * separately and never triggers a fallback attempt.
358
+ *
359
+ * The bare `401` is deliberately paired with an auth keyword: a plain "401" can
360
+ * appear in legitimate output (an HTTP-status table, a log line), so it only
361
+ * counts when it co-occurs with OAuth/authentication/credentials/Unauthorized.
362
+ */
363
+ export declare const AUTH_FAILURE_PATTERNS: RegExp[];
364
+ /**
365
+ * Return true if the text contains any known authentication-failure indicator.
366
+ * Agent-agnostic: matches the user-visible error string wherever it surfaces
367
+ * (stdout tail, a captured error message, a plain-text agent's output).
368
+ */
369
+ export declare function detectAuthFailure(text: string): boolean;
370
+ /**
371
+ * Return true if a stream-json log carries the structural markers of an auth
372
+ * failure. This is the authoritative signal for Claude: a logged-out run emits
373
+ * {"type":"system","subtype":"api_retry","error":"authentication_failed",…}
374
+ * {"type":"assistant",…,"error":"authentication_failed"}
375
+ * {"type":"result","is_error":true,"result":"Failed to authenticate…"}
376
+ * Note `terminal_reason` is "completed" on such a run, so exit-code / terminal-
377
+ * reason logic can never catch it — the `error:"authentication_failed"` marker
378
+ * and the `result`+`is_error` text are the reliable signals.
379
+ *
380
+ * Gated on the Claude stream-json shape; other agents don't emit these fields,
381
+ * so callers pass their agent and this returns false for non-claude.
382
+ */
383
+ export declare function detectAuthFailureEvent(logText: string, agent: AgentId): boolean;
384
+ /**
385
+ * Return the first human-readable auth-failure phrase found in the text, for use
386
+ * as a stable, short run `errorMessage` reason. Falls back to null when the only
387
+ * signal was the structural `error:"authentication_failed"` marker with no
388
+ * user-visible string.
389
+ */
390
+ export declare function authFailureReason(text: string): string | null;
391
+ /**
392
+ * Decide whether a run's stream-json log is an authentication failure. The
393
+ * single source of truth for both the foreground and detached run paths.
394
+ *
395
+ * The structural marker (`detectAuthFailureEvent`, gated on `is_error:true`) is
396
+ * authoritative and safe on ANY exit — it catches a logged-out Claude that exits
397
+ * 0 (its `result` event carries `is_error:true` while `terminal_reason` is
398
+ * "completed"). The raw user-visible string (`detectAuthFailure`) is only
399
+ * consulted when the process actually FAILED, as a fallback for a run that died
400
+ * mid-stream before emitting a `result` event. That gate is what prevents a
401
+ * genuinely-completed run whose output merely *mentions* an auth phrase (e.g. a
402
+ * routine documenting a login flow) from being misclassified and having its
403
+ * legitimate report suppressed.
404
+ */
405
+ export declare function isAuthFailureFromLog(logText: string, agent: AgentId, opts: {
406
+ processFailed: boolean;
407
+ }): boolean;
342
408
  /** An agent (with optional pinned version) in a fallback chain. */
343
409
  export interface FallbackEntry {
344
410
  agent: AgentId;