@phnx-labs/agents-cli 1.20.34 → 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 (291) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/LICENSE +185 -21
  3. package/README.md +13 -5
  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-actions.d.ts +15 -0
  11. package/dist/commands/computer-actions.js +120 -22
  12. package/dist/commands/computer.js +81 -0
  13. package/dist/commands/cost.js +17 -12
  14. package/dist/commands/daemon.js +4 -1
  15. package/dist/commands/doctor.d.ts +1 -0
  16. package/dist/commands/doctor.js +54 -97
  17. package/dist/commands/events.d.ts +16 -0
  18. package/dist/commands/events.js +173 -0
  19. package/dist/commands/exec.d.ts +19 -0
  20. package/dist/commands/exec.js +283 -12
  21. package/dist/commands/hosts.js +37 -29
  22. package/dist/commands/inspect.d.ts +2 -0
  23. package/dist/commands/inspect.js +62 -16
  24. package/dist/commands/lock.d.ts +12 -0
  25. package/dist/commands/lock.js +70 -0
  26. package/dist/commands/logs.d.ts +17 -0
  27. package/dist/commands/logs.js +139 -0
  28. package/dist/commands/message.d.ts +15 -0
  29. package/dist/commands/message.js +56 -0
  30. package/dist/commands/models.d.ts +3 -0
  31. package/dist/commands/models.js +27 -8
  32. package/dist/commands/permissions.js +9 -2
  33. package/dist/commands/repo.d.ts +34 -0
  34. package/dist/commands/repo.js +243 -65
  35. package/dist/commands/resource-view.d.ts +20 -0
  36. package/dist/commands/resource-view.js +90 -28
  37. package/dist/commands/routines.js +101 -5
  38. package/dist/commands/secrets-migrate.js +106 -57
  39. package/dist/commands/secrets.d.ts +31 -18
  40. package/dist/commands/secrets.js +196 -91
  41. package/dist/commands/serve.d.ts +10 -0
  42. package/dist/commands/serve.js +37 -0
  43. package/dist/commands/sessions-inject.d.ts +14 -0
  44. package/dist/commands/sessions-inject.js +111 -0
  45. package/dist/commands/sessions-picker.d.ts +2 -0
  46. package/dist/commands/sessions-picker.js +24 -3
  47. package/dist/commands/sessions-resume.d.ts +2 -0
  48. package/dist/commands/sessions-resume.js +221 -0
  49. package/dist/commands/sessions-tail.d.ts +10 -0
  50. package/dist/commands/sessions-tail.js +11 -0
  51. package/dist/commands/sessions.d.ts +143 -1
  52. package/dist/commands/sessions.js +628 -80
  53. package/dist/commands/setup.js +4 -2
  54. package/dist/commands/sync.d.ts +3 -1
  55. package/dist/commands/sync.js +156 -4
  56. package/dist/commands/teams.js +217 -0
  57. package/dist/commands/versions.js +2 -4
  58. package/dist/commands/view.d.ts +3 -0
  59. package/dist/commands/view.js +19 -8
  60. package/dist/commands/wallet.d.ts +6 -0
  61. package/dist/commands/wallet.js +22 -5
  62. package/dist/commands/watchdog.d.ts +18 -0
  63. package/dist/commands/watchdog.js +238 -0
  64. package/dist/index.js +82 -32
  65. package/dist/lib/agents.js +30 -9
  66. package/dist/lib/audit/log.d.ts +92 -0
  67. package/dist/lib/audit/log.js +177 -0
  68. package/dist/lib/auto-pull.js +2 -1
  69. package/dist/lib/browser/cdp.js +5 -1
  70. package/dist/lib/browser/chrome.d.ts +10 -0
  71. package/dist/lib/browser/chrome.js +18 -7
  72. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  73. package/dist/lib/browser/drivers/ssh.js +77 -16
  74. package/dist/lib/browser/har.d.ts +84 -0
  75. package/dist/lib/browser/har.js +77 -0
  76. package/dist/lib/browser/ipc.d.ts +3 -0
  77. package/dist/lib/browser/ipc.js +37 -12
  78. package/dist/lib/browser/profiles.d.ts +1 -1
  79. package/dist/lib/browser/profiles.js +8 -10
  80. package/dist/lib/browser/refs.d.ts +65 -0
  81. package/dist/lib/browser/refs.js +73 -1
  82. package/dist/lib/browser/runtime-state.js +1 -0
  83. package/dist/lib/browser/service.d.ts +38 -2
  84. package/dist/lib/browser/service.js +183 -23
  85. package/dist/lib/browser/types.d.ts +14 -1
  86. package/dist/lib/budget/live-cloud.d.ts +42 -0
  87. package/dist/lib/budget/live-cloud.js +79 -0
  88. package/dist/lib/budget/live-team.d.ts +31 -0
  89. package/dist/lib/budget/live-team.js +115 -0
  90. package/dist/lib/cloud/codex.js +4 -0
  91. package/dist/lib/cloud/rush.d.ts +12 -1
  92. package/dist/lib/cloud/rush.js +13 -3
  93. package/dist/lib/cloud/types.d.ts +9 -0
  94. package/dist/lib/computer/dispatch.d.ts +8 -0
  95. package/dist/lib/computer/dispatch.js +125 -0
  96. package/dist/lib/computer/loop.d.ts +62 -0
  97. package/dist/lib/computer/loop.js +98 -0
  98. package/dist/lib/computer/model.d.ts +44 -0
  99. package/dist/lib/computer/model.js +157 -0
  100. package/dist/lib/concurrency.d.ts +19 -0
  101. package/dist/lib/concurrency.js +33 -0
  102. package/dist/lib/daemon.d.ts +92 -0
  103. package/dist/lib/daemon.js +222 -18
  104. package/dist/lib/devices/connect.d.ts +3 -2
  105. package/dist/lib/devices/connect.js +5 -3
  106. package/dist/lib/devices/registry.d.ts +7 -0
  107. package/dist/lib/devices/registry.js +24 -0
  108. package/dist/lib/devices/tailscale.js +1 -1
  109. package/dist/lib/drift.d.ts +52 -0
  110. package/dist/lib/drift.js +112 -0
  111. package/dist/lib/events.d.ts +10 -3
  112. package/dist/lib/events.js +101 -24
  113. package/dist/lib/exec.d.ts +17 -0
  114. package/dist/lib/exec.js +100 -12
  115. package/dist/lib/git.d.ts +27 -0
  116. package/dist/lib/git.js +56 -1
  117. package/dist/lib/hooks/cache.d.ts +6 -0
  118. package/dist/lib/hooks/cache.js +54 -12
  119. package/dist/lib/hooks.d.ts +27 -0
  120. package/dist/lib/hooks.js +135 -10
  121. package/dist/lib/hosts/dispatch.d.ts +15 -0
  122. package/dist/lib/hosts/dispatch.js +45 -13
  123. package/dist/lib/hosts/logs.d.ts +16 -0
  124. package/dist/lib/hosts/logs.js +74 -0
  125. package/dist/lib/hosts/option.js +1 -1
  126. package/dist/lib/hosts/passthrough.js +3 -1
  127. package/dist/lib/hosts/progress.d.ts +66 -0
  128. package/dist/lib/hosts/progress.js +125 -17
  129. package/dist/lib/hosts/ready.d.ts +51 -7
  130. package/dist/lib/hosts/ready.js +98 -25
  131. package/dist/lib/hosts/reconcile.d.ts +53 -0
  132. package/dist/lib/hosts/reconcile.js +81 -0
  133. package/dist/lib/hosts/registry.d.ts +19 -2
  134. package/dist/lib/hosts/registry.js +58 -2
  135. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  136. package/dist/lib/hosts/remote-cmd.js +70 -1
  137. package/dist/lib/hosts/remote-os.d.ts +17 -0
  138. package/dist/lib/hosts/remote-os.js +30 -0
  139. package/dist/lib/hosts/session-index.d.ts +34 -0
  140. package/dist/lib/hosts/session-index.js +56 -0
  141. package/dist/lib/hosts/tasks.d.ts +22 -0
  142. package/dist/lib/hosts/tasks.js +29 -0
  143. package/dist/lib/lock.d.ts +93 -0
  144. package/dist/lib/lock.js +207 -0
  145. package/dist/lib/loop.js +16 -1
  146. package/dist/lib/machine-id.d.ts +21 -0
  147. package/dist/lib/machine-id.js +26 -0
  148. package/dist/lib/mailbox-target.d.ts +36 -0
  149. package/dist/lib/mailbox-target.js +45 -0
  150. package/dist/lib/mailbox.d.ts +47 -0
  151. package/dist/lib/mailbox.js +194 -0
  152. package/dist/lib/mcp.d.ts +5 -0
  153. package/dist/lib/mcp.js +24 -8
  154. package/dist/lib/migrate.d.ts +19 -0
  155. package/dist/lib/migrate.js +134 -26
  156. package/dist/lib/overdue.js +3 -0
  157. package/dist/lib/permissions.d.ts +4 -0
  158. package/dist/lib/permissions.js +35 -0
  159. package/dist/lib/picker.d.ts +26 -0
  160. package/dist/lib/picker.js +127 -0
  161. package/dist/lib/platform/exec.d.ts +46 -0
  162. package/dist/lib/platform/exec.js +74 -0
  163. package/dist/lib/platform/process.d.ts +31 -0
  164. package/dist/lib/platform/process.js +34 -1
  165. package/dist/lib/platform/winpath.js +2 -0
  166. package/dist/lib/plugin-marketplace.d.ts +30 -0
  167. package/dist/lib/plugin-marketplace.js +215 -2
  168. package/dist/lib/plugins.d.ts +5 -0
  169. package/dist/lib/plugins.js +61 -10
  170. package/dist/lib/profiles.d.ts +25 -0
  171. package/dist/lib/profiles.js +22 -6
  172. package/dist/lib/pty-client.js +2 -1
  173. package/dist/lib/rotate.d.ts +61 -0
  174. package/dist/lib/rotate.js +52 -0
  175. package/dist/lib/routines.d.ts +40 -2
  176. package/dist/lib/routines.js +66 -8
  177. package/dist/lib/runner.d.ts +11 -2
  178. package/dist/lib/runner.js +49 -7
  179. package/dist/lib/scheduler.js +6 -1
  180. package/dist/lib/secrets/bundles.d.ts +60 -4
  181. package/dist/lib/secrets/bundles.js +131 -12
  182. package/dist/lib/secrets/filestore.d.ts +3 -0
  183. package/dist/lib/secrets/filestore.js +42 -16
  184. package/dist/lib/secrets/index.d.ts +43 -2
  185. package/dist/lib/secrets/index.js +102 -3
  186. package/dist/lib/secrets/mcp.d.ts +93 -0
  187. package/dist/lib/secrets/mcp.js +205 -0
  188. package/dist/lib/secrets/remote.js +12 -5
  189. package/dist/lib/secrets/sync.js +83 -4
  190. package/dist/lib/secrets/windows.js +14 -3
  191. package/dist/lib/serve/data.d.ts +81 -0
  192. package/dist/lib/serve/data.js +91 -0
  193. package/dist/lib/serve/page.d.ts +7 -0
  194. package/dist/lib/serve/page.js +140 -0
  195. package/dist/lib/serve/server.d.ts +46 -0
  196. package/dist/lib/serve/server.js +115 -0
  197. package/dist/lib/session/active.d.ts +61 -0
  198. package/dist/lib/session/active.js +196 -17
  199. package/dist/lib/session/discover.d.ts +37 -0
  200. package/dist/lib/session/discover.js +204 -27
  201. package/dist/lib/session/inject.d.ts +18 -0
  202. package/dist/lib/session/inject.js +21 -0
  203. package/dist/lib/session/parse.d.ts +6 -0
  204. package/dist/lib/session/parse.js +286 -2
  205. package/dist/lib/session/pid-registry.d.ts +22 -0
  206. package/dist/lib/session/pid-registry.js +104 -0
  207. package/dist/lib/session/provenance.d.ts +14 -2
  208. package/dist/lib/session/provenance.js +39 -8
  209. package/dist/lib/session/remote-active.d.ts +26 -0
  210. package/dist/lib/session/remote-active.js +153 -0
  211. package/dist/lib/session/remote-list.d.ts +51 -0
  212. package/dist/lib/session/remote-list.js +213 -0
  213. package/dist/lib/session/remote.d.ts +7 -1
  214. package/dist/lib/session/remote.js +18 -8
  215. package/dist/lib/session/render.js +1 -1
  216. package/dist/lib/session/sync/config.d.ts +1 -7
  217. package/dist/lib/session/sync/config.js +4 -11
  218. package/dist/lib/session/types.d.ts +18 -1
  219. package/dist/lib/session/types.js +1 -1
  220. package/dist/lib/shims.d.ts +44 -6
  221. package/dist/lib/shims.js +143 -57
  222. package/dist/lib/ssh-exec.d.ts +26 -3
  223. package/dist/lib/ssh-exec.js +47 -3
  224. package/dist/lib/ssh-tunnel.d.ts +24 -5
  225. package/dist/lib/ssh-tunnel.js +62 -63
  226. package/dist/lib/staleness/writers/hooks.js +1 -1
  227. package/dist/lib/startup/command-registry.d.ts +8 -0
  228. package/dist/lib/startup/command-registry.js +17 -1
  229. package/dist/lib/state.d.ts +13 -0
  230. package/dist/lib/state.js +103 -9
  231. package/dist/lib/sync-umbrella.d.ts +14 -7
  232. package/dist/lib/sync-umbrella.js +17 -9
  233. package/dist/lib/teams/forEach.d.ts +110 -0
  234. package/dist/lib/teams/forEach.js +186 -0
  235. package/dist/lib/teams/index.d.ts +1 -0
  236. package/dist/lib/teams/index.js +1 -0
  237. package/dist/lib/teams/pr-watch.d.ts +226 -0
  238. package/dist/lib/teams/pr-watch.js +371 -0
  239. package/dist/lib/teams/registry.js +25 -9
  240. package/dist/lib/teams/supervisor.d.ts +14 -1
  241. package/dist/lib/teams/supervisor.js +19 -0
  242. package/dist/lib/teams/worktree.d.ts +9 -0
  243. package/dist/lib/teams/worktree.js +32 -0
  244. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  245. package/dist/lib/terminal/backends/ghostty.js +69 -0
  246. package/dist/lib/terminal/backends/index.d.ts +19 -0
  247. package/dist/lib/terminal/backends/index.js +31 -0
  248. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  249. package/dist/lib/terminal/backends/iterm.js +62 -0
  250. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  251. package/dist/lib/terminal/backends/tmux.js +23 -0
  252. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  253. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  254. package/dist/lib/terminal/engine.d.ts +39 -0
  255. package/dist/lib/terminal/engine.js +54 -0
  256. package/dist/lib/terminal/index.d.ts +17 -0
  257. package/dist/lib/terminal/index.js +8 -0
  258. package/dist/lib/terminal/inject.d.ts +204 -0
  259. package/dist/lib/terminal/inject.js +247 -0
  260. package/dist/lib/terminal/policy.d.ts +11 -0
  261. package/dist/lib/terminal/policy.js +11 -0
  262. package/dist/lib/terminal/quote.d.ts +11 -0
  263. package/dist/lib/terminal/quote.js +13 -0
  264. package/dist/lib/terminal/resolve.d.ts +64 -0
  265. package/dist/lib/terminal/resolve.js +90 -0
  266. package/dist/lib/terminal/shell.d.ts +6 -0
  267. package/dist/lib/terminal/shell.js +23 -0
  268. package/dist/lib/terminal/transport.d.ts +15 -0
  269. package/dist/lib/terminal/transport.js +40 -0
  270. package/dist/lib/terminal/types.d.ts +59 -0
  271. package/dist/lib/terminal/types.js +13 -0
  272. package/dist/lib/triggers/webhook.d.ts +85 -0
  273. package/dist/lib/triggers/webhook.js +141 -0
  274. package/dist/lib/types.d.ts +7 -0
  275. package/dist/lib/versions.d.ts +30 -3
  276. package/dist/lib/versions.js +129 -18
  277. package/dist/lib/watchdog/index.d.ts +3 -0
  278. package/dist/lib/watchdog/index.js +5 -0
  279. package/dist/lib/watchdog/read.d.ts +35 -0
  280. package/dist/lib/watchdog/read.js +149 -0
  281. package/dist/lib/watchdog/runner.d.ts +127 -0
  282. package/dist/lib/watchdog/runner.js +322 -0
  283. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  284. package/dist/lib/watchdog/watchdog.js +166 -0
  285. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  286. package/dist/lib/watchdog/watchdogTail.js +154 -0
  287. package/dist/lib/whats-new.d.ts +9 -0
  288. package/dist/lib/whats-new.js +35 -0
  289. package/dist/lib/workflows.d.ts +166 -0
  290. package/dist/lib/workflows.js +193 -0
  291. package/package.json +5 -4
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Reconcile a local host-task record against the remote run's ground truth.
3
+ *
4
+ * A detached `--host` run outlives the local follower: it keeps running and
5
+ * writes its exit code to `<id>.exit` on the host even if the laptop sleeps or
6
+ * the SSH connection drops mid-follow. When that happens the local record is
7
+ * left at `status:'running'` forever, because the only path that finalizes it
8
+ * (dispatch's post-follow `updateTask`) never runs. This module re-reads the
9
+ * remote `.exit` on demand — from `agents hosts ps` / `logs` — and heals the
10
+ * record. We only ever CONFIRM completion; an unreachable host or an absent
11
+ * `.exit` leaves the record `running` (we never guess failure).
12
+ */
13
+ import { type SshExecResult } from '../ssh-exec.js';
14
+ import { type HostTask } from './tasks.js';
15
+ export type RemoteExitState = {
16
+ state: 'running';
17
+ } | {
18
+ state: 'done';
19
+ code: number;
20
+ } | {
21
+ state: 'unreachable';
22
+ };
23
+ /**
24
+ * Classify a `cat <remoteExit>` result into a remote run state. Pure: all the
25
+ * bug-prone branching (ssh-failure vs absent vs empty vs coded) lives here so it
26
+ * can be unit-tested without a live host. ssh's own connection failure surfaces
27
+ * as code 255, a spawn error/timeout as `code === null`; neither is the remote
28
+ * command's exit and both mean "unreachable". An empty read is "still running"
29
+ * (the `.exit` is written only after the run ends, so absent `cat` → exit 1 →
30
+ * empty stdout, and a truncate-then-write mid-race is a sub-ms empty window).
31
+ */
32
+ export declare function classifyExit(res: Pick<SshExecResult, 'code' | 'stdout' | 'timedOut'>): RemoteExitState;
33
+ /**
34
+ * Read a task's remote `.exit` over ssh and classify it. `remoteExit` is a
35
+ * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
+ * remote shell expands $HOME (same contract as progress.ts's fetch).
37
+ */
38
+ export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number): RemoteExitState;
39
+ /**
40
+ * Heal one record. Terminal records are immutable (and never re-probed); a
41
+ * `running` record is resolved to completed/failed only when the remote `.exit`
42
+ * holds a code. Returns the (possibly updated) task.
43
+ */
44
+ export declare function reconcileTask(task: HostTask): HostTask;
45
+ /**
46
+ * Heal a list of records for a listing (`agents hosts ps`). Only `running` tasks
47
+ * are probed; each host is reachability-checked ONCE (deduped by target) so a
48
+ * down host costs a single short timeout instead of one per task, and its tasks
49
+ * are left `running` rather than falsely failed. Sequential by design — with the
50
+ * shared ssh control socket the live-host reads are sub-100ms, and a parallel
51
+ * (async) ssh path is deliberately out of scope.
52
+ */
53
+ export declare function reconcileRunningTasks(tasks: HostTask[]): HostTask[];
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Reconcile a local host-task record against the remote run's ground truth.
3
+ *
4
+ * A detached `--host` run outlives the local follower: it keeps running and
5
+ * writes its exit code to `<id>.exit` on the host even if the laptop sleeps or
6
+ * the SSH connection drops mid-follow. When that happens the local record is
7
+ * left at `status:'running'` forever, because the only path that finalizes it
8
+ * (dispatch's post-follow `updateTask`) never runs. This module re-reads the
9
+ * remote `.exit` on demand — from `agents hosts ps` / `logs` — and heals the
10
+ * record. We only ever CONFIRM completion; an unreachable host or an absent
11
+ * `.exit` leaves the record `running` (we never guess failure).
12
+ */
13
+ import { sshExec, sshReachable } from '../ssh-exec.js';
14
+ import { updateTask, terminalPatch } from './tasks.js';
15
+ /**
16
+ * Classify a `cat <remoteExit>` result into a remote run state. Pure: all the
17
+ * bug-prone branching (ssh-failure vs absent vs empty vs coded) lives here so it
18
+ * can be unit-tested without a live host. ssh's own connection failure surfaces
19
+ * as code 255, a spawn error/timeout as `code === null`; neither is the remote
20
+ * command's exit and both mean "unreachable". An empty read is "still running"
21
+ * (the `.exit` is written only after the run ends, so absent `cat` → exit 1 →
22
+ * empty stdout, and a truncate-then-write mid-race is a sub-ms empty window).
23
+ */
24
+ export function classifyExit(res) {
25
+ if (res.timedOut || res.code === null || res.code === 255)
26
+ return { state: 'unreachable' };
27
+ const out = res.stdout.trim();
28
+ if (out === '')
29
+ return { state: 'running' };
30
+ const code = parseInt(out, 10);
31
+ return { state: 'done', code: Number.isFinite(code) ? code : 0 };
32
+ }
33
+ /**
34
+ * Read a task's remote `.exit` over ssh and classify it. `remoteExit` is a
35
+ * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
+ * remote shell expands $HOME (same contract as progress.ts's fetch).
37
+ */
38
+ export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
39
+ return classifyExit(sshExec(target, `cat ${remoteExit} 2>/dev/null`, { timeoutMs, multiplex: true }));
40
+ }
41
+ /**
42
+ * Heal one record. Terminal records are immutable (and never re-probed); a
43
+ * `running` record is resolved to completed/failed only when the remote `.exit`
44
+ * holds a code. Returns the (possibly updated) task.
45
+ */
46
+ export function reconcileTask(task) {
47
+ if (task.status !== 'running')
48
+ return task;
49
+ const st = readRemoteExit(task.target, task.remoteExit);
50
+ if (st.state !== 'done')
51
+ return task;
52
+ return updateTask(task.id, terminalPatch(st.code)) ?? task;
53
+ }
54
+ /**
55
+ * Heal a list of records for a listing (`agents hosts ps`). Only `running` tasks
56
+ * are probed; each host is reachability-checked ONCE (deduped by target) so a
57
+ * down host costs a single short timeout instead of one per task, and its tasks
58
+ * are left `running` rather than falsely failed. Sequential by design — with the
59
+ * shared ssh control socket the live-host reads are sub-100ms, and a parallel
60
+ * (async) ssh path is deliberately out of scope.
61
+ */
62
+ export function reconcileRunningTasks(tasks) {
63
+ const running = tasks.filter((t) => t.status === 'running');
64
+ if (running.length === 0)
65
+ return tasks;
66
+ const reachable = new Map();
67
+ const patched = new Map();
68
+ for (const t of running) {
69
+ if (!reachable.has(t.target))
70
+ reachable.set(t.target, sshReachable(t.target, 6000));
71
+ if (!reachable.get(t.target))
72
+ continue; // host down → leave running
73
+ const st = readRemoteExit(t.target, t.remoteExit);
74
+ if (st.state === 'done') {
75
+ const updated = updateTask(t.id, terminalPatch(st.code));
76
+ if (updated)
77
+ patched.set(t.id, updated);
78
+ }
79
+ }
80
+ return tasks.map((t) => patched.get(t.id) ?? t);
81
+ }
@@ -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
  /**
@@ -6,6 +6,8 @@
6
6
  * `crabbox` later is a one-line `providers.set(...)` with no caller changes.
7
7
  */
