@phnx-labs/agents-cli 1.20.35 → 1.20.36

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 (225) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
package/dist/lib/hooks.js CHANGED
@@ -68,11 +68,15 @@ function getManagedHookPrefixes() {
68
68
  * absolute Windows paths break in bash because backslashes are stripped as
69
69
  * escape characters, whereas ~/... paths expand correctly via the ~/.claude
70
70
  * symlink/junction on both platforms.
71
+ *
72
+ * `home` and `sep` are injectable so the Windows behavior (backslash sep,
73
+ * drive-letter home) is unit-testable on a POSIX CI host — pass sep='\\' to
74
+ * simulate Windows. With the defaults this is byte-identical to reading
75
+ * os.homedir()/path.sep at the call site.
71
76
  */
72
- function toPortableCommand(absPath) {
73
- const home = os.homedir();
74
- const normalized = absPath.split(path.sep).join('/');
75
- const homeNorm = home.split(path.sep).join('/');
77
+ export function toPortableCommand(absPath, home = os.homedir(), sep = path.sep) {
78
+ const normalized = absPath.split(sep).join('/');
79
+ const homeNorm = home.split(sep).join('/');
76
80
  if (normalized.startsWith(homeNorm + '/')) {
77
81
  return '~/' + normalized.slice(homeNorm.length + 1);
78
82
  }
@@ -113,8 +117,38 @@ function isManagedHookCommand(command, prefixes) {
113
117
  }
114
118
  return false;
115
119
  }
120
+ /**
121
+ * Per-version-home command detection. Sync copies each hook script into the
122
+ * active version's home and registers the command by that version-scoped path
123
+ * (`~/.agents/.history/versions/<agent>/<version>/home/…`). Because the path
124
+ * embeds the version number, a later version's sync appends a fresh set whose
125
+ * paths never string-match (and thus never prune) the prior version's entries —
126
+ * so entries for every version installed over time pile up in one settings
127
+ * file, and once a version is removed its entries become dead hooks that error
128
+ * on every tool call. `versionHomeIdentity` extracts the `<agent>/<version>` a
129
+ * command (or home path) belongs to so stale sibling-version entries can be
130
+ * pruned. Returns null for any path outside a per-version home (system hooks,
131
+ * the user's own custom hooks) — those are never a prune target.
132
+ */
133
+ const VERSION_HOME_SEGMENT_RE = /\.history\/versions\/([^/]+)\/([^/]+)\/home(?:\/|$)/;
134
+ function versionHomeIdentity(commandOrPath) {
135
+ const norm = commandOrPath.split(/[\\/]/).join('/');
136
+ const m = VERSION_HOME_SEGMENT_RE.exec(norm);
137
+ return m ? { agent: m[1], version: m[2] } : null;
138
+ }
139
+ /**
140
+ * True when `command` points into a DIFFERENT version home of the same agent as
141
+ * `current` — i.e. a stale entry left behind by an earlier version's sync.
142
+ * Non-version-home commands (system + user-custom hooks) always return false.
143
+ */
144
+ function isStaleSiblingVersionCommand(command, current) {
145
+ if (!current)
146
+ return false;
147
+ const id = versionHomeIdentity(command);
148
+ return id !== null && id.agent === current.agent && id.version !== current.version;
149
+ }
116
150
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions } from './versions.js';
117
- import { generateHookShim, parseCacheConfig, removeHookShim } from './hooks/cache.js';
151
+ import { generateHookShim, isValidHookShimName, parseCacheConfig, removeHookShim } from './hooks/cache.js';
118
152
  import { getHookShimsDir } from './state.js';
119
153
  /**
120
154
  * Resolve the command path to register for a hook.
@@ -128,6 +162,8 @@ function resolveHookCommand(name, hookDef, resolveScript) {
128
162
  const scriptPath = resolveScript(hookDef.script);
129
163
  if (!scriptPath)
130
164
  return null;
165
+ if (!isValidHookShimName(name))
166
+ return null;
131
167
  const cache = parseCacheConfig(hookDef.cache);
132
168
  if (!cache) {
133
169
  // No caching opted in — make sure a previously generated shim from an
@@ -715,6 +751,24 @@ export function parseHookManifest(opts = {}) {
715
751
  }
716
752
  catch { /* skip unreadable manifest */ }
