@phnx-labs/agents-cli 1.20.73 → 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 (209) hide show
  1. package/CHANGELOG.md +989 -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 +31 -1
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/export.d.ts +11 -0
  15. package/dist/commands/export.js +215 -0
  16. package/dist/commands/feed.d.ts +7 -1
  17. package/dist/commands/feed.js +47 -4
  18. package/dist/commands/go.d.ts +9 -2
  19. package/dist/commands/go.js +16 -6
  20. package/dist/commands/harness.d.ts +14 -0
  21. package/dist/commands/harness.js +145 -0
  22. package/dist/commands/import.js +87 -9
  23. package/dist/commands/inspect.js +5 -2
  24. package/dist/commands/profiles.d.ts +20 -0
  25. package/dist/commands/profiles.js +68 -21
  26. package/dist/commands/repo.js +14 -0
  27. package/dist/commands/routines.js +15 -2
  28. package/dist/commands/secrets.js +44 -21
  29. package/dist/commands/sessions-browser.js +1 -1
  30. package/dist/commands/sessions-export.js +19 -5
  31. package/dist/commands/sessions-migrate.d.ts +29 -0
  32. package/dist/commands/sessions-migrate.js +596 -0
  33. package/dist/commands/sessions-picker.js +16 -1
  34. package/dist/commands/sessions.d.ts +36 -13
  35. package/dist/commands/sessions.js +210 -40
  36. package/dist/commands/setup-computer.js +2 -2
  37. package/dist/commands/setup.js +6 -1
  38. package/dist/commands/ssh.js +71 -35
  39. package/dist/commands/status.js +10 -2
  40. package/dist/commands/versions.js +51 -11
  41. package/dist/commands/view.d.ts +5 -0
  42. package/dist/commands/view.js +99 -31
  43. package/dist/commands/watchdog.d.ts +7 -2
  44. package/dist/commands/watchdog.js +73 -57
  45. package/dist/index.js +59 -7
  46. package/dist/lib/activity.d.ts +14 -4
  47. package/dist/lib/activity.js +17 -5
  48. package/dist/lib/actor.d.ts +50 -0
  49. package/dist/lib/actor.js +166 -0
  50. package/dist/lib/agent-spec/provider.js +2 -1
  51. package/dist/lib/agent-spec/resolve.js +19 -5
  52. package/dist/lib/agent-spec/types.d.ts +9 -1
  53. package/dist/lib/agents.d.ts +56 -0
  54. package/dist/lib/agents.js +133 -7
  55. package/dist/lib/cloud/codex.d.ts +2 -0
  56. package/dist/lib/cloud/codex.js +14 -3
  57. package/dist/lib/cloud/session-index.d.ts +32 -0
  58. package/dist/lib/cloud/session-index.js +58 -0
  59. package/dist/lib/cloud/store.d.ts +7 -0
  60. package/dist/lib/cloud/store.js +25 -0
  61. package/dist/lib/config-transfer.d.ts +16 -0
  62. package/dist/lib/config-transfer.js +60 -0
  63. package/dist/lib/daemon.js +18 -2
  64. package/dist/lib/devices/connect.d.ts +15 -1
  65. package/dist/lib/devices/connect.js +15 -1
  66. package/dist/lib/devices/fleet.d.ts +21 -0
  67. package/dist/lib/devices/fleet.js +26 -0
  68. package/dist/lib/devices/health-report.d.ts +38 -0
  69. package/dist/lib/devices/health-report.js +214 -0
  70. package/dist/lib/devices/health.js +4 -1
  71. package/dist/lib/devices/reachability.d.ts +31 -0
  72. package/dist/lib/devices/reachability.js +40 -0
  73. package/dist/lib/devices/registry.d.ts +33 -0
  74. package/dist/lib/devices/registry.js +37 -0
  75. package/dist/lib/devices/resolve-target.d.ts +15 -27
  76. package/dist/lib/devices/resolve-target.js +63 -102
  77. package/dist/lib/devices/sync.d.ts +18 -0
  78. package/dist/lib/devices/sync.js +23 -1
  79. package/dist/lib/devices/tailscale.d.ts +3 -0
  80. package/dist/lib/devices/tailscale.js +1 -0
  81. package/dist/lib/events.d.ts +1 -1
  82. package/dist/lib/exec.d.ts +66 -0
  83. package/dist/lib/exec.js +195 -6
  84. package/dist/lib/export.d.ts +72 -0
  85. package/dist/lib/export.js +269 -0
  86. package/dist/lib/feed-post.d.ts +63 -0
  87. package/dist/lib/feed-post.js +204 -0
  88. package/dist/lib/fleet/apply.d.ts +32 -2
  89. package/dist/lib/fleet/apply.js +97 -10
  90. package/dist/lib/fleet/types.d.ts +11 -0
  91. package/dist/lib/fs-walk.d.ts +13 -0
  92. package/dist/lib/fs-walk.js +16 -7
  93. package/dist/lib/hooks.d.ts +1 -0
  94. package/dist/lib/hooks.js +156 -0
  95. package/dist/lib/hosts/dispatch.d.ts +7 -0
  96. package/dist/lib/hosts/dispatch.js +2 -0
  97. package/dist/lib/hosts/passthrough.js +2 -0
  98. package/dist/lib/hosts/reconnect.d.ts +78 -0
  99. package/dist/lib/hosts/reconnect.js +127 -0
  100. package/dist/lib/hosts/registry.d.ts +75 -14
  101. package/dist/lib/hosts/registry.js +205 -30
  102. package/dist/lib/hosts/remote-cmd.js +1 -0
  103. package/dist/lib/hosts/run-target.d.ts +13 -5
  104. package/dist/lib/hosts/run-target.js +26 -10
  105. package/dist/lib/hosts/session-index.d.ts +18 -1
  106. package/dist/lib/hosts/session-index.js +37 -2
  107. package/dist/lib/hosts/session-marker.d.ts +33 -0
  108. package/dist/lib/hosts/session-marker.js +51 -0
  109. package/dist/lib/hosts/tasks.d.ts +8 -4
  110. package/dist/lib/import.d.ts +25 -0
  111. package/dist/lib/import.js +107 -2
  112. package/dist/lib/isolation-boundary-report.d.ts +10 -0
  113. package/dist/lib/isolation-boundary-report.js +35 -0
  114. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  115. package/dist/lib/menubar/install-menubar.js +45 -1
  116. package/dist/lib/migrate.d.ts +16 -0
  117. package/dist/lib/migrate.js +36 -0
  118. package/dist/lib/models.d.ts +27 -0
  119. package/dist/lib/models.js +54 -1
  120. package/dist/lib/overdue.d.ts +7 -2
  121. package/dist/lib/overdue.js +18 -11
  122. package/dist/lib/picker.js +4 -1
  123. package/dist/lib/platform/process.d.ts +17 -0
  124. package/dist/lib/platform/process.js +70 -0
  125. package/dist/lib/profiles-presets.js +9 -7
  126. package/dist/lib/profiles.d.ts +31 -0
  127. package/dist/lib/profiles.js +70 -0
  128. package/dist/lib/project-resources.js +20 -5
  129. package/dist/lib/pty-server.d.ts +2 -10
  130. package/dist/lib/pty-server.js +4 -38
  131. package/dist/lib/registry.d.ts +1 -1
  132. package/dist/lib/registry.js +48 -8
  133. package/dist/lib/rotate.d.ts +18 -0
  134. package/dist/lib/rotate.js +28 -0
  135. package/dist/lib/routines.d.ts +26 -0
  136. package/dist/lib/routines.js +19 -2
  137. package/dist/lib/runner.js +104 -7
  138. package/dist/lib/secrets/agent.d.ts +66 -1
  139. package/dist/lib/secrets/agent.js +154 -90
  140. package/dist/lib/secrets/bundles.d.ts +34 -0
  141. package/dist/lib/secrets/bundles.js +179 -19
  142. package/dist/lib/secrets/index.d.ts +15 -1
  143. package/dist/lib/secrets/index.js +21 -3
  144. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  145. package/dist/lib/secrets/rc-hygiene.js +154 -0
  146. package/dist/lib/secrets/remote.d.ts +7 -4
  147. package/dist/lib/secrets/remote.js +7 -4
  148. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  149. package/dist/lib/secrets/sync-commands.js +21 -0
  150. package/dist/lib/self-update.d.ts +23 -2
  151. package/dist/lib/self-update.js +86 -5
  152. package/dist/lib/session/active.d.ts +114 -7
  153. package/dist/lib/session/active.js +323 -52
  154. package/dist/lib/session/cloud.js +2 -1
  155. package/dist/lib/session/db.d.ts +48 -0
  156. package/dist/lib/session/db.js +238 -14
  157. package/dist/lib/session/detached.d.ts +30 -0
  158. package/dist/lib/session/detached.js +92 -0
  159. package/dist/lib/session/discover.d.ts +465 -2
  160. package/dist/lib/session/discover.js +1648 -468
  161. package/dist/lib/session/fork.js +2 -1
  162. package/dist/lib/session/hook-sessions.d.ts +43 -0
  163. package/dist/lib/session/hook-sessions.js +135 -0
  164. package/dist/lib/session/linear.d.ts +6 -0
  165. package/dist/lib/session/linear.js +55 -0
  166. package/dist/lib/session/migrate-targets.d.ts +65 -0
  167. package/dist/lib/session/migrate-targets.js +94 -0
  168. package/dist/lib/session/migrations.d.ts +37 -0
  169. package/dist/lib/session/migrations.js +60 -0
  170. package/dist/lib/session/parse.d.ts +18 -0
  171. package/dist/lib/session/parse.js +130 -30
  172. package/dist/lib/session/pid-registry.d.ts +34 -2
  173. package/dist/lib/session/pid-registry.js +49 -2
  174. package/dist/lib/session/render.d.ts +7 -2
  175. package/dist/lib/session/render.js +22 -10
  176. package/dist/lib/session/short-id.d.ts +17 -0
  177. package/dist/lib/session/short-id.js +20 -0
  178. package/dist/lib/session/state.js +6 -2
  179. package/dist/lib/shims.d.ts +27 -1
  180. package/dist/lib/shims.js +88 -6
  181. package/dist/lib/sqlite.d.ts +3 -2
  182. package/dist/lib/sqlite.js +27 -4
  183. package/dist/lib/staleness/detectors/commands.js +1 -1
  184. package/dist/lib/staleness/detectors/workflows.js +13 -2
  185. package/dist/lib/staleness/writers/commands.js +7 -7
  186. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  187. package/dist/lib/startup/command-registry.d.ts +2 -0
  188. package/dist/lib/startup/command-registry.js +5 -0
  189. package/dist/lib/state.d.ts +1 -1
  190. package/dist/lib/state.js +15 -36
  191. package/dist/lib/teams/agents.d.ts +19 -10
  192. package/dist/lib/teams/agents.js +40 -39
  193. package/dist/lib/types.d.ts +48 -2
  194. package/dist/lib/uninstall.js +1 -45
  195. package/dist/lib/usage.d.ts +33 -1
  196. package/dist/lib/usage.js +172 -12
  197. package/dist/lib/versions.d.ts +22 -2
  198. package/dist/lib/versions.js +98 -10
  199. package/dist/lib/watchdog/log.d.ts +43 -0
  200. package/dist/lib/watchdog/log.js +69 -0
  201. package/dist/lib/watchdog/routine.d.ts +44 -0
  202. package/dist/lib/watchdog/routine.js +69 -0
  203. package/dist/lib/watchdog/runner.d.ts +51 -7
  204. package/dist/lib/watchdog/runner.js +239 -64
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +31 -16
  207. package/dist/lib/workflows.d.ts +16 -0
  208. package/dist/lib/workflows.js +110 -1
  209. package/package.json +1 -1
