@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
package/dist/lib/git.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import simpleGit from 'simple-git';
9
9
  import * as fs from 'fs';
10
+ import * as os from 'os';
10
11
  import * as path from 'path';
11
12
  import { IS_WINDOWS, isWindowsAbsolutePath } from './platform/index.js';
12
13
  import { getPackageLocalPath } from './state.js';
@@ -494,6 +495,15 @@ export async function hasLocalChanges(dir) {
494
495
  return false;
495
496
  }
496
497
  }
498
+ /**
499
+ * Render an absolute path in ~-relative form with forward slashes, matching the
500
+ * way the rest of the CLI prints home-anchored paths (e.g. `~/.agents/.system`).
501
+ */
502
+ export function displayHomePath(dir) {
503
+ const home = os.homedir();
504
+ const rel = dir.startsWith(home) ? '~' + dir.slice(home.length) : dir;
505
+ return rel.replace(/\\/g, '/');
506
+ }
497
507
  /**
498
508
  * Pull changes in an existing repo.
499
509
  * Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
@@ -506,7 +516,7 @@ export async function pullRepo(dir) {
506
516
  return {
507
517
  success: false,
508
518
  commit: '',
509
- error: 'Working tree has uncommitted changes. Commit or discard them before pulling.\n\n cd ~/.agents && git status',
519
+ error: `Working tree has uncommitted changes. Commit or discard them before pulling.\n\n cd ${displayHomePath(dir)} && git status`,
510
520
  };
511
521
  }
512
522
  await git.fetch();
@@ -522,6 +532,51 @@ export async function pullRepo(dir) {
522
532
  return { success: false, commit: '', error: err.message };
523
533
  }
524
534
  }
535
+ /**
536
+ * Rebase a repo onto its remote, optionally pushing local commits back up.
537
+ *
538
+ * The one-repo counterpart to `pullRepo` used by `agents sync <repo>`:
539
+ * 1. Refuse if the working tree is dirty (commit or discard first).
540
+ * 2. `git fetch origin` then `git pull --rebase origin <branch>` — rebase, not
541
+ * merge, so a local commit lands cleanly on top of upstream with no merge
542
+ * bubble.
543
+ * 3. When `push` is set, `git push origin <branch>` to send local commits up.
544
+ *
545
+ * The branch is read from the repo's current HEAD (falls back to `main`) rather
546
+ * than hardcoded. System repos pass `push: false` — they are pull-only mirrors
547
+ * of the npm-shipped upstream.
548
+ */
549
+ export async function syncRepoGit(dir, opts) {
550
+ try {
551
+ if (!isGitRepo(dir)) {
552
+ return { success: false, commit: '', pushed: false, error: `Not a git repo: ${dir}` };
553
+ }
554
+ const git = simpleGit(dir);
555
+ const status = await git.status();
556
+ if (!status.isClean()) {
557
+ return {
558
+ success: false,
559
+ commit: '',
560
+ pushed: false,
561
+ error: `Working tree has uncommitted changes. Commit or discard them first.\n\n cd ${dir} && git status`,
562
+ };
563
+ }
564
+ const branch = status.current || 'main';
565
+ await git.fetch('origin');
566
+ await git.pull('origin', branch, { '--rebase': 'true' });
567
+ installGithooksSymlinks(dir);
568
+ let pushed = false;
569
+ if (opts.push) {
570
+ await git.push('origin', branch);
571
+ pushed = true;
572
+ }
573
+ const log = await git.log({ maxCount: 1 });
574
+ return { success: true, commit: log.latest?.hash.slice(0, 8) || 'unknown', pushed };
575
+ }
576
+ catch (err) {
577
+ return { success: false, commit: '', pushed: false, error: err.message };
578
+ }
579
+ }
525
580
  /** Compute the sync status of a git repo, optionally scoped to a subdirectory. */