717
753
  }
754
+ // Extra-repo layer: hooks: section of each enabled extra repo's agents.yaml.
755
+ // Sits above system but below user, mirroring resolveHookScriptPath's
756
+ // first-found order (user > extra > system). Without this layer the script
757
+ // path of an extra-repo hook resolves but its events never register (#602).
758
+ // Earlier extras win over later ones, so iterate in reverse: the last write
759
+ // for a given name comes from the earliest-registered repo.
760
+ for (const { dir } of [...getEnabledExtraRepos()].reverse()) {
761
+ const extraMetaPath = path.join(dir, 'agents.yaml');
762
+ if (!fs.existsSync(extraMetaPath))
763
+ continue;
764
+ try {
765
+ const meta = yaml.parse(fs.readFileSync(extraMetaPath, 'utf-8'));
766
+ if (meta?.hooks)
767
+ for (const [name, def] of Object.entries(meta.hooks))
768
+ merged[name] = def;
769
+ }
770
+ catch { /* skip unreadable extra-repo manifest */ }
771
+ }
718
772
  // User layer: hooks: section of agents.yaml.
719
773
  const userMetaPath = path.join(getUserAgentsDir(), 'agents.yaml');
720
774
  if (fs.existsSync(userMetaPath)) {
@@ -989,15 +1043,21 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
989
1043
  if (resolved)
990
1044
  currentManifestPaths.add(resolved);
991
1045
  }
992
- // Remove stale entries: any hook command under a managed root that isn't
993
- // in the current manifest is a leftover from a renamed/deleted hook script.
1046
+ // Identity of the version home being synced. Used to prune entries that point
1047
+ // at a DIFFERENT version home of the same agent (see isStaleSiblingVersionCommand).
1048
+ const currentVh = versionHomeIdentity(versionHome);
1049
+ // Remove stale entries: any hook command under a managed root that isn't in
1050
+ // the current manifest is a leftover from a renamed/deleted hook script; any
1051
+ // command pointing at a sibling version's home is a leftover from that
1052
+ // version's sync (its version-scoped path never matches the current set).
994
1053
  for (const eventEntries of Object.values(hooks)) {
995
1054
  if (!Array.isArray(eventEntries))
996
1055
  continue;
997
1056
  for (const group of eventEntries) {
998
1057
  if (!group.hooks)
999
1058
  continue;
1000
- group.hooks = group.hooks.filter((h) => !isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command));
1059
+ group.hooks = group.hooks.filter((h) => (!isManagedHookCommand(h.command, managedPrefixes) || currentManifestPaths.has(h.command)) &&
1060
+ !isStaleSiblingVersionCommand(h.command, currentVh));
1001
1061
  }
1002
1062
  }
1003
1063
  // Remove empty matcher groups left after cleanup
@@ -1049,6 +1109,65 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
1049
1109
  }
1050
1110
  return { registered, errors };
1051
1111
  }