package/dist/lib/hooks.js CHANGED
@@ -801,6 +801,10 @@ export function parseHookManifest(opts = {}) {
801
801
  }
802
802
  return merged;
803
803
  }
804
+ export function selectHookManifest(manifest, selected) {
805
+ const selectedHooks = new Set(selected);
806
+ return Object.fromEntries(Object.entries(manifest).filter(([name, hook]) => selectedHooks.has(name) || selectedHooks.has(path.basename(hook.script))));
807
+ }
804
808
  /**
805
809
  * Hook script files present on disk that no manifest entry declares — "dead"
806
810
  * hooks. The registrar only wires manifest-declared hooks into an agent's
@@ -934,6 +938,15 @@ function sweepOrphanShims(manifest) {
934
938
  export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride) {
935
939
  const manifest = hookManifest || parseHookManifest();
936
940
  if (Object.keys(manifest).length === 0) {
941
+ if (agentId === 'opencode') {
942
+ const pluginPath = path.join(versionHome, '.config', 'opencode', 'plugins', 'agents-cli-hooks.ts');
943
+ try {
944
+ fs.rmSync(pluginPath, { force: true });
945
+ }
946
+ catch (e) {
947
+ return { registered: [], errors: [`Failed to remove agents-cli-hooks.ts: ${e.message}`] };
948
+ }
949
+ }
937
950
  return { registered: [], errors: [] };
938
951
  }
939
952
  sweepOrphanShims(manifest);
@@ -991,6 +1004,9 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
991
1004
  if (agentId === 'grok') {
992
1005
  return registerHooksForGrok(versionHome, manifest, resolveScript, managedPrefixes);
993
1006
  }
1007
+ if (agentId === 'opencode') {
1008
+ return registerHooksForOpenCode(versionHome, manifest, resolveScript);
1009
+ }
994
1010
  if (agentId === 'kimi') {
995
1011
  return registerHooksForKimi(versionHome, manifest, resolveScript, managedPrefixes);
996
1012
  }
@@ -1011,6 +1027,146 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1011
1027
  }
1012
1028
  return { registered: [], errors: [] };
1013
1029
  }
1030
+ const OPENCODE_DIRECT_EVENT_MAP = {
1031
+ PreToolUse: 'tool.execute.before',
1032
+ PostToolUse: 'tool.execute.after',
1033
+ UserPromptSubmit: 'chat.message',
1034
+ };
1035
+ const OPENCODE_LIFECYCLE_EVENT_MAP = {
1036
+ SessionStart: ['session.created'],
1037
+ SessionEnd: ['session.deleted'],
1038
+ Stop: ['session.idle', 'session.error'],
1039
+ PreCompact: ['session.compacted'],
1040
+ OnError: ['session.error'],
1041
+ Notification: ['permission.asked'],
1042
+ };
1043
+ /**
1044
+ * Compile canonical hooks.yaml entries into a local OpenCode plugin.
1045
+ *
1046
+ * OpenCode has no declarative shell-hook config block. It auto-loads direct
1047
+ * TS/JS modules from ~/.config/opencode/plugins/, and supplies Bun's `$` shell
1048
+ * primitive to each plugin. The generated module subscribes to native plugin
1049
+ * events, sends the event payload to the managed hook script as JSON on stdin,
1050
+ * and surfaces a non-zero script exit as a plugin error.
1051
+ */
1052
+ function registerHooksForOpenCode(versionHome, manifest, resolveScript) {
1053
+ const registered = [];
1054
+ const errors = [];
1055
+ const direct = new Map();
1056
+ const lifecycle = new Map();
1057
+ for (const [name, hookDef] of Object.entries(manifest)) {
1058
+ if (!hookDef.events || hookDef.events.length === 0)
1059
+ continue;
1060
+ const command = resolveHookCommand(name, hookDef, resolveScript);
1061
+ if (!command) {
1062
+ errors.push(`${name}: script not found`);
1063
+ continue;
1064
+ }
1065
+ const generated = {
1066
+ name,
1067
+ command,
1068
+ ...(hookDef.timeout !== undefined ? { timeout: hookDef.timeout } : {}),
1069
+ ...(hookDef.matcher ? { matcher: hookDef.matcher } : {}),
1070
+ };
1071
+ for (const event of hookDef.events) {
1072
+ const directEvent = OPENCODE_DIRECT_EVENT_MAP[event];
1073
+ if (directEvent) {
1074
+ const hooks = direct.get(directEvent) ?? [];
1075
+ hooks.push(generated);
1076
+ direct.set(directEvent, hooks);
1077
+ registered.push(`${name} -> ${directEvent}`);
1078
+ }
1079
+ for (const lifecycleEvent of OPENCODE_LIFECYCLE_EVENT_MAP[event] ?? []) {
1080
+ const hooks = lifecycle.get(lifecycleEvent) ?? [];
1081
+ hooks.push(generated);
1082
+ lifecycle.set(lifecycleEvent, hooks);
1083
+ registered.push(`${name} -> ${lifecycleEvent}`);
1084
+ }
1085
+ }
1086
+ }
1087
+ const serializedDirect = JSON.stringify(Object.fromEntries(direct), null, 2);
1088
+ const serializedLifecycle = JSON.stringify(Object.fromEntries(lifecycle), null, 2);
1089
+ const pluginSource = `// Generated by agents-cli. Re-run agents sync to update.
1090
+ const directHooks = ${serializedDirect}
1091
+ const lifecycleHooks = ${serializedLifecycle}
1092
+
1093
+ function matches(hook, tool) {
1094
+ if (!hook.matcher) return true
1095
+ try {
1096
+ return new RegExp(hook.matcher).test(tool)
1097
+ } catch {
1098
+ return hook.matcher === tool
1099
+ }
1100
+ }
1101
+
1102
+ async function runHooks(hooks, payload, $, matchTool = false) {
1103
+ for (const hook of hooks ?? []) {
1104
+ if (matchTool && !matches(hook, payload.tool_name ?? "")) continue
1105
+ const input = JSON.stringify(payload)
1106
+ const home = Bun.env.HOME ?? Bun.env.USERPROFILE ?? ""
1107
+ const command = hook.command.startsWith("~/")
1108
+ ? \`\${home}/\${hook.command.slice(2)}\`
1109
+ : hook.command
1110
+ const shell = Bun.which("bash") ?? Bun.which("sh")
1111
+ if (!shell) throw new Error(\`\${hook.name} requires bash or sh\`)
1112
+ const execArgs = [shell, "-c", 'exec "$1"', "agents-hook", command]
1113
+ if (hook.timeout !== undefined) {
1114
+ const child = Bun.spawn(execArgs, {
1115
+ stdin: new Response(input),
1116
+ stdout: "ignore",
1117
+ stderr: "pipe",
1118
+ })
1119
+ let timedOut = false
1120
+ const timer = setTimeout(() => {
1121
+ timedOut = true
1122
+ if (process.platform === "win32") {
1123
+ Bun.spawnSync(["taskkill", "/PID", String(child.pid), "/T", "/F"])
1124
+ } else {
1125
+ child.kill()
1126
+ }
1127
+ }, hook.timeout * 1000)
1128
+ const exitCode = await child.exited.finally(() => clearTimeout(timer))
1129
+ const stderr = await new Response(child.stderr).text()
1130
+ if (timedOut) {
1131
+ throw new Error(\`\${hook.name} timed out after \${hook.timeout} seconds\`)
1132
+ }
1133
+ if (exitCode !== 0) {
1134
+ throw new Error(\`\${hook.name} failed with exit code \${exitCode}: \${stderr.trim()}\`)
1135
+ }
1136
+ continue
1137
+ }
1138
+ const result = await $\`\${shell} -c \${'exec "$1"'} \${"agents-hook"} \${command} < \${new Response(input)}\`.nothrow().quiet()
1139
+ if (result.exitCode !== 0) {
1140
+ throw new Error(\`\${hook.name} failed with exit code \${result.exitCode}: \${result.stderr.toString().trim()}\`)
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ export const AgentsCliHooks = async ({ $ }) => ({
1146
+ event: async ({ event }) => {
1147
+ await runHooks(lifecycleHooks[event.type], { hook_event_name: event.type, ...event }, $)
1148
+ },
1149
+ "chat.message": async (input, output) => {
1150
+ await runHooks(directHooks["chat.message"], { hook_event_name: "UserPromptSubmit", ...input, ...output }, $)
1151
+ },
1152
+ "tool.execute.before": async (input, output) => {
1153
+ await runHooks(directHooks["tool.execute.before"], { hook_event_name: "PreToolUse", tool_name: input.tool, tool_input: output.args, ...input }, $, true)
1154
+ },
1155
+ "tool.execute.after": async (input, output) => {
1156
+ await runHooks(directHooks["tool.execute.after"], { hook_event_name: "PostToolUse", tool_name: input.tool, tool_input: input.args, tool_response: output, ...input }, $, true)
1157
+ },
1158
+ })
1159
+ `;
1160
+ const pluginDir = path.join(versionHome, '.config', 'opencode', 'plugins');
1161
+ try {
1162
+ fs.mkdirSync(pluginDir, { recursive: true });
1163
+ fs.writeFileSync(path.join(pluginDir, 'agents-cli-hooks.ts'), pluginSource, 'utf-8');
1164
+ }
1165
+ catch (e) {
1166
+ errors.push(`Failed to write agents-cli-hooks.ts: ${e.message}`);
1167
+ }
1168
+ return { registered, errors };
1169
+ }
1014
1170
  /**
1015
1171
  * Antigravity (agy) event names differ from agents-cli manifest names. The
1016
1172
  * mapping below is the documented agy schema. PostToolUse has no exact
@@ -107,6 +107,13 @@ export interface DispatchOptions {
107
107
  name?: string;
108
108
  /** Resume an existing session on the host by id (via `agents run --resume`). */