526
581
  export async function getGitSyncStatus(dir, subdir) {
527
582
  if (!isGitRepo(dir)) {
@@ -7,6 +7,12 @@ import type { HookCache, HookCacheConfig } from '../types.js';
7
7
  export declare function parseCacheConfig(raw: HookCache | undefined): HookCacheConfig | null;
8
8
  /** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
9
9
  export declare function parseDuration(d: number | string | undefined): number | null;
10
+ /**
11
+ * Reject hook names that could escape the shims directory when interpolated
12
+ * into a filename. Mirrors the containment gate on hook script resolution in
13
+ * hooks.ts (`resolveContainedHookPath`).
14
+ */
15
+ export declare function isValidHookShimName(name: string): boolean;
10
16
  /** Absolute path of the generated shim for a hook name. */
11
17
  export declare function getHookShimPath(name: string): string;
12
18
  /**
@@ -70,9 +70,35 @@ export function parseDuration(d) {
70
70
  return value * 60;
71
71
  return value;
72
72
  }
73
+ /**
74
+ * Reject hook names that could escape the shims directory when interpolated
75
+ * into a filename. Mirrors the containment gate on hook script resolution in
76
+ * hooks.ts (`resolveContainedHookPath`).
77
+ */
78
+ export function isValidHookShimName(name) {
79
+ return (!!name &&
80
+ name !== '.' &&
81
+ name !== '..' &&
82
+ !name.startsWith('-') &&
83
+ !/[\/\\\x00]/.test(name) &&
84
+ name.length <= 255);
85
+ }
86
+ /** Resolve shimsDir + `${name}.sh` and assert the result stays inside shimsDir. */
87
+ function resolveContainedHookShimPath(shimsDir, name) {
88
+ if (!isValidHookShimName(name)) {
89
+ throw new Error(`Invalid hook shim name: ${name}`);
90
+ }
91
+ const resolvedRoot = path.resolve(shimsDir);
92
+ const candidate = path.join(shimsDir, `${name}.sh`);
93
+ const resolved = path.resolve(candidate);
94
+ if (!resolved.startsWith(resolvedRoot + path.sep)) {
95
+ throw new Error(`Invalid hook shim name: ${name}`);
96
+ }
97
+ return resolved;
98
+ }
73
99
  /** Absolute path of the generated shim for a hook name. */
74
100
  export function getHookShimPath(name) {
75
- return path.join(getHookShimsDir(), `${name}.sh`);
101
+ return resolveContainedHookShimPath(getHookShimsDir(), name);
76
102
  }
77
103
  /**
78
104
  * Generate (or refresh) the shim script for a hook. Idempotent — only writes
@@ -82,7 +108,7 @@ export function generateHookShim(args) {
82
108
  const shimsDir = args.paths?.shimsDir ?? getHookShimsDir();
83
109
  const cacheDir = args.paths?.cacheDir ?? getHookCacheDir();
84
110
  const logsDir = args.paths?.logsDir ?? getLogsDir();
85
- const shimPath = path.join(shimsDir, `${args.name}.sh`);
111
+ const shimPath = resolveContainedHookShimPath(shimsDir, args.name);
86
112
  const content = renderShim(args.name, args.scriptPath, args.cache, { cacheDir, logsDir });
87
113
  fs.mkdirSync(shimsDir, { recursive: true });
88
114
  let existing = null;
@@ -105,9 +131,10 @@ export function generateHookShim(args) {
105
131
  return shimPath;
106
132
  }
107
133
  /**
108
- * Render the bash shim. Bash 3.2-compatible (macOS default). Uses python3 for
109
- * monotonic-ish nanosecond timing already a hard dependency of other hooks
110
- * in this repo (04-capture-session-start-metadata.sh does the same).
134
+ * Render the bash shim. Bash 3.2-compatible (macOS default). Uses Python for
135
+ * hashing + monotonic-ish nanosecond timing + portable mtime, resolved at
136
+ * runtime (python3, then python) so a Windows Microsoft Store `python3` alias
137
+ * stub — which exits non-zero without running — doesn't silently break caching.
111
138
  */
112
139
  function renderShim(name, scriptPath, cache, paths) {
113
140
  const ttl = typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0);
@@ -133,12 +160,25 @@ KEY_MODE=${q(key)}
133
160
 
134
161
  mkdir -p "$CACHE_DIR" "$LOGS_DIR"
135
162
 
163
+ # Resolve a real Python. On Windows, bare python3 is often a Microsoft Store
164
+ # app-execution alias stub that prints to stderr and exits non-zero (0 bytes on
165
+ # stdout) -- command -v finds it but it cannot run, which silently empties the
166
+ # hash + mtime primitives below and makes EVERY call a cache miss (the hook
167
+ # re-runs every time). Probe by executing, not by lookup, and fall back to python.
168
+ PY=""
169
+ for _cand in python3 python; do
170
+ if command -v "$_cand" >/dev/null 2>&1 && "$_cand" -c 'import sys' >/dev/null 2>&1; then
171
+ PY="$_cand"; break
172
+ fi
173
+ done
174
+ [ -z "$PY" ] && PY=python3
175
+
136
176
  # Read stdin once (Claude/Codex/Gemini pass JSON on stdin to every hook).
137
177
  STDIN_PAYLOAD="$(cat || true)"
138
178
 
139
179
  # Portable sha1 — \`shasum\` is Perl, missing on minimal Linux images;
140
180
  # \`sha1sum\` is coreutils, missing on macOS. Truncate to 12 hex chars.
141
- sha1_12() { python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().encode()).hexdigest()[:12])'; }
181
+ sha1_12() { "$PY" -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().encode()).hexdigest()[:12])'; }
142
182
 