1112
+ /**
1113
+ * Prune every Claude-family (`settings.json`) hook entry whose command lives
1114
+ * under a removed version's home
1115
+ * (`~/.agents/.history/versions/<agent>/<removedVersion>/home/…`).
1116
+ *
1117
+ * `agents remove <agent>@<version>` soft-deletes the version's files but leaves
1118
+ * the hook entries other version homes registered against it — dead hooks that
1119
+ * error on every tool call ("No such file or directory") until the next sync.
1120
+ * This clears them from a remaining version's settings immediately. Only the
1121
+ * removed version's entries are touched; the current version's entries, system
1122
+ * hooks, and the user's own custom hooks are left intact. Returns the number of
1123
+ * entries removed.
1124
+ */
1125
+ export function pruneVersionHomeHookEntriesFromSettings(settingsPath, agent, removedVersion) {
1126
+ if (!fs.existsSync(settingsPath))
1127
+ return 0;
1128
+ let config;
1129
+ try {
1130
+ config = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
1131
+ }
1132
+ catch {
1133
+ return 0;
1134
+ }
1135
+ if (!config.hooks || typeof config.hooks !== 'object')
1136
+ return 0;
1137
+ const hooks = config.hooks;
1138
+ let removed = 0;
1139
+ for (const eventEntries of Object.values(hooks)) {
1140
+ if (!Array.isArray(eventEntries))
1141
+ continue;
1142
+ for (const group of eventEntries) {
1143
+ if (!group.hooks)
1144
+ continue;
1145
+ const before = group.hooks.length;
1146
+ group.hooks = group.hooks.filter((h) => {
1147
+ const id = versionHomeIdentity(h.command);
1148
+ return !(id !== null && id.agent === agent && id.version === removedVersion);
1149
+ });
1150
+ removed += before - group.hooks.length;
1151
+ }
1152
+ }
1153
+ if (removed === 0)
1154
+ return 0;
1155
+ // Drop matcher groups left empty by the prune.
1156
+ for (const [event, eventEntries] of Object.entries(hooks)) {
1157
+ if (!Array.isArray(eventEntries))
1158
+ continue;
1159
+ hooks[event] = eventEntries.filter((g) => g.hooks && g.hooks.length > 0);
1160
+ }
1161
+ try {
1162
+ fs.writeFileSync(settingsPath, JSON.stringify(config, null, 2), 'utf-8');
1163
+ }
1164
+ catch {
1165
+ // Best-effort cleanup: a write failure leaves the dead entry to be pruned
1166
+ // on the next sync (see isStaleSiblingVersionCommand).
1167
+ return 0;
1168
+ }
1169
+ return removed;
1170
+ }
1052
1171
  function registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes) {
1053
1172
  const registered = [];
1054
1173
  const errors = [];
@@ -21,10 +21,25 @@ export interface DispatchOptions {
21
21
  mode?: string;
22
22
  model?: string;
23
23
  remoteCwd?: string;
24
+ /**
25
+ * Force the remote run's NEW session to use this exact id (Claude only, via
26
+ * `agents run --session-id`). Captured on the task record so the run is
27
+ * resumable by id. Mutually exclusive with `resume`.
28
+ */
29
+ sessionId?: string;
30
+ /** Resume an existing session on the host by id (via `agents run --resume`). */
31
+ resume?: string;
24
32
  /** Stream progress and block until completion (default true). */
25
33
  follow?: boolean;
26
34
  timeoutMs?: number;
27
35
  }
36
+ /**
37
+ * Build the remote `agents run …` argv for a host dispatch. Pure so the
38
+ * session-id / resume flag wiring is unit-testable without an SSH round-trip.
39
+ * `--session-id` and `--resume` are mutually exclusive (the CLI rejects both);
40
+ * resume wins when — defensively — both are set.
41
+ */
42
+ export declare function buildRunForwardedArgs(opts: DispatchOptions): string[];
28
43
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
29
44
  export declare function dispatchToHost(host: Host, opts: DispatchOptions): Promise<DispatchResult>;
30
45
  export interface CommandDispatchOptions {
@@ -12,6 +12,8 @@ import { randomUUID } from 'crypto';
12
12
  import { sshExec, shellQuote } from '../ssh-exec.js';
13
13
  import { sshTargetFor } from './types.js';
14
14
  import { ensureHostReady } from './ready.js';
15
+ import { remoteShellFor } from './remote-cmd.js';
16
+ import { resolveRemoteOsSync } from './remote-os.js';
15
17
  import { saveTask, updateTask, terminalPatch } from './tasks.js';
16
18
  import { followHostTask } from './progress.js';
17
19
  // Use $HOME (not ~) so the path is correct whether or not it's quoted and
@@ -22,8 +24,22 @@ const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
22
24
  * The launch + task-record + optional follow core. Both `dispatchToHost` (run)
23
25
  * and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
24
26
  * so the nohup/exit-file/offset-tail machinery lives in exactly one place.
27
+ *
28
+ * Windows remotes are refused up front: the detached launch is only half the
29
+ * contract — the follow/reconcile layer (`progress.ts`/`reconcile.ts`) offset-
30
+ * tails the log with POSIX `tail -c`/`printf`/`cat`/`stat`, which do not exist
31
+ * in cmd.exe/PowerShell. Shipping the launch alone would leave `run --host
32
+ * <windows>` dispatching but hanging on follow forever, so we fail fast with an
33
+ * actionable message instead. Read-only `--host` commands (view/sessions/…) run
34
+ * a single round-trip with no follow protocol and DO work against Windows.
25
35
  */
26
36
  async function launchDetached(host, target, opts) {
37
+ if (remoteShellFor(resolveRemoteOsSync(host.name)) === 'powershell') {
38
+ throw new Error(`Detached dispatch to Windows host "${host.name}" is not supported yet — the run ` +
39
+ `follow/reconcile layer is POSIX-only (offset-tails the remote log with tail/cat/stat). ` +
40
+ `Read-only --host commands (view, sessions, usage, cost, doctor, list, teams) do work ` +
41
+ `against Windows.`);
42
+ }
27
43
  const id = randomUUID().slice(0, 8);
28
44
  const remoteLog = `${REMOTE_DIR}/${id}.log`;
29
45
  const remoteExit = `${REMOTE_DIR}/${id}.exit`;
@@ -45,6 +61,7 @@ async function launchDetached(host, target, opts) {
45
61
  agent: opts.agentLabel,
46
62
  prompt: opts.promptLabel,
47
63
  pid: Number.isFinite(pid) ? pid : undefined,
64
+ sessionId: opts.sessionId,
48
65
  remoteLog,
49
66
  remoteExit,
50
67
  status: 'running',
@@ -67,24 +84,40 @@ async function launchDetached(host, target, opts) {
67
84
  const finished = exitCode === -1 ? task : (updateTask(id, terminalPatch(exitCode)) ?? task);
68
85
  return { task: finished, exitCode };
69
86
  }
87
+ /**
88
+ * Build the remote `agents run …` argv for a host dispatch. Pure so the
89
+ * session-id / resume flag wiring is unit-testable without an SSH round-trip.
90
+ * `--session-id` and `--resume` are mutually exclusive (the CLI rejects both);
91
+ * resume wins when — defensively — both are set.
92
+ */
93
+ export function buildRunForwardedArgs(opts) {
94
+ const args = ['run', opts.agent, opts.prompt, '--quiet'];
95
+ if (opts.mode)
96
+ args.push('--mode', opts.mode);
97
+ if (opts.model)
98
+ args.push('--model', opts.model);
99
+ if (opts.resume)
100
+ args.push('--resume', opts.resume);
101
+ else if (opts.sessionId)
102
+ args.push('--session-id', opts.sessionId);
103
+ return args;
104
+ }
70
105
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
71
106
  export async function dispatchToHost(host, opts) {
72
107
  const target = sshTargetFor(host);
73
108
  const { warnings } = ensureHostReady(host, { agent: opts.agent });
74
109
  for (const w of warnings)
75
110
  process.stderr.write(`[hosts] warning: ${w}\n`);
76
- const forwardedArgs = ['run', opts.agent, opts.prompt, '--quiet'];
77
- if (opts.mode)
78
- forwardedArgs.push('--mode', opts.mode);
79
- if (opts.model)
80
- forwardedArgs.push('--model', opts.model);
81
111
  return launchDetached(host, target, {
82
- forwardedArgs,
112
+ forwardedArgs: buildRunForwardedArgs(opts),
83
113
  remoteCwd: opts.remoteCwd,
84
114
  follow: opts.follow,
85
115
  timeoutMs: opts.timeoutMs,
86
116
  agentLabel: opts.agent,
87
117
  promptLabel: opts.prompt,
118
+ // On resume the remote session keeps its existing id; record that id so the
119
+ // task stays mapped to the same session.
120
+ sessionId: opts.resume ?? opts.sessionId,
88
121
  });
89
122
  }
90
123
  /**
@@ -11,6 +11,7 @@ import chalk from 'chalk';
11
11
  import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
12
12
  import { followHostTask } from './progress.js';
13
13
  import { reconcileTask } from './reconcile.js';
14
+ import { sshExecRaw } from '../ssh-exec.js';
14
15
  /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
15
16
  export async function showHostTaskLog(id, follow) {
16
17
  const task = loadTask(id);
@@ -39,7 +40,35 @@ export async function showHostTaskLog(id, follow) {
39
40
  process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
40
41
  }
41
42
  catch {
42
- console.log(chalk.gray('(no local log captured for this task)'));
43
+ // No local log task was dispatched with --no-follow. Fetch from the remote
44
+ // on demand and cache locally so subsequent calls are instant.
45
+ const remote = fetchAndCacheRemoteLog(task);
46
+ if (remote !== null) {
47
+ process.stdout.write(remote);
48
+ }
49
+ else {
50
+ process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
51
+ }
43
52
  }
44
53
  return { found: true, exitCode: 0 };
45
54
  }
55
+ /**
56
+ * Fetch a task's remote log over SSH, write it to the local mirror path (for
57
+ * future instant reads), and return its content. Returns null when the host is
58
+ * unreachable or the remote log is empty/absent.
59
+ *
60
+ * `remoteLog` is a $HOME-prefixed path with a safe (hex) basename — intentionally
61
+ * unquoted so the remote shell expands $HOME, matching the contract in reconcile.ts
62
+ * and progress.ts.
63
+ */
64
+ function fetchAndCacheRemoteLog(task) {
65
+ const res = sshExecRaw(task.target, `cat ${task.remoteLog} 2>/dev/null`, { timeoutMs: 30000, multiplex: true });
66
+ if (res.code !== 0 || res.stdout.length === 0)
67
+ return null;
68
+ // The hosts cache dir already exists (saveTask created it) — write is best-effort.
69
+ try {
70
+ fs.writeFileSync(localLogPath(task.id), res.stdout);
71
+ }
72
+ catch { /* best-effort cache */ }
73
+ return res.stdout;
74
+ }
@@ -12,7 +12,7 @@
12
12
  /** Attach the standard `--host` flag family to a command and return it (chainable). */
13
13
  export function addHostOption(cmd) {
14
14
  return cmd
15
- .option('-H, --host <name>', 'Run this command on a registered host (or user@host) over SSH instead of locally. See `agents hosts`.')
15
+ .option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, or user@host. See `agents devices` / `agents hosts`.')
16
16
  .option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
17
17
  .option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
18
18
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
@@ -22,6 +22,7 @@ import { resolveHost, resolveHostByCap } from './registry.js';
22
22
  import { sshTargetFor } from './types.js';
23
23
  import { dispatchAgentsCommand } from './dispatch.js';
24
24
  import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
+ import { resolveRemoteOsSync } from './remote-os.js';
25
26
  import { machineId } from '../session/sync/config.js';
26
27
  const REMOTE_PASSTHROUGH = {
27
28
  view: {},
@@ -32,6 +33,7 @@ const REMOTE_PASSTHROUGH = {
32
33
  list: {},
33
34
  sync: { nonInteractive: ['--yes'] },
34
35
  teams: {},
36
+ message: {},
35
37
  };
36
38
  /** `--no-tty` is stripped like the routing flags but carries no value. */
37
39
  const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
@@ -130,7 +132,7 @@ export async function maybeRunOnHost(command, allArgs) {
130
132
  }
131
133
  return true;
132
134
  }
133
- const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd);
135
+ const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, resolveRemoteOsSync(host.name));
134
136
  const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
135
137
  if (code === 255) {
136
138
  console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
@@ -8,15 +8,30 @@
8
8
  import type { Host } from './types.js';
9
9
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
10
10
  export declare function localCliVersion(): string | null;
11
- /** uname over ssh reachable + OS string. */
12
- export declare function probeHost(target: string): {
11
+ /** ssh command that confirms reachability and echoes the OS. POSIX boxes answer
12
+ * `uname -s`; a Windows target has no `uname` (ssh lands in cmd.exe/PowerShell),
13
+ * so it runs a tiny PowerShell probe. Pure/exported so both branches are
14
+ * unit-testable without ssh. */
15
+ export declare function buildProbeCommand(os?: string): string;
16
+ /**
17
+ * Reachability + OS probe over ssh. `os` is the caller's hint (device-registry
18
+ * platform / enrolled `HostEntry.os`); when it marks the host Windows we take
19
+ * the PowerShell path and report that known platform, otherwise POSIX + uname.
20
+ */
21
+ export declare function probeHost(target: string, os?: string): {
13
22
  reachable: boolean;
14
23
  os?: string;
15
24
  };
16
- /** Remote agents-cli version (login shell for PATH), or null if not installed. */
17
- export declare function remoteAgentsVersion(target: string): string | null;
25
+ /** ssh command that prints the remote agents-cli version. Pure/exported. */
26
+ export declare function buildRemoteVersionCommand(os?: string): string;
27
+ /** Remote agents-cli version (PATH-resolved on the remote), or null if not installed. */
28
+ export declare function remoteAgentsVersion(target: string, os?: string): string | null;
29
+ /** ssh command that installs/upgrades agents-cli to `spec` then `agents setup`.
30
+ * PowerShell has no `tail`/`[ -d ]`/`||`, so the Windows branch uses native
31
+ * equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
32
+ export declare function buildBootstrapCommand(spec: string, os?: string): string;
18
33
  /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
19
- export declare function bootstrapAgentsCli(target: string, version: string | null): {
34
+ export declare function bootstrapAgentsCli(target: string, version: string | null, os?: string): {
20
35
  ok: boolean;
21
36
  output: string;
22
37
  };
@@ -36,7 +51,15 @@ export interface ReadyProbe {
36
51
  * rather than the exit code, so a command that ran-but-failed is never mistaken
37
52
  * for a dead connection (only ssh's own failure drops the sentinel).
38
53
  */
39
- export declare function readyProbe(target: string): ReadyProbe;
54
+ /**
55
+ * The one-shot readiness command (version + sentinel + agent listing). The
56
+ * Windows branch emits the sentinel with `Write-Output` and branches on
57
+ * `$LASTEXITCODE` (no `printf`/`||`); `parseReadyProbe` keys off the sentinel
58
+ * substring, so the missing leading newline vs the POSIX `printf '\n…'` form is
59
+ * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
60
+ */
61
+ export declare function buildReadyProbeCommand(os?: string): string;
62
+ export declare function readyProbe(target: string, os?: string): ReadyProbe;
40
63
  /** Pure parser for `readyProbe` output (unit-tested without ssh). */
41
64
  export declare function parseReadyProbe(stdout: string): ReadyProbe;
42
65
  /** True if `view` output lists the named agent (word-boundary, case-insensitive). */
@@ -10,6 +10,8 @@ import * as path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
11
  import { sshExec, shellQuote } from '../ssh-exec.js';
12
12
  import { sshTargetFor } from './types.js';
13
+ import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote } from './remote-cmd.js';
14
+ import { resolveRemoteOsSync } from './remote-os.js';
13
15
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
14
16
  export function localCliVersion() {
15
17
  let dir = path.dirname(fileURLToPath(import.meta.url));
@@ -30,29 +32,63 @@ export function localCliVersion() {
30
32
  }
31
33
  return null;
32
34
  }
33
- /** uname over ssh reachable + OS string. */
34
- export function probeHost(target) {
35
- const r = sshExec(target, 'uname -s 2>/dev/null || echo unknown', { timeoutMs: 12000 });
35
+ /** ssh command that confirms reachability and echoes the OS. POSIX boxes answer
36
+ * `uname -s`; a Windows target has no `uname` (ssh lands in cmd.exe/PowerShell),
37
+ * so it runs a tiny PowerShell probe. Pure/exported so both branches are
38
+ * unit-testable without ssh. */
39
+ export function buildProbeCommand(os) {
40
+ if (remoteShellFor(os) === 'powershell') {
41
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell('[System.Environment]::OSVersion.Platform.ToString()')}`;
42
+ }
43
+ return 'uname -s 2>/dev/null || echo unknown';
44
+ }
45
+ /**
46
+ * Reachability + OS probe over ssh. `os` is the caller's hint (device-registry
47
+ * platform / enrolled `HostEntry.os`); when it marks the host Windows we take
48
+ * the PowerShell path and report that known platform, otherwise POSIX + uname.
49
+ */
50
+ export function probeHost(target, os) {
51
+ const r = sshExec(target, buildProbeCommand(os), { timeoutMs: 12000 });
36
52
  if (r.code !== 0)
37
53
  return { reachable: false };
38
- const os = r.stdout.trim();
39
- return { reachable: true, os: os && os !== 'unknown' ? os : undefined };
54
+ if (remoteShellFor(os) === 'powershell')
55
+ return { reachable: true, os };
56
+ const uname = r.stdout.trim();
57
+ return { reachable: true, os: uname && uname !== 'unknown' ? uname : undefined };
40
58
  }
41
- /** Remote agents-cli version (login shell for PATH), or null if not installed. */
42
- export function remoteAgentsVersion(target) {
43
- const r = sshExec(target, 'bash -lc "agents --version 2>/dev/null"', { timeoutMs: 20000 });
59
+ /** ssh command that prints the remote agents-cli version. Pure/exported. */
60
+ export function buildRemoteVersionCommand(os) {
61
+ return remoteShellFor(os) === 'powershell'
62
+ ? buildWindowsAgentsCommand({ args: ['--version'] })
63
+ : 'bash -lc "agents --version 2>/dev/null"';
64
+ }
65
+ /** Remote agents-cli version (PATH-resolved on the remote), or null if not installed. */
66
+ export function remoteAgentsVersion(target, os) {
67
+ const r = sshExec(target, buildRemoteVersionCommand(os), { timeoutMs: 20000 });
44
68
  if (r.code !== 0)
45
69
  return null;
46
70
  const v = r.stdout.trim();
47
71
  return v || null;
48
72
  }
49
- /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
50
- export function bootstrapAgentsCli(target, version) {
51
- const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
73
+ /** ssh command that installs/upgrades agents-cli to `spec` then `agents setup`.
74
+ * PowerShell has no `tail`/`[ -d ]`/`||`, so the Windows branch uses native
75
+ * equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
76
+ export function buildBootstrapCommand(spec, os) {
77
+ if (remoteShellFor(os) === 'powershell') {
78
+ const script = `npm install -g ${powershellQuote(spec)} 2>&1 | Select-Object -Last 3; ` +
79
+ `if (-not (Test-Path "$HOME/.agents/.system")) { agents setup 2>&1 | Select-Object -Last 3 }; ` +
80
+ `agents --version`;
81
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
82
+ }
52
83
  const script = `npm install -g ${shellQuote(spec)} 2>&1 | tail -3; ` +
53
84
  `if [ ! -d ~/.agents/.system ]; then agents setup 2>&1 | tail -3 || true; fi; ` +
54
85
  `agents --version`;
55
- const r = sshExec(target, `bash -lc ${shellQuote(script)}`, { timeoutMs: 300000 });
86
+ return `bash -lc ${shellQuote(script)}`;
87
+ }
88
+ /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
89
+ export function bootstrapAgentsCli(target, version, os) {
90
+ const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
91
+ const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000 });
56
92
  return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() };
57
93
  }
58
94
  /** Sentinel splitting the version output from the agent listing in one probe. */
@@ -65,10 +101,25 @@ const READY_MARKER = '@@AGENTS_READY@@';
65
101
  * rather than the exit code, so a command that ran-but-failed is never mistaken
66
102
  * for a dead connection (only ssh's own failure drops the sentinel).
67
103
  */
68
- export function readyProbe(target) {
104
+ /**
105
+ * The one-shot readiness command (version + sentinel + agent listing). The
106
+ * Windows branch emits the sentinel with `Write-Output` and branches on
107
+ * `$LASTEXITCODE` (no `printf`/`||`); `parseReadyProbe` keys off the sentinel
108
+ * substring, so the missing leading newline vs the POSIX `printf '\n…'` form is
109
+ * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
110
+ */
111
+ export function buildReadyProbeCommand(os) {
112
+ if (remoteShellFor(os) === 'powershell') {
113
+ const script = `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
114
+ `agents view 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
115
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
116
+ }
69
117
  const script = `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
70
118
  `agents view 2>/dev/null || agents list 2>/dev/null`;
71
- const r = sshExec(target, `bash -lc ${shellQuote(script)}`, { timeoutMs: 20000 });
119
+ return `bash -lc ${shellQuote(script)}`;
120
+ }
121
+ export function readyProbe(target, os) {
122
+ const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000 });
72
123
  return parseReadyProbe(r.stdout);
73
124
  }
74
125
  /** Pure parser for `readyProbe` output (unit-tested without ssh). */
@@ -92,7 +143,7 @@ export function viewHasAgent(view, agent) {
92
143
  */
93
144
  export function ensureHostReady(host, opts) {
94
145
  const target = sshTargetFor(host);
95
- const probe = readyProbe(target);
146
+ const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name));
96
147
  if (!probe.reachable) {
97
148
  throw new Error(`Host "${host.name}" (${target}) is not reachable over SSH. Check it's online and key auth works.`);
98
149
  }
@@ -11,8 +11,25 @@ export declare function getAllProviders(): HostProvider[];
11
11
  /** Every host across all registered providers, deduped by name (first wins). */
12
12
  export declare function listAllHosts(): Promise<Host[]>;
13
13
  /**
14
- * Resolve a host name to a single host across providers, or null if unknown.
15
- * First provider that owns the name wins (only `local` in v1).
14
+ * Thrown when a device resolves but can't be used as an offload target because
15
+ * it authenticates with a password. The offload path runs over `sshExec`, whose
16
+ * `SSH_OPTS` force `BatchMode=yes` (no password prompts), so only key / ssh-config
17
+ * auth can carry a `--host` run. Named so the top-level catch prints the message
18
+ * cleanly instead of a stack trace.
19
+ */
20
+ export declare class DeviceOffloadUnsupportedError extends Error {
21
+ constructor(name: string);
22
+ }
23
+ /**
24
+ * Resolve a host name to a single host, or null if unknown. Resolution order:
25
+ * 1. host providers — the `agents hosts` registry (agents.yaml overlay + ssh-config)
26
+ * 2. the devices registry (`agents devices`) — a machine registered once with
27
+ * `agents devices sync` is reachable by `--host`/`--device` with no second enroll
28
+ * 3. an ad-hoc `user@host` (must contain `@`, validated) — nothing to register
29
+ *
30
+ * A bare unknown name returns null so capability-tag routing (`resolveHostByCap`)
31
+ * stays reachable: `--host gpu` must fall through to a cap lookup, not be misread
32
+ * as an ad-hoc target.
16
33
  */
17
34
  export declare function resolveHost(name: string): Promise<Host | null>;
18
35
  /**