109
109
  resume?: string;
110
+ /**
111
+ * Forward `--emit-session-id` so the remote run prints its resolved session id
112
+ * as a stdout sentinel (hosts/session-marker.ts). The launcher parses that id
113
+ * out of the followed log and stamps it on the task — the join that maps a
114
+ * remote-created session back home for agents that don't take `--session-id`.
115
+ */
116
+ emitSessionId?: boolean;
110
117
  /** Stream progress and block until completion (default true). */
111
118
  follow?: boolean;
112
119
  timeoutMs?: number;
@@ -298,6 +298,8 @@ export function buildRunForwardedArgs(opts) {
298
298
  args.push('--resume', opts.resume);
299
299
  else if (opts.sessionId)
300
300
  args.push('--session-id', opts.sessionId);
301
+ if (opts.emitSessionId)
302
+ args.push('--emit-session-id');
301
303
  if (opts.passthroughArgs && opts.passthroughArgs.length > 0)
302
304
  args.push('--', ...opts.passthroughArgs);
303
305
  return args;
@@ -98,6 +98,8 @@ const REMOTE_PASSTHROUGH = {
98
98
  const OWN_HOST_COMMANDS = new Set([
99
99
  'run',
100
100
  'exec', // deprecated alias of run
101
+ 'harness', // `--host <agent>` names the host CLI to run under, not a remote device
102
+ 'harnesses',
101
103
  'sessions',
102
104
  'feed',
103
105
  'computer',
@@ -0,0 +1,78 @@
1
+ import { type Host } from './types.js';
2
+ /** ssh's connection-layer failure code — the signal that the link dropped rather
3
+ * than the remote command exiting on its own. Mirrors ssh-exec.ts `sshStream`. */
4
+ export declare const SSH_CONN_FAILURE = 255;
5
+ /** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
6
+ * {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
7
+ * refills the budget, so a long session that blinks all day reconnects every time
8
+ * — only consecutive UNREACHABLE attempts exhaust it. */
9
+ export declare const MAX_ATTEMPTS = 6;
10
+ export interface ReconnectState {
11
+ /** Consecutive failed-to-connect reattaches since the last genuine reconnection. */
12
+ attempt: number;
13
+ }
14
+ export interface ReconnectOutcome {
15
+ /** Exit code of the run (initial) or the last re-attach. */
16
+ code: number;
17
+ /** Whether the ssh handshake for this attempt actually completed. The initial run
18
+ * and any reattach whose preflight probe succeeded are `connected`; a reattach
19
+ * that couldn't reach the host is not. Drives the budget refill (see file head). */
20
+ connected: boolean;
21
+ }
22
+ export type ReconnectDecision = {
23
+ action: 'stop';
24
+ code: number;
25
+ } | {
26
+ action: 'retry';
27
+ waitMs: number;
28
+ state: ReconnectState;
29
+ };
30
+ export declare function initialReconnectState(): ReconnectState;
31
+ /** Exponential backoff capped at {@link MAX_BACKOFF_MS}: 2s, 4s, 8s, 16s, 30s… */
32
+ export declare function backoffMs(attempt: number): number;
33
+ /**
34
+ * Decide what to do after a run/re-attach returned `outcome`. Pure — the only
35
+ * input is the prior state and the outcome, the only output is the next action.
36
+ *
37
+ * - a non-255 code means the remote command spoke for itself (clean detach = 0,
38
+ * agent exit / no live session = non-zero) → stop and surface that code.
39
+ * - a 255 means the link dropped → retry, unless the budget is spent.
40
+ * - a 255 from an attempt that DID connect (a genuine reconnection that then
41
+ * dropped) refills the budget first; a 255 that never connected counts against
42
+ * it, so a host that stays unreachable gives up after MAX_ATTEMPTS.
43
+ */
44
+ export declare function reconnectStep(state: ReconnectState, outcome: ReconnectOutcome): ReconnectDecision;
45
+ /** Human-readable notice shown before each reconnect wait. "13 seconds", not "12.8s". */
46
+ export declare function reconnectNotice(sessionId: string, host: string, attempt: number, waitMs: number): string;
47
+ /** Notice shown once the retry budget is spent. */
48
+ export declare function exhaustedNotice(sessionId: string, host: string): string;
49
+ /**
50
+ * Re-attach the live remote tmux pane for `sessionId` by driving the peer's own
51
+ * `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
52
+ * first establishes whether the host is actually reachable this attempt — that
53
+ * `connected` bit, not the call duration, is what the retry policy keys on. Only on
54
+ * a reachable host do we run the interactive attach (which carries no credentials —
55
+ * the agent already runs on the peer — so it rides the normal transport). Returns
56
+ * the ssh exit code (255 = dropped again / unreachable; 0 = clean detach; other =
57
+ * session ended) plus whether this attempt connected.
58
+ */
59
+ export declare function reattachRemoteSession(host: Host, sessionId: string): ReconnectOutcome;
60
+ export interface ReconnectLoopOpts {
61
+ host: Host;
62
+ sessionId: string;
63
+ /** The exit code from the initial interactive run (which, having run the agent,
64
+ * is treated as a connected attempt). */
65
+ initialExit: number;
66
+ /** Injected for tests: the real re-attach and wait are swapped for deterministic
67
+ * fakes so the loop's control flow is exercised without SSH. Production uses the
68
+ * real {@link reattachRemoteSession} + `sleep`. */
69
+ reattach?: (host: Host, sessionId: string) => ReconnectOutcome;
70
+ wait?: (ms: number) => Promise<void>;
71
+ write?: (s: string) => void;
72
+ }
73
+ /**
74
+ * Drive the reconnect loop from the initial run's outcome to a terminal code.
75
+ * Only the real SSH re-attach and the wait are side effects; the decision is
76
+ * {@link reconnectStep}. Returns the exit code the process should ultimately use.
77
+ */
78
+ export declare function reconnectInteractiveSession(opts: ReconnectLoopOpts): Promise<number>;
@@ -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
  /**