143
183
  # Derive cache key suffix from KEY_MODE. All untrusted inputs (cwd, session_id,
144
184
  # project path) are hashed before going into the filename so a malicious stdin
@@ -146,14 +186,14 @@ sha1_12() { python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().
146
186
  cache_suffix=""
147
187
  case "$KEY_MODE" in
148
188
  per-cwd)
149
- cwd_val="$(printf '%s' "$STDIN_PAYLOAD" | python3 -c 'import json,sys
189
+ cwd_val="$(printf '%s' "$STDIN_PAYLOAD" | "$PY" -c 'import json,sys
150
190
  try: print(json.load(sys.stdin).get("cwd","") or "")
151
191
  except Exception: pass' 2>/dev/null || true)"
152
192
  [ -z "$cwd_val" ] && cwd_val="$PWD"
153
193
  cache_suffix=".$(printf '%s' "$cwd_val" | sha1_12)"
154
194
  ;;
155
195
  per-session)
156
- sid_val="$(printf '%s' "$STDIN_PAYLOAD" | python3 -c 'import json,sys
196
+ sid_val="$(printf '%s' "$STDIN_PAYLOAD" | "$PY" -c 'import json,sys
157
197
  try: print(json.load(sys.stdin).get("session_id","") or "")
158
198
  except Exception: pass' 2>/dev/null || true)"
159
199
  # Hash + fall back to a sentinel so missing-session doesn't silently
@@ -173,7 +213,7 @@ esac
173
213
  CACHE_FILE="$CACHE_DIR/$HOOK_NAME$cache_suffix.out"
174
214
 
175
215
  # Monotonic-ish nanosecond timer (macOS \`date\` has no %N).
176
- now_ns() { python3 -c 'import time; print(int(time.time()*1e9))'; }
216
+ now_ns() { "$PY" -c 'import time; print(int(time.time()*1e9))'; }
177
217
  START_NS=$(now_ns)
178
218
 
179
219
  CACHE_STATUS=miss
@@ -181,10 +221,10 @@ CACHE_AGE=-1
181
221
  EXIT=0
182
222
 
183
223
  if [ -f "$CACHE_FILE" ]; then
184
- # python3 is already a hard dep (used for now_ns) and gives portable mtime
224
+ # $PY is already resolved (used for now_ns) and gives portable mtime
185
225
  # without the macOS-vs-Linux \`stat\` flag divergence (-f %m vs -c %Y) that
186
226
  # blew up under \`set -u\` when the wrong flag produced literal "%m".