8
8
  import { LocalHostProvider } from './providers/local.js';
9
+ import { getDevice } from '../devices/registry.js';
10
+ import { assertValidSshTarget } from '../ssh-exec.js';
9
11
  const providers = new Map();
10
12
  function initProviders() {
11
13
  if (providers.size > 0)
@@ -39,8 +41,54 @@ export async function listAllHosts() {
39
41
  return out;
40
42
  }
41
43
  /**
42
- * Resolve a host name to a single host across providers, or null if unknown.
43
- * First provider that owns the name wins (only `local` in v1).
44
+ * Thrown when a device resolves but can't be used as an offload target because
45
+ * it authenticates with a password. The offload path runs over `sshExec`, whose
46
+ * `SSH_OPTS` force `BatchMode=yes` (no password prompts), so only key / ssh-config
47
+ * auth can carry a `--host` run. Named so the top-level catch prints the message
48
+ * cleanly instead of a stack trace.
49
+ */
50
+ export class DeviceOffloadUnsupportedError extends Error {
51
+ constructor(name) {
52
+ super(`Device "${name}" uses password auth, which --host offload can't use yet ` +
53
+ `(runs go over ssh with BatchMode=yes). Switch it to key auth with ` +
54
+ `\`agents devices set ${name} --auth key\`, or enroll it as a host with ` +
55
+ `\`agents hosts add ${name}\`.`);
56
+ this.name = 'DeviceOffloadUnsupportedError';
57
+ }
58
+ }
59
+ /**
60
+ * Bridge a registered device into a `Host` so `--host <device>` just works.
61
+ * A device's dnsName (preferred) or ip becomes the ssh address; `source: 'inline'`
62
+ * makes `sshTargetFor` emit `user@address`.
63
+ */
64
+ function deviceToHost(device) {
65
+ if (device.auth.method === 'password') {
66
+ throw new DeviceOffloadUnsupportedError(device.name);
67
+ }
68
+ const address = device.address.dnsName ?? device.address.ip;
69
+ if (!address) {
70
+ throw new Error(`Device "${device.name}" has no address (Tailscale DNS name or IP) to reach it by.`);
71
+ }
72
+ return {
73
+ name: device.name,
74
+ provider: 'local',
75
+ source: 'inline',
76
+ address,
77
+ user: device.user,
78
+ ...(device.platform !== 'unknown' ? { os: device.platform } : {}),
79
+ enrolled: true,
80
+ };
81
+ }
82
+ /**
83
+ * Resolve a host name to a single host, or null if unknown. Resolution order:
84
+ * 1. host providers — the `agents hosts` registry (agents.yaml overlay + ssh-config)
85
+ * 2. the devices registry (`agents devices`) — a machine registered once with
86
+ * `agents devices sync` is reachable by `--host`/`--device` with no second enroll
87
+ * 3. an ad-hoc `user@host` (must contain `@`, validated) — nothing to register
88
+ *
89
+ * A bare unknown name returns null so capability-tag routing (`resolveHostByCap`)
90
+ * stays reachable: `--host gpu` must fall through to a cap lookup, not be misread
91
+ * as an ad-hoc target.
44
92
  */
45
93
  export async function resolveHost(name) {
46
94
  for (const provider of getAllProviders()) {
@@ -48,6 +96,14 @@ export async function resolveHost(name) {
48
96
  if (host)
49
97
  return host;
50
98
  }
99
+ const device = await getDevice(name);
100
+ if (device)
101
+ return deviceToHost(device);
102
+ if (name.includes('@')) {
103
+ assertValidSshTarget(name);
104
+ const at = name.indexOf('@');
105
+ return { name, provider: 'local', source: 'inline', user: name.slice(0, at), address: name.slice(at + 1) };
106
+ }
51
107
  return null;
52
108
  }
53
109
  /**
@@ -32,5 +32,66 @@ export declare const HOST_ROUTING_SPECS: StripSpec[];
32
32
  * are quoted for the inner login shell, then the whole `agents …` invocation is
33
33
  * quoted again so it survives `bash -lc <...>` — `bash -lc` so the remote login
34
34
  * PATH resolves `agents`. An optional `cd` runs first for `--remote-cwd`.
35
+ *
36
+ * `os` selects the remote shell dialect: a Windows target gets a PowerShell
37
+ * invocation instead (ssh lands in cmd.exe/PowerShell there, where `bash -lc`
38
+ * does not exist). Anything else — including an unknown/absent OS — keeps the
39
+ * POSIX form, so linux/macos are byte-for-byte unchanged.
40
+ */
41
+ export declare function buildRemoteAgentsInvocation(forwardedArgs: string[], remoteCwd?: string, os?: string): string;
42
+ /** The two remote shell dialects we build commands for. */
43
+ export type RemoteShell = 'posix' | 'powershell';
44
+ /**
45
+ * Pick the remote shell dialect from a recorded OS/platform string. A Windows
46
+ * host (device-registry `platform: 'windows'`, or an enrolled `HostEntry.os`
47
+ * that reads `windows`/`Windows`/`win32`/…) speaks PowerShell; everything else,
48
+ * including `undefined`/unknown, defaults to POSIX so linux/macos never regress.
49
+ */
50
+ export declare function remoteShellFor(os: string | undefined): RemoteShell;
51
+ /**
52
+ * PowerShell single-quoted literal: wrap in `'…'` and double any embedded `'`.
53
+ * Single-quoted PS strings are fully literal (no `$var`, no backtick escapes),
54
+ * so this neutralises every metacharacter the same way POSIX `shellQuote` does.
55
+ */
56
+ export declare function powershellQuote(s: string): string;
57
+ /**
58
+ * Encode a PowerShell script for `powershell -EncodedCommand`: base64 of its
59
+ * UTF-16LE bytes. The payload is a bare base64 word (no spaces or shell
60
+ * metacharacters), so it survives being handed to ssh as a single argument and
61
+ * re-parsed by the remote's cmd.exe/PowerShell with zero quoting hazards —
62
+ * the robust way to ship a complex command to a Windows box over SSH.
63
+ */
64
+ export declare function encodePowershell(script: string): string;
65
+ /** Inverse of {@link encodePowershell} — decode an `-EncodedCommand` payload
66
+ * back to its script. Used by tests to assert on the built command. */
67
+ export declare function decodePowershell(encoded: string): string;
68
+ /** A single `agents …` invocation to run on a Windows remote. */
69
+ export interface WindowsAgentsCommand {
70
+ /** `agents` argv (command name NOT included; `agents` is prepended). */
71
+ args: string[];
72
+ /** Env vars scoped to this invocation (POSIX `VAR=val` ↔ PS `$env:VAR=…`). */
73
+ env?: Record<string, string>;
74
+ /** Directory to enter before running (`--remote-cwd`). */
75
+ cwd?: string;
76
+ /**
77
+ * Append `exit $LASTEXITCODE` so a native `agents` exit code propagates out
78
+ * through `powershell.exe` (which otherwise exits 0 regardless). Default true;
79
+ * pass false for probes whose reachability keys off a sentinel, not the code.
80
+ */
81
+ propagateExit?: boolean;
82
+ }
83
+ /**
84
+ * The PowerShell script (pre-encoding) that {@link buildWindowsAgentsCommand}
85
+ * runs. Exposed so tests can decode the `-EncodedCommand` payload and compare
86
+ * against the exact script, without needing PowerShell on the test host.
87
+ *
88
+ * `& agents …` invokes the CLI from the machine PATH (Windows has no login
89
+ * shell, so there is no `bash -lc` equivalent — the shim is simply on PATH).
90
+ */
91
+ export declare function windowsAgentsScript(cmd: WindowsAgentsCommand): string;
92
+ /**
93
+ * Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
94
+ * Windows remote: a `powershell -NoProfile -EncodedCommand <base64>` call. The
95
+ * Windows counterpart of `bash -lc '<...>'`, shared by every `--host` site.
35
96
  */
36
- export declare function buildRemoteAgentsInvocation(forwardedArgs: string[], remoteCwd?: string): string;
97
+ export declare function buildWindowsAgentsCommand(cmd: WindowsAgentsCommand): string;
@@ -48,9 +48,78 @@ export const HOST_ROUTING_SPECS = [
48
48
  * are quoted for the inner login shell, then the whole `agents …` invocation is
49
49
  * quoted again so it survives `bash -lc <...>` — `bash -lc` so the remote login
50
50
  * PATH resolves `agents`. An optional `cd` runs first for `--remote-cwd`.
51
+ *
52
+ * `os` selects the remote shell dialect: a Windows target gets a PowerShell
53
+ * invocation instead (ssh lands in cmd.exe/PowerShell there, where `bash -lc`
54
+ * does not exist). Anything else — including an unknown/absent OS — keeps the
55
+ * POSIX form, so linux/macos are byte-for-byte unchanged.
51
56
  */
52
- export function buildRemoteAgentsInvocation(forwardedArgs, remoteCwd) {
57
+ export function buildRemoteAgentsInvocation(forwardedArgs, remoteCwd, os) {
58
+ if (remoteShellFor(os) === 'powershell') {
59
+ return buildWindowsAgentsCommand({ args: forwardedArgs, cwd: remoteCwd });
60
+ }
53
61
  const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
54
62
  const withCwd = remoteCwd ? `cd ${shellQuote(remoteCwd)} && ${inner}` : inner;
55
63
  return `bash -lc ${shellQuote(withCwd)}`;
56
64
  }
65
+ /**
66
+ * Pick the remote shell dialect from a recorded OS/platform string. A Windows
67
+ * host (device-registry `platform: 'windows'`, or an enrolled `HostEntry.os`
68
+ * that reads `windows`/`Windows`/`win32`/…) speaks PowerShell; everything else,
69
+ * including `undefined`/unknown, defaults to POSIX so linux/macos never regress.
70
+ */
71
+ export function remoteShellFor(os) {
72
+ return /^win/i.test((os ?? '').trim()) ? 'powershell' : 'posix';
73
+ }
74
+ /**
75
+ * PowerShell single-quoted literal: wrap in `'…'` and double any embedded `'`.
76
+ * Single-quoted PS strings are fully literal (no `$var`, no backtick escapes),
77
+ * so this neutralises every metacharacter the same way POSIX `shellQuote` does.
78
+ */
79
+ export function powershellQuote(s) {
80
+ return "'" + s.replace(/'/g, "''") + "'";
81
+ }
82
+ /**
83
+ * Encode a PowerShell script for `powershell -EncodedCommand`: base64 of its
84
+ * UTF-16LE bytes. The payload is a bare base64 word (no spaces or shell
85
+ * metacharacters), so it survives being handed to ssh as a single argument and
86
+ * re-parsed by the remote's cmd.exe/PowerShell with zero quoting hazards —
87
+ * the robust way to ship a complex command to a Windows box over SSH.
88
+ */
89
+ export function encodePowershell(script) {
90
+ return Buffer.from(script, 'utf16le').toString('base64');
91
+ }
92
+ /** Inverse of {@link encodePowershell} — decode an `-EncodedCommand` payload
93
+ * back to its script. Used by tests to assert on the built command. */
94
+ export function decodePowershell(encoded) {
95
+ return Buffer.from(encoded, 'base64').toString('utf16le');
96
+ }
97
+ /**
98
+ * The PowerShell script (pre-encoding) that {@link buildWindowsAgentsCommand}
99
+ * runs. Exposed so tests can decode the `-EncodedCommand` payload and compare
100
+ * against the exact script, without needing PowerShell on the test host.
101
+ *
102
+ * `& agents …` invokes the CLI from the machine PATH (Windows has no login
103
+ * shell, so there is no `bash -lc` equivalent — the shim is simply on PATH).
104
+ */
105
+ export function windowsAgentsScript(cmd) {
106
+ const { args, env, cwd, propagateExit = true } = cmd;
107
+ const parts = [];
108
+ if (env)
109
+ for (const [k, v] of Object.entries(env))
110
+ parts.push(`$env:${k} = ${powershellQuote(v)}`);
111
+ if (cwd)
112
+ parts.push(`Set-Location -LiteralPath ${powershellQuote(cwd)}`);
113
+ parts.push(`& ${['agents', ...args].map(powershellQuote).join(' ')}`);
114
+ if (propagateExit)
115
+ parts.push('exit $LASTEXITCODE');
116
+ return parts.join('; ');
117
+ }
118
+ /**
119
+ * Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
120
+ * Windows remote: a `powershell -NoProfile -EncodedCommand <base64>` call. The
121
+ * Windows counterpart of `bash -lc '<...>'`, shared by every `--host` site.
122
+ */
123
+ export function buildWindowsAgentsCommand(cmd) {
124
+ return `powershell -NoProfile -EncodedCommand ${encodePowershell(windowsAgentsScript(cmd))}`;
125
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Resolve a remote host's OS family so the SSH command layer can pick the right
3
+ * shell dialect (POSIX `bash -lc` vs Windows PowerShell). See `remoteShellFor`
4
+ * in `remote-cmd.ts` for how the string is consumed.
5
+ *
6
+ * Two sources, in priority order:
7
+ * 1. The device registry `platform` (`windows`/`linux`/`macos`), which is
8
+ * populated fleet-wide by Tailscale sync — the reliable answer for a box
9
+ * like `win-mini` that was discovered, not hand-enrolled.
10
+ * 2. The enrolled `HostEntry.os` overlay in agents.yaml (the `uname` captured
11
+ * at `agents hosts add` time), for hosts that live only in that overlay.
12
+ *
13
+ * Missing/unknown from both → `undefined`, which `remoteShellFor` maps to POSIX.
14
+ * Kept synchronous so the sync `agents sessions --host` fan-out can use it.
15
+ */
16
+ /** Resolve the OS/platform string for a host name, or undefined if unknown. */
17
+ export declare function resolveRemoteOsSync(name: string): string | undefined;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Resolve a remote host's OS family so the SSH command layer can pick the right
3
+ * shell dialect (POSIX `bash -lc` vs Windows PowerShell). See `remoteShellFor`
4
+ * in `remote-cmd.ts` for how the string is consumed.
5
+ *
6
+ * Two sources, in priority order:
7
+ * 1. The device registry `platform` (`windows`/`linux`/`macos`), which is
8
+ * populated fleet-wide by Tailscale sync — the reliable answer for a box
9
+ * like `win-mini` that was discovered, not hand-enrolled.
10
+ * 2. The enrolled `HostEntry.os` overlay in agents.yaml (the `uname` captured
11
+ * at `agents hosts add` time), for hosts that live only in that overlay.
12
+ *
13
+ * Missing/unknown from both → `undefined`, which `remoteShellFor` maps to POSIX.
14
+ * Kept synchronous so the sync `agents sessions --host` fan-out can use it.
15
+ */
16
+ import { loadDevicesSync } from '../devices/registry.js';
17
+ import { readMeta } from '../state.js';
18
+ /** Resolve the OS/platform string for a host name, or undefined if unknown. */
19
+ export function resolveRemoteOsSync(name) {
20
+ try {
21
+ const platform = loadDevicesSync()[name]?.platform;
22
+ if (platform && platform !== 'unknown')
23
+ return platform;
24
+ }
25
+ catch {
26
+ // A corrupt/unreadable device registry must never break command building —
27
+ // fall through to the host overlay and ultimately the POSIX default.
28
+ }
29
+ return readMeta().hosts?.[name]?.os;
30
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Register host-dispatched runs (`agents run --host <h>`) into the LOCAL session
3
+ * index so they show up in `agents sessions` and are resolvable by id — even
4
+ * though the transcript itself lives on the remote host.
5
+ *
6
+ * The transcript file is remote, so the row is registered with an EMPTY
7
+ * `file_path`. The DB's stale-row filter keeps rows with no file_path
8
+ * (`!file_path || existsSync(file_path)` in db.ts querySessions) precisely for
9
+ * this "synthetic file_path" case, and the scanner never prunes session rows —
10
+ * so the entry survives rescans. `cwd` is the LOCAL directory the dispatch was
11
+ * issued from, so the run appears in that project's session listing like any
12
+ * local run. The `[host/<name>]` label mirrors the cloud path's
13
+ * `[cloud/<status>]` convention.
14
+ */
15
+ import type { SessionMeta } from '../session/types.js';
16
+ import type { HostTask } from './tasks.js';
17
+ export interface HostSessionContext {
18
+ /** Local directory the `agents run --host` was invoked from. */
19
+ cwd: string;
20
+ /** Prompt the run was launched with, used for the session topic. */
21
+ prompt: string;
22
+ }
23
+ /**
24
+ * Build the SessionMeta for a host-dispatched run. Returns null when the run has
25
+ * no captured session id (nothing stable to key/resume on) or its agent isn't a
26
+ * known session agent. Pure — no I/O — so the mapping is unit-testable.
27
+ */
28
+ export declare function hostSessionMeta(task: HostTask, ctx: HostSessionContext): SessionMeta | null;
29
+ /**
30
+ * Register (or refresh) a host-dispatched run in the local session index. No-op
31
+ * when the task carries no session id. Best-effort: a failed write must never
32
+ * break the dispatch itself, which has already been launched on the host.
33
+ */
34
+ export declare function registerHostSession(task: HostTask, ctx: HostSessionContext): void;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Register host-dispatched runs (`agents run --host <h>`) into the LOCAL session
3
+ * index so they show up in `agents sessions` and are resolvable by id — even
4
+ * though the transcript itself lives on the remote host.
5
+ *
6
+ * The transcript file is remote, so the row is registered with an EMPTY
7
+ * `file_path`. The DB's stale-row filter keeps rows with no file_path
8
+ * (`!file_path || existsSync(file_path)` in db.ts querySessions) precisely for
9
+ * this "synthetic file_path" case, and the scanner never prunes session rows —
10
+ * so the entry survives rescans. `cwd` is the LOCAL directory the dispatch was
11
+ * issued from, so the run appears in that project's session listing like any
12
+ * local run. The `[host/<name>]` label mirrors the cloud path's
13
+ * `[cloud/<status>]` convention.
14
+ */
15
+ import { upsertSession } from '../session/db.js';
16
+ import { SESSION_AGENTS } from '../session/types.js';
17
+ /**
18
+ * Build the SessionMeta for a host-dispatched run. Returns null when the run has
19
+ * no captured session id (nothing stable to key/resume on) or its agent isn't a
20
+ * known session agent. Pure — no I/O — so the mapping is unit-testable.
21
+ */
22
+ export function hostSessionMeta(task, ctx) {
23
+ const id = task.sessionId;
24
+ if (!id)
25
+ return null;
26
+ if (!SESSION_AGENTS.includes(task.agent))
27
+ return null;
28
+ return {
29
+ id,
30
+ shortId: id.slice(0, 8),
31
+ agent: task.agent,
32
+ timestamp: task.createdAt,
33
+ cwd: ctx.cwd,
34
+ // Remote transcript — no local file. Empty file_path is the sentinel the DB
35
+ // stale-filter treats as "always live" (see module doc).
36
+ filePath: '',
37
+ topic: ctx.prompt.split('\n')[0]?.slice(0, 120) || undefined,
38
+ label: `[host/${task.host}]`,
39
+ };
40
+ }
41
+ /**
42
+ * Register (or refresh) a host-dispatched run in the local session index. No-op
43
+ * when the task carries no session id. Best-effort: a failed write must never
44
+ * break the dispatch itself, which has already been launched on the host.
45
+ */
46
+ export function registerHostSession(task, ctx) {
47
+ const meta = hostSessionMeta(task, ctx);
48
+ if (!meta)
49
+ return;
50
+ try {
51
+ upsertSession(meta, '');
52
+ }
53
+ catch {
54
+ /* index write is best-effort; the run is already live on the host */
55
+ }
56
+ }
@@ -15,6 +15,13 @@ export interface HostTask {
15
15
  agent: string;
16
16
  prompt: string;
17
17
  pid?: number;
18
+ /**
19
+ * The agent session id the remote run was launched with (Claude only — the
20
+ * only agent that accepts `--session-id` to force a NEW session's id). Lets
21
+ * `agents sessions`/resume-by-id map a discovered session back to the host it
22
+ * lives on. Absent for agents that don't take an explicit session id.
23
+ */
24
+ sessionId?: string;
18
25
  /** Remote paths (under the host's ~/.agents/.cache/hosts/). */
19
26
  remoteLog: string;
20
27
  remoteExit: string;
@@ -29,4 +36,19 @@ export declare function localLogPath(id: string): string;
29
36
  export declare function saveTask(task: HostTask): void;
30
37
  export declare function loadTask(id: string): HostTask | null;
31
38
  export declare function updateTask(id: string, patch: Partial<HostTask>): HostTask | null;
39
+ /**
40
+ * The record patch for a run that has finished with `code`. The single authority
41
+ * for the exit-code → status mapping, so the dispatch, reconcile, and log-follow
42
+ * paths can never disagree. A genuine remote exit code is never -1 (that sentinel
43
+ * means "follow window closed while the run continues"), so callers must resolve
44
+ * -1 as still-running and never pass it here.
45
+ */
46
+ export declare function terminalPatch(code: number): Partial<HostTask>;
32
47
  export declare function listTasks(): HostTask[];
48
+ /**
49
+ * Find the host task that launched a given agent session id, so a resume-by-id
50
+ * can re-dispatch to the host the session actually lives on. Newest task wins
51
+ * (listTasks is createdAt-desc) — a session id should be unique, but a re-run
52
+ * with the same forced id resolves to the most recent dispatch.
53
+ */
54
+ export declare function findTaskBySessionId(sessionId: string): HostTask | null;
@@ -40,6 +40,20 @@ export function updateTask(id, patch) {
40
40
  saveTask(next);
41
41
  return next;
42
42
  }
43
+ /**
44
+ * The record patch for a run that has finished with `code`. The single authority
45
+ * for the exit-code → status mapping, so the dispatch, reconcile, and log-follow
46
+ * paths can never disagree. A genuine remote exit code is never -1 (that sentinel
47
+ * means "follow window closed while the run continues"), so callers must resolve
48
+ * -1 as still-running and never pass it here.
49
+ */
50
+ export function terminalPatch(code) {
51
+ return {
52
+ status: code === 0 ? 'completed' : 'failed',
53
+ exitCode: code,
54
+ finishedAt: new Date().toISOString(),
55
+ };
56
+ }
43
57
  export function listTasks() {
44
58
  let files;
45
59
  try {
@@ -56,3 +70,18 @@ export function listTasks() {
56
70
  }
57
71
  return tasks.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
58
72
  }
73
+ /**
74
+ * Find the host task that launched a given agent session id, so a resume-by-id
75
+ * can re-dispatch to the host the session actually lives on. Newest task wins
76
+ * (listTasks is createdAt-desc) — a session id should be unique, but a re-run
77
+ * with the same forced id resolves to the most recent dispatch.
78
+ */
79
+ export function findTaskBySessionId(sessionId) {
80
+ if (!sessionId)
81
+ return null;
82
+ for (const task of listTasks()) {
83
+ if (task.sessionId === sessionId)
84
+ return task;
85
+ }
86
+ return null;
87
+ }