187
- mtime=$(python3 -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$CACHE_FILE" 2>/dev/null)
227
+ mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$CACHE_FILE" 2>/dev/null)
188
228
  mtime=\${mtime:-0}
189
229
  now_s=$(date +%s)
190
230
  CACHE_AGE=$((now_s - mtime))
@@ -231,8 +271,10 @@ exit "$EXIT"
231
271
  * hook is renamed/deleted or has its `cache:` field removed.
232
272
  */
233
273
  export function removeHookShim(name, shimsDir) {
274
+ if (!isValidHookShimName(name))
275
+ return;
234
276
  const dir = shimsDir ?? getHookShimsDir();
235
- const shimPath = path.join(dir, `${name}.sh`);
277
+ const shimPath = resolveContainedHookShimPath(dir, name);
236
278
  if (fs.existsSync(shimPath)) {
237
279
  try {
238
280
  fs.unlinkSync(shimPath);
@@ -8,6 +8,19 @@
8
8
  * and syncing them across version switches.
9
9
  */
10
10
  export declare function resolveHookScriptPath(script: string): string | null;
11
+ /**
12
+ * Convert an absolute path under HOME to a portable ~/... form with forward
13
+ * slashes. Hook commands stored this way work on both macOS and Windows:
14
+ * absolute Windows paths break in bash because backslashes are stripped as
15
+ * escape characters, whereas ~/... paths expand correctly via the ~/.claude
16
+ * symlink/junction on both platforms.
17
+ *
18
+ * `home` and `sep` are injectable so the Windows behavior (backslash sep,
19
+ * drive-letter home) is unit-testable on a POSIX CI host — pass sep='\\' to
20
+ * simulate Windows. With the defaults this is byte-identical to reading
21
+ * os.homedir()/path.sep at the call site.
22
+ */
23
+ export declare function toPortableCommand(absPath: string, home?: string, sep?: string): string;
11
24
  import type { AgentId, InstalledHook, ManifestHook } from './types.js';
12
25
  export type HookEntry = {
13
26
  name: string;
@@ -165,3 +178,17 @@ export declare function registerHooksToSettings(agentId: AgentId, versionHome: s
165
178
  registered: string[];
166
179
  errors: string[];
167
180
  };
181
+ /**
182
+ * Prune every Claude-family (`settings.json`) hook entry whose command lives
183
+ * under a removed version's home
184
+ * (`~/.agents/.history/versions/<agent>/<removedVersion>/home/…`).
185
+ *
186
+ * `agents remove <agent>@<version>` soft-deletes the version's files but leaves
187
+ * the hook entries other version homes registered against it — dead hooks that
188
+ * error on every tool call ("No such file or directory") until the next sync.
189
+ * This clears them from a remaining version's settings immediately. Only the
190
+ * removed version's entries are touched; the current version's entries, system
191
+ * hooks, and the user's own custom hooks are left intact. Returns the number of
192
+ * entries removed.
193
+ */
194
+ export declare function pruneVersionHomeHookEntriesFromSettings(settingsPath: string, agent: AgentId, removedVersion: string): number;
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)) {
@@ -910,6 +964,12 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
910
964
  if (agentId === 'claude') {
911
965
  return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes);
912
966
  }
967
+ if (agentId === 'droid') {
968
+ // Droid's settings.json hooks schema is identical to Claude's (top-level
969
+ // `hooks` object → event → matcher-group array), so reuse the Claude
970
+ // registrar targeting `.factory/settings.json` (agentConfigDirName('droid')).
971
+ return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, agentConfigDirName('droid'));
972
+ }
913
973
  if (agentId === 'codex') {
914
974
  return registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes);
915
975
  }
@@ -953,10 +1013,10 @@ const ANTIGRAVITY_EVENT_MAP = {
953
1013
  const GEMINI_EVENT_MAP = {
954
1014
  UserPromptSubmit: 'BeforeAgent',
955
1015
  };
956
- function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes) {
1016
+ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, configDirName = '.claude') {
957
1017
  const registered = [];
958
1018
  const errors = [];
959
- const configDir = path.join(versionHome, '.claude');
1019
+ const configDir = path.join(versionHome, configDirName);
960
1020
  const settingsPath = path.join(configDir, 'settings.json');
961
1021
  let config = {};
962
1022
  if (fs.existsSync(settingsPath)) {
@@ -983,15 +1043,21 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
983
1043
  if (resolved)
984
1044
  currentManifestPaths.add(resolved);
985
1045
  }
986
- // Remove stale entries: any hook command under a managed root that isn't
987
- // 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).
988
1053
  for (const eventEntries of Object.values(hooks)) {
989
1054
  if (!Array.isArray(eventEntries))
990
1055
  continue;
991
1056
  for (const group of eventEntries) {
992
1057
  if (!group.hooks)
993
1058
  continue;
994
- 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));
995
1061
  }
996
1062
  }
997
1063
  // Remove empty matcher groups left after cleanup
@@ -1043,6 +1109,65 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
1043
1109
  }
1044
1110
  return { registered, errors };
1045
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
+ }
1046
1171
  function registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes) {
1047
1172
  const registered = [];
1048
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,7 +12,9 @@ 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 { saveTask, updateTask } from './tasks.js';
15
+ import { remoteShellFor } from './remote-cmd.js';
16
+ import { resolveRemoteOsSync } from './remote-os.js';
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
18
20
  // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
@@ -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',
@@ -61,12 +78,29 @@ async function launchDetached(host, target, opts) {
61
78
  echo: true,
62
79
  timeoutMs: opts.timeoutMs,
63
80
  });
64
- const finished = updateTask(id, {
65
- status: exitCode === 0 ? 'completed' : exitCode === -1 ? 'unknown' : 'failed',
66
- exitCode: exitCode === -1 ? undefined : exitCode,
67
- finishedAt: new Date().toISOString(),
68
- });
69
- return { task: finished ?? task, exitCode };
81
+ // -1 = the follow window closed while the run continues on the host. Leave the
82
+ // record 'running' (do NOT freeze it terminal) so a later `hosts ps`/`logs`
83
+ // reconcile against the remote `.exit` resolves the true final status.
84
+ const finished = exitCode === -1 ? task : (updateTask(id, terminalPatch(exitCode)) ?? task);
85
+ return { task: finished, exitCode };
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;
70
104
  }
71
105
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
72
106
  export async function dispatchToHost(host, opts) {
@@ -74,18 +108,16 @@ export async function dispatchToHost(host, opts) {
74
108
  const { warnings } = ensureHostReady(host, { agent: opts.agent });
75
109
  for (const w of warnings)
76
110
  process.stderr.write(`[hosts] warning: ${w}\n`);
77
- const forwardedArgs = ['run', opts.agent, opts.prompt, '--quiet'];
78
- if (opts.mode)
79
- forwardedArgs.push('--mode', opts.mode);
80
- if (opts.model)
81
- forwardedArgs.push('--model', opts.model);
82
111
  return launchDetached(host, target, {
83
- forwardedArgs,
112
+ forwardedArgs: buildRunForwardedArgs(opts),
84
113
  remoteCwd: opts.remoteCwd,
85
114
  follow: opts.follow,
86
115
  timeoutMs: opts.timeoutMs,
87
116
  agentLabel: opts.agent,
88
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,
89
121
  });
90
122
  }
91
123
  /**
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared host-task log viewer — the show-or-follow core behind both
3
+ * `agents hosts logs <id>` and the top-level `agents logs <id>`.
4
+ *
5
+ * A running task with follow re-enters the offset-tail (`followHostTask`);
6
+ * otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
7
+ * place so the two commands can never drift.
8
+ */
9
+ export interface HostLogResult {
10
+ /** False when no host task with this id exists (caller may fall through to sessions). */
11
+ found: boolean;
12
+ /** Process exit code to adopt when the task was shown/followed. */
13
+ exitCode?: number;
14
+ }
15
+ /** Show (or follow, when running) a dispatched host task's combined-stdout log. */
16
+ export declare function showHostTaskLog(id: string, follow: boolean): Promise<HostLogResult>;