@phnx-labs/agents-cli 1.20.35 → 1.20.37

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 (236) 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-sync.d.ts +3 -0
  34. package/dist/commands/sessions-sync.js +44 -4
  35. package/dist/commands/sessions.d.ts +78 -1
  36. package/dist/commands/sessions.js +506 -66
  37. package/dist/commands/setup.js +4 -2
  38. package/dist/commands/sync.d.ts +3 -1
  39. package/dist/commands/sync.js +156 -4
  40. package/dist/commands/teams.js +217 -0
  41. package/dist/commands/versions.js +2 -4
  42. package/dist/commands/watchdog.d.ts +18 -0
  43. package/dist/commands/watchdog.js +238 -0
  44. package/dist/index.js +25 -2
  45. package/dist/lib/audit/log.d.ts +92 -0
  46. package/dist/lib/audit/log.js +177 -0
  47. package/dist/lib/auto-pull.js +2 -1
  48. package/dist/lib/browser/chrome.d.ts +10 -0
  49. package/dist/lib/browser/chrome.js +18 -7
  50. package/dist/lib/browser/drivers/ssh.js +2 -1
  51. package/dist/lib/browser/har.d.ts +84 -0
  52. package/dist/lib/browser/har.js +77 -0
  53. package/dist/lib/browser/ipc.js +24 -3
  54. package/dist/lib/browser/profiles.d.ts +1 -1
  55. package/dist/lib/browser/profiles.js +8 -10
  56. package/dist/lib/browser/refs.d.ts +65 -0
  57. package/dist/lib/browser/refs.js +73 -1
  58. package/dist/lib/browser/runtime-state.js +1 -0
  59. package/dist/lib/browser/service.d.ts +38 -2
  60. package/dist/lib/browser/service.js +112 -8
  61. package/dist/lib/browser/types.d.ts +14 -1
  62. package/dist/lib/budget/live-cloud.d.ts +42 -0
  63. package/dist/lib/budget/live-cloud.js +79 -0
  64. package/dist/lib/budget/live-team.d.ts +31 -0
  65. package/dist/lib/budget/live-team.js +115 -0
  66. package/dist/lib/cloud/codex.js +4 -0
  67. package/dist/lib/cloud/rush.d.ts +12 -1
  68. package/dist/lib/cloud/rush.js +13 -3
  69. package/dist/lib/cloud/types.d.ts +9 -0
  70. package/dist/lib/computer/dispatch.d.ts +8 -0
  71. package/dist/lib/computer/dispatch.js +125 -0
  72. package/dist/lib/computer/loop.d.ts +62 -0
  73. package/dist/lib/computer/loop.js +98 -0
  74. package/dist/lib/computer/model.d.ts +44 -0
  75. package/dist/lib/computer/model.js +157 -0
  76. package/dist/lib/concurrency.d.ts +19 -0
  77. package/dist/lib/concurrency.js +33 -0
  78. package/dist/lib/daemon.d.ts +57 -0
  79. package/dist/lib/daemon.js +196 -18
  80. package/dist/lib/devices/registry.d.ts +7 -0
  81. package/dist/lib/devices/registry.js +24 -0
  82. package/dist/lib/devices/resolve-target.d.ts +24 -0
  83. package/dist/lib/devices/resolve-target.js +80 -0
  84. package/dist/lib/devices/tailscale.js +1 -1
  85. package/dist/lib/drift.d.ts +52 -0
  86. package/dist/lib/drift.js +112 -0
  87. package/dist/lib/events.d.ts +1 -1
  88. package/dist/lib/events.js +31 -13
  89. package/dist/lib/exec.d.ts +17 -0
  90. package/dist/lib/exec.js +79 -13
  91. package/dist/lib/git.d.ts +27 -0
  92. package/dist/lib/git.js +56 -1
  93. package/dist/lib/hooks/cache.d.ts +6 -0
  94. package/dist/lib/hooks/cache.js +54 -12
  95. package/dist/lib/hooks.d.ts +27 -0
  96. package/dist/lib/hooks.js +127 -8
  97. package/dist/lib/hosts/dispatch.d.ts +15 -0
  98. package/dist/lib/hosts/dispatch.js +39 -6
  99. package/dist/lib/hosts/logs.js +30 -1
  100. package/dist/lib/hosts/option.js +1 -1
  101. package/dist/lib/hosts/passthrough.js +3 -1
  102. package/dist/lib/hosts/ready.d.ts +29 -6
  103. package/dist/lib/hosts/ready.js +66 -15
  104. package/dist/lib/hosts/registry.d.ts +19 -2
  105. package/dist/lib/hosts/registry.js +58 -2
  106. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  107. package/dist/lib/hosts/remote-cmd.js +70 -1
  108. package/dist/lib/hosts/remote-os.d.ts +17 -0
  109. package/dist/lib/hosts/remote-os.js +30 -0
  110. package/dist/lib/hosts/session-index.d.ts +34 -0
  111. package/dist/lib/hosts/session-index.js +56 -0
  112. package/dist/lib/hosts/tasks.d.ts +14 -0
  113. package/dist/lib/hosts/tasks.js +15 -0
  114. package/dist/lib/lock.d.ts +93 -0
  115. package/dist/lib/lock.js +207 -0
  116. package/dist/lib/loop.js +16 -1
  117. package/dist/lib/machine-id.d.ts +21 -0
  118. package/dist/lib/machine-id.js +26 -0
  119. package/dist/lib/mailbox-target.d.ts +36 -0
  120. package/dist/lib/mailbox-target.js +45 -0
  121. package/dist/lib/mailbox.d.ts +47 -0
  122. package/dist/lib/mailbox.js +194 -0
  123. package/dist/lib/mcp.d.ts +5 -0
  124. package/dist/lib/mcp.js +24 -8
  125. package/dist/lib/migrate.d.ts +19 -0
  126. package/dist/lib/migrate.js +134 -26
  127. package/dist/lib/overdue.js +3 -0
  128. package/dist/lib/picker.d.ts +2 -0
  129. package/dist/lib/picker.js +4 -1
  130. package/dist/lib/platform/exec.d.ts +46 -0
  131. package/dist/lib/platform/exec.js +74 -0
  132. package/dist/lib/platform/process.d.ts +31 -0
  133. package/dist/lib/platform/process.js +34 -1
  134. package/dist/lib/platform/winpath.js +2 -0
  135. package/dist/lib/plugins.js +16 -6
  136. package/dist/lib/profiles.d.ts +25 -0
  137. package/dist/lib/profiles.js +22 -6
  138. package/dist/lib/pty-client.js +2 -1
  139. package/dist/lib/rotate.d.ts +61 -0
  140. package/dist/lib/rotate.js +52 -0
  141. package/dist/lib/routines.d.ts +40 -2
  142. package/dist/lib/routines.js +66 -8
  143. package/dist/lib/runner.d.ts +11 -2
  144. package/dist/lib/runner.js +49 -7
  145. package/dist/lib/scheduler.js +6 -1
  146. package/dist/lib/secrets/bundles.d.ts +60 -4
  147. package/dist/lib/secrets/bundles.js +131 -12
  148. package/dist/lib/secrets/filestore.d.ts +3 -0
  149. package/dist/lib/secrets/filestore.js +42 -16
  150. package/dist/lib/secrets/index.d.ts +43 -2
  151. package/dist/lib/secrets/index.js +102 -3
  152. package/dist/lib/secrets/mcp.d.ts +93 -0
  153. package/dist/lib/secrets/mcp.js +205 -0
  154. package/dist/lib/secrets/remote.js +12 -5
  155. package/dist/lib/secrets/sync.js +83 -4
  156. package/dist/lib/secrets/windows.js +14 -3
  157. package/dist/lib/serve/data.d.ts +81 -0
  158. package/dist/lib/serve/data.js +91 -0
  159. package/dist/lib/serve/page.d.ts +7 -0
  160. package/dist/lib/serve/page.js +140 -0
  161. package/dist/lib/serve/server.d.ts +46 -0
  162. package/dist/lib/serve/server.js +115 -0
  163. package/dist/lib/session/active.d.ts +73 -0
  164. package/dist/lib/session/active.js +199 -22
  165. package/dist/lib/session/db.d.ts +2 -1
  166. package/dist/lib/session/db.js +41 -5
  167. package/dist/lib/session/discover.d.ts +39 -0
  168. package/dist/lib/session/discover.js +127 -29
  169. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  170. package/dist/lib/session/ghostty-tabs.js +126 -0
  171. package/dist/lib/session/inject.d.ts +18 -0
  172. package/dist/lib/session/inject.js +21 -0
  173. package/dist/lib/session/parse.js +23 -20
  174. package/dist/lib/session/pid-registry.d.ts +1 -0
  175. package/dist/lib/session/pid-registry.js +24 -0
  176. package/dist/lib/session/provenance.d.ts +14 -2
  177. package/dist/lib/session/provenance.js +39 -8
  178. package/dist/lib/session/relative-time.js +6 -2
  179. package/dist/lib/session/remote-active.js +19 -17
  180. package/dist/lib/session/remote-list.d.ts +51 -0
  181. package/dist/lib/session/remote-list.js +205 -0
  182. package/dist/lib/session/remote.d.ts +7 -1
  183. package/dist/lib/session/remote.js +20 -4
  184. package/dist/lib/session/sync/config.d.ts +14 -15
  185. package/dist/lib/session/sync/config.js +60 -20
  186. package/dist/lib/session/types.d.ts +23 -0
  187. package/dist/lib/shims.d.ts +36 -6
  188. package/dist/lib/shims.js +91 -29
  189. package/dist/lib/ssh-exec.js +2 -0
  190. package/dist/lib/ssh-tunnel.js +2 -1
  191. package/dist/lib/startup/command-registry.d.ts +6 -0
  192. package/dist/lib/startup/command-registry.js +13 -1
  193. package/dist/lib/state.d.ts +13 -0
  194. package/dist/lib/state.js +103 -9
  195. package/dist/lib/sync-umbrella.d.ts +14 -7
  196. package/dist/lib/sync-umbrella.js +21 -13
  197. package/dist/lib/teams/forEach.d.ts +110 -0
  198. package/dist/lib/teams/forEach.js +186 -0
  199. package/dist/lib/teams/index.d.ts +1 -0
  200. package/dist/lib/teams/index.js +1 -0
  201. package/dist/lib/teams/pr-watch.d.ts +226 -0
  202. package/dist/lib/teams/pr-watch.js +371 -0
  203. package/dist/lib/teams/supervisor.d.ts +14 -1
  204. package/dist/lib/teams/supervisor.js +19 -0
  205. package/dist/lib/teams/worktree.d.ts +9 -0
  206. package/dist/lib/teams/worktree.js +32 -0
  207. package/dist/lib/terminal/backends/index.d.ts +2 -1
  208. package/dist/lib/terminal/backends/index.js +3 -1
  209. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  210. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  211. package/dist/lib/terminal/index.d.ts +4 -1
  212. package/dist/lib/terminal/index.js +4 -1
  213. package/dist/lib/terminal/inject.d.ts +204 -0
  214. package/dist/lib/terminal/inject.js +247 -0
  215. package/dist/lib/terminal/resolve.d.ts +64 -0
  216. package/dist/lib/terminal/resolve.js +90 -0
  217. package/dist/lib/terminal/types.d.ts +1 -1
  218. package/dist/lib/tmux/session.d.ts +10 -0
  219. package/dist/lib/tmux/session.js +31 -0
  220. package/dist/lib/triggers/webhook.d.ts +85 -0
  221. package/dist/lib/triggers/webhook.js +141 -0
  222. package/dist/lib/versions.d.ts +23 -0
  223. package/dist/lib/versions.js +119 -13
  224. package/dist/lib/watchdog/index.d.ts +3 -0
  225. package/dist/lib/watchdog/index.js +5 -0
  226. package/dist/lib/watchdog/read.d.ts +35 -0
  227. package/dist/lib/watchdog/read.js +149 -0
  228. package/dist/lib/watchdog/runner.d.ts +127 -0
  229. package/dist/lib/watchdog/runner.js +322 -0
  230. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  231. package/dist/lib/watchdog/watchdog.js +166 -0
  232. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  233. package/dist/lib/watchdog/watchdogTail.js +154 -0
  234. package/dist/lib/workflows.d.ts +166 -0
  235. package/dist/lib/workflows.js +193 -0
  236. package/package.json +5 -4
@@ -46,6 +46,12 @@ export interface SessionMeta {
46
46
  shortId: string;
47
47
  agent: SessionAgentId;
48
48
  timestamp: string;
49
+ /**
50
+ * Last-activity time (ISO): the last message timestamp when a parser computed
51
+ * it, else file mtime, else `timestamp`. This is the recency signal the
52
+ * listing sorts and labels by; `timestamp` stays the creation time.
53
+ */
54
+ lastActivity?: string;
49
55
  project?: string;
50
56
  cwd?: string;
51
57
  filePath: string;
@@ -78,6 +84,23 @@ export interface SessionMeta {
78
84
  * `entrypoint` field ('sdk-cli' for team spawns, 'cli' for real sessions).
79
85
  */
80
86
  isTeamOrigin?: boolean;
87
+ /**
88
+ * The machine (normalized hostname) this session's transcript originated on:
89
+ * the local machine for live-home sessions, or the origin machine parsed from
90
+ * the cross-machine mirror path (backups/<agent>/<machine>/…, see
91
+ * sync/agents.ts). Populated by discoverSessions for the listing/picker;
92
+ * undefined for sessions obtained outside that path.
93
+ */
94
+ machine?: string;
95
+ /**
96
+ * True only for rows pulled from another machine over the live cross-machine
97
+ * fan-out (`remote-list.ts`) — their transcript is on that peer's disk, so
98
+ * reading/resuming has to hop back over SSH. Distinct from `machine`, which is
99
+ * also set on locally-readable synced mirrors: a mirror is machine-tagged but
100
+ * its `filePath` is a local path, so it must NOT be treated as remote. Set by
101
+ * `parseRemoteList`; transient (never persisted, stripped from --json).
102
+ */
103
+ _remote?: boolean;
81
104
  /** Terms that matched the current search query */
82
105
  _matchedTerms?: string[];
83
106
  /** BM25 relevance score from the most recent content-index search */
@@ -77,7 +77,7 @@ export interface ConflictInfo {
77
77
  * top-level entry add/remove — deep edits to plugin contents won't
78
78
  * trigger auto-resync, run `agents sync` for that.
79
79
  */
80
- export declare const SHIM_SCHEMA_VERSION = 21;
80
+ export declare const SHIM_SCHEMA_VERSION = 22;
81
81
  /**
82
82
  * Generate the full bash shim script for the given agent. The returned string
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
@@ -134,8 +134,19 @@ export declare function removeShim(agent: AgentId): boolean;
134
134
  * generic node_modules/.bin branch.
135
135
  * v10 — guard grok's `command -v grok` fallback against resolving to our own
136
136
  * shims dir (same infinite re-exec loop), mirroring droid.
137
- */
138
- export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 10;
137
+ * v11 — export DISABLE_AUTOUPDATER=1 for claude aliases so a pinned per-version
138
+ * install can't self-mutate via Claude Code's background auto-updater.
139
+ * Explicit user value wins.
140
+ * v12 — Windows: stop writing the extensionless bash alias next to the `.cmd`
141
+ * (and delete a lingering one). The version suffix contains dots, so
142
+ * cmd.exe and PowerShell treat `claude@2.1.201` as a complete filename
143
+ * with extension `.201`, exact-match the bash script AHEAD of PATHEXT
144
+ * probing, and ShellExecute it to the `.sh` editor association — the
145
+ * editor opens the script and the agent never launches. The `.cmd` is
146
+ * now the only Windows artifact and carries this alias marker so
147
+ * staleness checks read it directly.
148
+ */
149
+ export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 12;
139
150
  /**
140
151
  * Generate a versioned alias script that directly execs a specific version.
141
152
  * e.g., claude@2.0.65 -> directly runs that version's binary
@@ -157,20 +168,39 @@ export declare function isVersionedAliasCurrent(agent: AgentId, version: string)
157
168
  */
158
169
  export declare function ensureVersionedAliasCurrent(agent: AgentId, version: string): 'created' | 'updated' | 'current';
159
170
  /**
160
- * Get the filesystem path for a versioned alias script.
171
+ * Get the filesystem path for a versioned alias script — the logical
172
+ * (extensionless) launch name. On Windows this is not a real file (see
173
+ * versionedAliasOnDiskFile); stat/read checks must use the on-disk path.
161
174
  */
162
175
  export declare function getVersionedAliasPath(agent: AgentId, version: string): string;
176
+ /**
177
+ * The file createVersionedAlias actually materializes for a platform:
178
+ * `<cmd>@<version>.cmd` on Windows, the bare bash script on POSIX. Pure —
179
+ * testable on any host. Mirrors onDiskShimFile for the main shim.
180
+ */
181
+ export declare function versionedAliasOnDiskFile(cliCommand: string, version: string, platform: NodeJS.Platform): string;
163
182
  /**
164
183
  * Create a versioned alias for a specific agent version.
165
184
  * e.g., claude@2.0.65
185
+ *
186
+ * Same platform split as createShim (shimTargetsFor): POSIX writes the
187
+ * extensionless bash script; Windows writes ONLY the `.cmd`. Unlike the main
188
+ * shim — where the bash file was merely dead weight — a bash alias next to the
189
+ * versioned `.cmd` is actively harmful: the dotted version suffix makes
190
+ * cmd.exe/PowerShell treat `claude@2.1.201` as a complete filename (extension
191
+ * `.201`), so the exact match wins over PATHEXT probing and the shell
192
+ * ShellExecutes the bash script to the `.sh` editor association — the editor
193
+ * opens, the agent never launches. Any legacy bash alias is deleted here.
166
194
  */
167
195
  export declare function createVersionedAlias(agent: AgentId, version: string): string;
168
196
  /**
169
- * Remove a versioned alias for a specific agent version.
197
+ * Remove a versioned alias for a specific agent version. Removes whichever
198
+ * companions exist — the extensionless script (POSIX, or a legacy Windows
199
+ * install that wrote it) AND the `.cmd` (Windows) — mirroring removeShim.
170
200
  */
171
201
  export declare function removeVersionedAlias(agent: AgentId, version: string): boolean;
172
202
  /**
173
- * Check if a versioned alias exists.
203
+ * Check if a versioned alias exists (the on-disk artifact for this platform).
174
204
  */
175
205
  export declare function versionedAliasExists(agent: AgentId, version: string): boolean;
176
206
  /**
package/dist/lib/shims.js CHANGED
@@ -208,7 +208,10 @@ async function promptConflictStrategy(conflictInfos) {
208
208
  // re-exec-looped through `command -v kimi` (the dispatcher itself).
209
209
  // v21 — guard grok's `command -v grok` fallback against resolving to our own
210
210
  // shims dir (same infinite re-exec loop), mirroring droid.
211
- export const SHIM_SCHEMA_VERSION = 21;
211
+ // v22 export DISABLE_AUTOUPDATER=1 for claude shims so a pinned per-version
212
+ // install can't self-mutate: Claude Code's background auto-updater would
213
+ // otherwise rewrite the pinned binary in place. Explicit user value wins.
214
+ export const SHIM_SCHEMA_VERSION = 22;
212
215
  /** Internal marker string used to embed the schema version in shim scripts. */
213
216
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
214
217
  function shellQuote(value) {
@@ -236,6 +239,10 @@ export function generateShimScript(agent) {
236
239
  # selected version's config directory so switching versions also switches the
237
240
  # live Claude account.
238
241
  export CLAUDE_CONFIG_DIR="$VERSION_DIR/home/${configDirName}"
242
+ # Managed installs are pinned in a per-version dir; Claude Code's background
243
+ # auto-updater would rewrite the pinned binary in place. Disable it so a pin
244
+ # stays a pin. An explicit user value always wins.
245
+ export DISABLE_AUTOUPDATER="\${DISABLE_AUTOUPDATER:-1}"
239
246
  # On Linux sandboxes (no keychain), fall back to a per-version token file.
240
247
  # The env var always wins if already set; no-op on macOS.
241
248
  if [ "\$(uname -s)" = "Linux" ] && [ -z "\${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -f "\$CLAUDE_CONFIG_DIR/.oauth_token" ]; then
@@ -580,12 +587,18 @@ export function createShim(agent) {
580
587
  * `cliCommand@version` for a versioned alias. node + the dist entrypoint are
581
588
  * resolved at generation time so the launcher does not depend on `agents`
582
589
  * already being on PATH.
590
+ *
591
+ * `extraMarkerLines` lets callers stamp additional schema markers into the
592
+ * header — versioned aliases embed their own alias-schema marker so
593
+ * readVersionedAliasSchemaVersion can stat the `.cmd` (the only Windows
594
+ * artifact) the same way it reads the bash script on POSIX.
583
595
  */
584
- function writeWindowsCmdShim(cmdPath, spec) {
596
+ function writeWindowsCmdShim(cmdPath, spec, extraMarkerLines = []) {
585
597
  const indexJs = getAgentsBinForGeneratedShim();
586
598
  const content = `@echo off\r\n` +
587
599
  `rem Auto-generated by agents-cli - do not edit\r\n` +
588
600
  `rem ${SHIM_VERSION_MARKER} ${SHIM_SCHEMA_VERSION}\r\n` +
601
+ extraMarkerLines.map((line) => `rem ${line}\r\n`).join('') +
589
602
  `node "${indexJs}" __shim ${spec} %*\r\n`;
590
603
  fs.writeFileSync(cmdPath, content);
591
604
  }
@@ -640,8 +653,19 @@ export function removeShim(agent) {
640
653
  * generic node_modules/.bin branch.
641
654
  * v10 — guard grok's `command -v grok` fallback against resolving to our own
642
655
  * shims dir (same infinite re-exec loop), mirroring droid.
656
+ * v11 — export DISABLE_AUTOUPDATER=1 for claude aliases so a pinned per-version
657
+ * install can't self-mutate via Claude Code's background auto-updater.
658
+ * Explicit user value wins.
659
+ * v12 — Windows: stop writing the extensionless bash alias next to the `.cmd`
660
+ * (and delete a lingering one). The version suffix contains dots, so
661
+ * cmd.exe and PowerShell treat `claude@2.1.201` as a complete filename
662
+ * with extension `.201`, exact-match the bash script AHEAD of PATHEXT
663
+ * probing, and ShellExecute it to the `.sh` editor association — the
664
+ * editor opens the script and the agent never launches. The `.cmd` is
665
+ * now the only Windows artifact and carries this alias marker so
666
+ * staleness checks read it directly.
643
667
  */
644
- export const VERSIONED_ALIAS_SCHEMA_VERSION = 10;
668
+ export const VERSIONED_ALIAS_SCHEMA_VERSION = 12;
645
669
  /** Internal marker string used to embed the schema version in versioned alias scripts. */
646
670
  const VERSIONED_ALIAS_VERSION_MARKER = 'agents-versioned-alias-version:';
647
671
  // The version string is interpolated into a generated bash script and into
@@ -669,6 +693,10 @@ export function generateVersionedAliasScript(agent, version) {
669
693
  # Claude stores OAuth credentials in the macOS keychain. Scope them to this
670
694
  # version's config directory so direct aliases also switch the live account.
671
695
  export CLAUDE_CONFIG_DIR="$HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
696
+ # Managed installs are pinned in a per-version dir; Claude Code's background
697
+ # auto-updater would rewrite the pinned binary in place. Disable it so a pin
698
+ # stays a pin. An explicit user value always wins.
699
+ export DISABLE_AUTOUPDATER="\${DISABLE_AUTOUPDATER:-1}"
672
700
  `
673
701
  : agent === 'codex'
674
702
  ? `
@@ -765,7 +793,7 @@ exec "$BINARY"${launchArgs} "$@"
765
793
  * alias doesn't exist or is a pre-v2 alias (no marker — treated as stale).
766
794
  */
767
795
  export function readVersionedAliasSchemaVersion(agent, version) {
768
- const aliasPath = getVersionedAliasPath(agent, version);
796
+ const aliasPath = versionedAliasOnDiskPath(agent, version);
769
797
  if (!fs.existsSync(aliasPath))
770
798
  return null;
771
799
  try {
@@ -792,11 +820,19 @@ export function isVersionedAliasCurrent(agent, version) {
792
820
  * was upgraded.
793
821
  */
794
822
  export function ensureVersionedAliasCurrent(agent, version) {
795
- const aliasPath = getVersionedAliasPath(agent, version);
796
- if (!fs.existsSync(aliasPath)) {
823
+ if (!fs.existsSync(versionedAliasOnDiskPath(agent, version))) {
797
824
  createVersionedAlias(agent, version);
798
825
  return 'created';
799
826
  }
827
+ // A lingering extensionless bash alias on Windows shadows the `.cmd` in both
828
+ // cmd.exe and PowerShell (the dotted version reads as a file extension, and
829
+ // an exact filename match beats PATHEXT probing), ShellExecuting the bash
830
+ // script to the `.sh` editor association instead of launching the agent.
831
+ // Regenerate regardless of the `.cmd`'s stamp so the shadow gets deleted.
832
+ if (shimTargetsFor(process.platform).cmd && fs.existsSync(getVersionedAliasPath(agent, version))) {
833
+ createVersionedAlias(agent, version);
834
+ return 'updated';
835
+ }
800
836
  // Upgrade-only (newest-wins), same rationale as ensureShimCurrent: never
801
837
  // downgrade an alias stamped by a newer install sharing the shims dir.
802
838
  const onDisk = readVersionedAliasSchemaVersion(agent, version);
@@ -807,14 +843,38 @@ export function ensureVersionedAliasCurrent(agent, version) {
807
843
  return 'current';
808
844
  }
809
845
  /**
810
- * Get the filesystem path for a versioned alias script.
846
+ * Get the filesystem path for a versioned alias script — the logical
847
+ * (extensionless) launch name. On Windows this is not a real file (see
848
+ * versionedAliasOnDiskFile); stat/read checks must use the on-disk path.
811
849
  */
812
850
  export function getVersionedAliasPath(agent, version) {
813
851
  return path.join(getShimsDir(), `${AGENTS[agent].cliCommand}@${version}`);
814
852
  }
853
+ /**
854
+ * The file createVersionedAlias actually materializes for a platform:
855
+ * `<cmd>@<version>.cmd` on Windows, the bare bash script on POSIX. Pure —
856
+ * testable on any host. Mirrors onDiskShimFile for the main shim.
857
+ */
858
+ export function versionedAliasOnDiskFile(cliCommand, version, platform) {
859
+ const name = `${cliCommand}@${version}`;
860
+ return shimTargetsFor(platform).cmd ? `${name}.cmd` : name;
861
+ }
862
+ /** The on-disk versioned-alias path for the current platform. */
863
+ function versionedAliasOnDiskPath(agent, version) {
864
+ return path.join(getShimsDir(), versionedAliasOnDiskFile(AGENTS[agent].cliCommand, version, process.platform));
865
+ }
815
866
  /**
816
867
  * Create a versioned alias for a specific agent version.
817
868
  * e.g., claude@2.0.65
869
+ *
870
+ * Same platform split as createShim (shimTargetsFor): POSIX writes the
871
+ * extensionless bash script; Windows writes ONLY the `.cmd`. Unlike the main
872
+ * shim — where the bash file was merely dead weight — a bash alias next to the
873
+ * versioned `.cmd` is actively harmful: the dotted version suffix makes
874
+ * cmd.exe/PowerShell treat `claude@2.1.201` as a complete filename (extension
875
+ * `.201`), so the exact match wins over PATHEXT probing and the shell
876
+ * ShellExecutes the bash script to the `.sh` editor association — the editor
877
+ * opens, the agent never launches. Any legacy bash alias is deleted here.
818
878
  */
819
879
  export function createVersionedAlias(agent, version) {
820
880
  assertSafeVersion(version);
@@ -822,40 +882,42 @@ export function createVersionedAlias(agent, version) {
822
882
  const shimsDir = getShimsDir();
823
883
  const agentConfig = AGENTS[agent];
824
884
  const aliasPath = path.join(shimsDir, `${agentConfig.cliCommand}@${version}`);
825
- const script = generateVersionedAliasScript(agent, version);
826
- fs.writeFileSync(aliasPath, script, { mode: 0o755 });
827
- if (IS_WINDOWS) {
828
- writeWindowsCmdShim(aliasPath + '.cmd', `${agentConfig.cliCommand}@${version}`);
885
+ const targets = shimTargetsFor(process.platform);
886
+ if (targets.bash) {
887
+ fs.writeFileSync(aliasPath, generateVersionedAliasScript(agent, version), { mode: 0o755 });
888
+ }
889
+ else {
890
+ try {
891
+ fs.unlinkSync(aliasPath);
892
+ }
893
+ catch { }
894
+ }
895
+ if (targets.cmd) {
896
+ writeWindowsCmdShim(aliasPath + '.cmd', `${agentConfig.cliCommand}@${version}`, [`${VERSIONED_ALIAS_VERSION_MARKER} ${VERSIONED_ALIAS_SCHEMA_VERSION}`]);
829
897
  }
830
898
  return aliasPath;
831
899
  }
832
900
  /**
833
- * Remove a versioned alias for a specific agent version.
901
+ * Remove a versioned alias for a specific agent version. Removes whichever
902
+ * companions exist — the extensionless script (POSIX, or a legacy Windows
903
+ * install that wrote it) AND the `.cmd` (Windows) — mirroring removeShim.
834
904
  */
835
905
  export function removeVersionedAlias(agent, version) {
836
- const shimsDir = getShimsDir();
837
- const agentConfig = AGENTS[agent];
838
- const aliasPath = path.join(shimsDir, `${agentConfig.cliCommand}@${version}`);
839
- if (fs.existsSync(aliasPath)) {
840
- fs.unlinkSync(aliasPath);
841
- if (IS_WINDOWS) {
842
- try {
843
- fs.unlinkSync(aliasPath + '.cmd');
844
- }
845
- catch { }
906
+ const aliasPath = getVersionedAliasPath(agent, version);
907
+ let removed = false;
908
+ for (const p of [aliasPath, aliasPath + '.cmd']) {
909
+ if (fs.existsSync(p)) {
910
+ fs.unlinkSync(p);
911
+ removed = true;
846
912
  }
847
- return true;
848
913
  }
849
- return false;
914
+ return removed;
850
915
  }
851
916
  /**
852
- * Check if a versioned alias exists.
917
+ * Check if a versioned alias exists (the on-disk artifact for this platform).
853
918
  */
854
919
  export function versionedAliasExists(agent, version) {
855
- const shimsDir = getShimsDir();
856
- const agentConfig = AGENTS[agent];
857
- const aliasPath = path.join(shimsDir, `${agentConfig.cliCommand}@${version}`);
858
- return fs.existsSync(aliasPath);
920
+ return fs.existsSync(versionedAliasOnDiskPath(agent, version));
859
921
  }
860
922
  /**
861
923
  * Get the path to the agent's config directory in HOME.
@@ -103,6 +103,7 @@ export function sshExec(target, remoteCmd, opts = {}) {
103
103
  encoding: 'utf-8',
104
104
  timeout: opts.timeoutMs,
105
105
  stdio: ['pipe', 'pipe', 'pipe'],
106
+ windowsHide: true,
106
107
  });
107
108
  const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
108
109
  return {
@@ -128,6 +129,7 @@ export function sshExecRaw(target, remoteCmd, opts = {}) {
128
129
  // No `encoding` → spawnSync returns Buffers.
129
130
  timeout: opts.timeoutMs,
130
131
  stdio: ['pipe', 'pipe', 'pipe'],
132
+ windowsHide: true,
131
133
  });
132
134
  const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
133
135
  return {
@@ -23,6 +23,7 @@ import * as path from 'path';
23
23
  import { fileURLToPath } from 'url';
24
24
  import { randomBytes } from 'crypto';
25
25
  import { sshExec, SSH_OPTS } from './ssh-exec.js';
26
+ import { backgroundSpawnOptions } from './platform/process.js';
26
27
  import { encodePowerShell } from './browser/drivers/ssh.js';
27
28
  import { getDevice } from './devices/registry.js';
28
29
  import { sshTargetFor } from './devices/connect.js';
@@ -56,7 +57,7 @@ export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
56
57
  const args = buildTunnelArgs(user, host, localPort, remotePort);
57
58
  const tunnel = spawn('ssh', args, {
58
59
  stdio: opts.detached ? 'ignore' : ['ignore', 'ignore', 'pipe'],
59
- detached: Boolean(opts.detached),
60
+ ...(opts.detached ? backgroundSpawnOptions() : { detached: false, windowsHide: true }),
60
61
  });
61
62
  let stderr = '';
62
63
  tunnel.stderr?.on('data', (data) => {
@@ -49,6 +49,7 @@ export declare const loadPrune: ModuleLoader;
49
49
  export declare const loadTrash: ModuleLoader;
50
50
  export declare const loadRestore: ModuleLoader;
51
51
  export declare const loadDoctor: ModuleLoader;
52
+ export declare const loadCheck: ModuleLoader;
52
53
  export declare const loadStatus: ModuleLoader;
53
54
  export declare const loadProfiles: ModuleLoader;
54
55
  export declare const loadSecrets: ModuleLoader;
@@ -57,6 +58,7 @@ export declare const loadHelper: ModuleLoader;
57
58
  export declare const loadMenubar: ModuleLoader;
58
59
  export declare const loadBeta: ModuleLoader;
59
60
  export declare const loadSync: ModuleLoader;
61
+ export declare const loadLock: ModuleLoader;
60
62
  export declare const loadRefreshRules: ModuleLoader;
61
63
  export declare const loadDrive: ModuleLoader;
62
64
  export declare const loadFactory: ModuleLoader;
@@ -66,6 +68,7 @@ export declare const loadBudget: ModuleLoader;
66
68
  export declare const loadAlias: ModuleLoader;
67
69
  export declare const loadPty: ModuleLoader;
68
70
  export declare const loadTmux: ModuleLoader;
71
+ export declare const loadWatchdog: ModuleLoader;
69
72
  export declare const loadBrowser: ModuleLoader;
70
73
  export declare const loadComputer: ModuleLoader;
71
74
  export declare const loadHosts: ModuleLoader;
@@ -79,6 +82,9 @@ export declare const loadSetup: ModuleLoader;
79
82
  export declare const loadSessions: ModuleLoader;
80
83
  export declare const loadTeams: ModuleLoader;
81
84
  export declare const loadCloud: ModuleLoader;
85
+ export declare const loadMessage: ModuleLoader;
86
+ export declare const loadServe: ModuleLoader;
87
+ export declare const loadAudit: ModuleLoader;
82
88
  /**
83
89
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
84
90
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -27,6 +27,7 @@ export const loadPrune = async () => (await import('../../commands/prune.js')).r
27
27
  export const loadTrash = async () => (await import('../../commands/trash.js')).registerTrashCommands;
28
28
  export const loadRestore = async () => (await import('../../commands/trash.js')).registerRestoreCommand;
29
29
  export const loadDoctor = async () => (await import('../../commands/doctor.js')).registerDoctorCommand;
30
+ export const loadCheck = async () => (await import('../../commands/check.js')).registerCheckCommand;
30
31
  export const loadStatus = async () => (await import('../../commands/status.js')).registerStatusCommand;
31
32
  export const loadProfiles = async () => (await import('../../commands/profiles.js')).registerProfilesCommands;
32
33
  export const loadSecrets = async () => (await import('../../commands/secrets.js')).registerSecretsCommands;
@@ -35,6 +36,7 @@ export const loadHelper = async () => (await import('../../commands/helper.js'))
35
36
  export const loadMenubar = async () => (await import('../../commands/menubar.js')).registerMenubarCommands;
36
37
  export const loadBeta = async () => (await import('../../commands/beta.js')).registerBetaCommands;
37
38
  export const loadSync = async () => (await import('../../commands/sync.js')).registerSyncCommand;
39
+ export const loadLock = async () => (await import('../../commands/lock.js')).registerLockCommand;
38
40
  export const loadRefreshRules = async () => (await import('../../commands/refresh-rules.js')).registerRefreshRulesCommand;
39
41
  export const loadDrive = async () => (await import('../../commands/drive.js')).registerDriveCommands;
40
42
  export const loadFactory = async () => (await import('../../commands/factory.js')).registerFactoryCommands;
@@ -44,6 +46,7 @@ export const loadBudget = async () => (await import('../../commands/budget.js'))
44
46
  export const loadAlias = async () => (await import('../../commands/alias.js')).registerAliasCommand;
45
47
  export const loadPty = async () => (await import('../../commands/pty.js')).registerPtyCommands;
46
48
  export const loadTmux = async () => (await import('../../commands/tmux.js')).registerTmuxCommands;
49
+ export const loadWatchdog = async () => (await import('../../commands/watchdog.js')).registerWatchdogCommand;
47
50
  export const loadBrowser = async () => (await import('../../commands/browser.js')).registerBrowserCommand;
48
51
  export const loadComputer = async () => (await import('../../commands/computer.js')).registerComputerCommand;
49
52
  export const loadHosts = async () => (await import('../../commands/hosts.js')).registerHostsCommand;
@@ -57,6 +60,9 @@ export const loadSetup = async () => (await import('../../commands/setup.js')).r
57
60
  export const loadSessions = async () => (await import('../../commands/sessions.js')).registerSessionsCommands;
58
61
  export const loadTeams = async () => (await import('../../commands/teams.js')).registerTeamsCommands;
59
62
  export const loadCloud = async () => (await import('../../commands/cloud.js')).registerCloudCommands;
63
+ export const loadMessage = async () => (await import('../../commands/message.js')).registerMessageCommand;
64
+ export const loadServe = async () => (await import('../../commands/serve.js')).registerServeCommand;
65
+ export const loadAudit = async () => (await import('../../commands/audit.js')).registerAuditCommands;
60
66
  /**
61
67
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
62
68
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -64,7 +70,7 @@ export const loadCloud = async () => (await import('../../commands/cloud.js')).r
64
70
  * inherit the root's custom help formatter rather than getting the per-command
65
71
  * recursive pass. Keeping that ordering preserves their `--help` output exactly.
66
72
  */
67
- export const LAZY_COMMAND_NAMES = new Set(['sessions', 'teams', 'cloud']);
73
+ export const LAZY_COMMAND_NAMES = new Set(['sessions', 'teams', 'cloud', 'message', 'serve']);
68
74
  /**
69
75
  * User-typed top-level command name -> ordered list of module loaders to run.
70
76
  *
@@ -113,6 +119,7 @@ export const COMMAND_LOADERS = {
113
119
  trash: [loadTrash],
114
120
  restore: [loadRestore],
115
121
  doctor: [loadDoctor],
122
+ check: [loadCheck],
116
123
  status: [loadStatus],
117
124
  profiles: [loadProfiles],
118
125
  secrets: [loadSecrets],
@@ -121,6 +128,7 @@ export const COMMAND_LOADERS = {
121
128
  menubar: [loadMenubar],
122
129
  beta: [loadBeta],
123
130
  sync: [loadSync],
131
+ lock: [loadLock],
124
132
  'refresh-rules': [loadRefreshRules],
125
133
  drive: [loadDrive],
126
134
  factory: [loadFactory],
@@ -130,6 +138,7 @@ export const COMMAND_LOADERS = {
130
138
  alias: [loadAlias],
131
139
  pty: [loadPty],
132
140
  tmux: [loadTmux],
141
+ watchdog: [loadWatchdog],
133
142
  browser: [loadBrowser],
134
143
  computer: [loadComputer],
135
144
  hosts: [loadHosts],
@@ -144,4 +153,7 @@ export const COMMAND_LOADERS = {
144
153
  sessions: [loadSessions],
145
154
  teams: [loadTeams],
146
155
  cloud: [loadCloud],
156
+ message: [loadMessage],
157
+ serve: [loadServe],
158
+ audit: [loadAudit],
147
159
  };
@@ -120,6 +120,8 @@ export declare function getRoutinesDir(): string;
120
120
  export declare function getProjectRoutinesDir(cwd?: string): string | null;
121
121
  /** Path to routine execution logs (~/.agents/.history/runs/). */
122
122
  export declare function getRunsDir(): string;
123
+ /** Root for per-agent mailboxes (~/.agents/.history/mailbox/). */
124
+ export declare function getMailboxRootDir(): string;
123
125
  /** Path to installed agent CLI binaries (~/.agents/.history/versions/). */
124
126
  export declare function getVersionsDir(): string;
125
127
  /** Path to version-switching shim scripts (~/.agents/.cache/shims/). */
@@ -226,6 +228,17 @@ export declare function getEnabledExtraRepos(): Array<{
226
228
  export declare function ensureAgentsDir(): void;
227
229
  /** Return an empty Meta object used when no agents.yaml exists yet. */
228
230
  export declare function createDefaultMeta(): Meta;
231
+ /**
232
+ * Per-device machine-local version pins — `~/.agents/devices/<machine>/agents.yaml`.
233
+ * Committed and synced, but each machine only ever writes its OWN folder, so
234
+ * pulls never conflict. `<machine>` = machineId() (Tailscale-aligned short name).
235
+ */
236
+ export declare function getDeviceMetaPath(): string;
237
+ /**
238
+ * Machine-local per-version resource tracking — `~/.agents/.history/version-resources.json`.
239
+ * Gitignored (under .history/) and regenerable; never synced.
240
+ */
241
+ export declare function getVersionResourcesPath(): string;
229
242
  /**
230
243
  * Read and cache ~/.agents/agents.yaml, migrating from legacy locations if needed.
231
244
  *
package/dist/lib/state.js CHANGED
@@ -28,6 +28,7 @@ import * as os from 'os';
28
28
  import * as yaml from 'yaml';
29
29
  import { ensureLockTarget, atomicWriteFileSync, withFileLock } from './fs-atomic.js';
30
30
  import { SEEDED_REGISTRIES } from './types.js';
31
+ import { machineId } from './machine-id.js';
31
32
  const HOME = process.env.HOME ?? os.homedir();
32
33
  /**
33
34
  * Compare two filesystem paths for identity, resolving symlinks and (on
@@ -87,6 +88,7 @@ const RUNS_DIR = path.join(HISTORY_DIR, 'runs');
87
88
  const TEAMS_AGENTS_DIR = path.join(HISTORY_DIR, 'teams', 'agents');
88
89
  const BACKUPS_DIR = path.join(HISTORY_DIR, 'backups');
89
90
  const TRASH_DIR = path.join(HISTORY_DIR, 'trash');
91
+ const MAILBOX_DIR = path.join(HISTORY_DIR, 'mailbox');
90
92
  // Cache bucket (regenerable).
91
93
  const SHIMS_DIR = path.join(CACHE_DIR, 'shims');
92
94
  const HOOK_SHIMS_DIR = path.join(SHIMS_DIR, 'hooks');
@@ -309,6 +311,8 @@ export function getProjectRoutinesDir(cwd = process.cwd()) {
309
311
  }
310
312
  /** Path to routine execution logs (~/.agents/.history/runs/). */
311
313
  export function getRunsDir() { return RUNS_DIR; }
314
+ /** Root for per-agent mailboxes (~/.agents/.history/mailbox/). */
315
+ export function getMailboxRootDir() { return MAILBOX_DIR; }
312
316
  /** Path to installed agent CLI binaries (~/.agents/.history/versions/). */
313
317
  export function getVersionsDir() { return VERSIONS_DIR; }
314
318
  /** Path to version-switching shim scripts (~/.agents/.cache/shims/). */
@@ -495,13 +499,37 @@ function safeMtimeMs(filePath) {
495
499
  return 0;
496
500
  }
497
501
  }
498
- /** Compute the combined cache stamp for the user + system agents.yaml files. */
502
+ /**
503
+ * Per-device machine-local version pins — `~/.agents/devices/<machine>/agents.yaml`.
504
+ * Committed and synced, but each machine only ever writes its OWN folder, so
505
+ * pulls never conflict. `<machine>` = machineId() (Tailscale-aligned short name).
506
+ */
507
+ export function getDeviceMetaPath() {
508
+ return path.join(USER_AGENTS_DIR, 'devices', machineId(), 'agents.yaml');
509
+ }
510
+ /**
511
+ * Machine-local per-version resource tracking — `~/.agents/.history/version-resources.json`.
512
+ * Gitignored (under .history/) and regenerable; never synced.
513
+ */
514
+ export function getVersionResourcesPath() {
515
+ return path.join(HISTORY_DIR, 'version-resources.json');
516
+ }
517
+ /**
518
+ * Combined cache stamp across all four Meta sources: central + system
519
+ * agents.yaml, this machine's device pins, and the version-resources tracking.
520
+ * A delimited string, NOT a numeric sum — summing down-scaled epoch-ms values
521
+ * loses precision (float64 rounds sub-unit terms away at ~1.75e12), so a change
522
+ * in any one file must contribute at full resolution.
523
+ */
499
524
  function currentMetaStamp() {
500
- return safeMtimeMs(META_FILE) + safeMtimeMs(SYSTEM_META_FILE) * 1e-3;
525
+ return safeMtimeMs(META_FILE)
526
+ + '|' + safeMtimeMs(SYSTEM_META_FILE)
527
+ + '|' + safeMtimeMs(getDeviceMetaPath())
528
+ + '|' + safeMtimeMs(getVersionResourcesPath());
501
529
  }
502
530
  /** Memoize a parsed Meta against the current file mtimes. */
503
531
  function rememberMeta(meta) {
504
- metaCache = { mtime: currentMetaStamp(), meta };
532
+ metaCache = { stamp: currentMetaStamp(), meta };
505
533
  return meta;
506
534
  }
507
535
  function withMetaLock(fn) {
@@ -526,11 +554,78 @@ function withMetaLock(fn) {
526
554
  }
527
555
  });
528
556
  }
557
+ /** Atomic write only when the on-disk content differs — avoids needless mtime
558
+ * bumps (which would thrash the meta cache) on no-op field routing. */
559
+ function writeIfChanged(filePath, content) {
560
+ let current = null;
561
+ try {
562
+ current = fs.readFileSync(filePath, 'utf-8');
563
+ }
564
+ catch { /* absent */ }
565
+ if (current === content)
566
+ return;
567
+ atomicWriteFileSync(filePath, content);
568
+ }
569
+ /**
570
+ * Partition the in-memory Meta across three files by sync-domain:
571
+ * - central `~/.agents/agents.yaml` — portable, everything else
572
+ * - device `~/.agents/devices/<machine>/agents.yaml` — `agents:` pins (per-device)
573
+ * - history `~/.agents/.history/version-resources.json` — `versions:` (machine-local)
574
+ * All callers funnel through writeMeta → here, so nothing else changes. Empty
575
+ * `agents:` / `versions:` are not written (no empty committed files).
576
+ */
529
577
  function writeMetaUnlocked(meta) {
530
- const content = META_HEADER + yaml.stringify(meta);
531
- atomicWriteFileSync(META_FILE, content);
578
+ const { agents, versions, ...central } = meta;
579
+ // Write the machine-local files FIRST, then strip central — so a crash mid-write
580
+ // never removes pins/versions from central before they're persisted elsewhere.
581
+ const devicePath = getDeviceMetaPath();
582
+ if (agents && Object.keys(agents).length > 0) {
583
+ fs.mkdirSync(path.dirname(devicePath), { recursive: true });
584
+ writeIfChanged(devicePath, META_HEADER + yaml.stringify({ agents }));
585
+ }
586
+ else if (fs.existsSync(devicePath)) {
587
+ // Every pin was cleared. Persist the emptied map instead of skipping the
588
+ // write — otherwise the stale device file survives and overlayMachineLocal
589
+ // re-applies the removed pin on the next read, leaving a dangling default
590
+ // (e.g. a launcher pointing at a version that was just uninstalled).
591
+ writeIfChanged(devicePath, META_HEADER + yaml.stringify({ agents: {} }));
592
+ }
593
+ if (versions && Object.keys(versions).length > 0) {
594
+ const vrPath = getVersionResourcesPath();
595
+ fs.mkdirSync(path.dirname(vrPath), { recursive: true });
596
+ writeIfChanged(vrPath, JSON.stringify(versions, null, 2) + '\n');
597
+ }
598
+ writeIfChanged(META_FILE, META_HEADER + yaml.stringify(central));
532
599
  metaCache = null;
533
600
  }
601
+ /**
602
+ * Overlay this machine's local state onto a central-portable Meta:
603
+ * - `agents:` from the device file (device wins; the union both preserves the
604
+ * one-level merge and self-heals a pre-migration central that still has pins)
605
+ * - `versions:` from the history JSON (wholesale replace; falls back to
606
+ * whatever central carried when the history file doesn't exist yet)
607
+ */
608
+ function overlayMachineLocal(meta) {
609
+ const devicePath = getDeviceMetaPath();
610
+ if (fs.existsSync(devicePath)) {
611
+ try {
612
+ const dm = yaml.parse(fs.readFileSync(devicePath, 'utf-8'));
613
+ if (dm?.agents)
614
+ meta.agents = { ...meta.agents, ...dm.agents };
615
+ }
616
+ catch { /* ignore malformed device file */ }
617
+ }
618
+ const vrPath = getVersionResourcesPath();
619
+ if (fs.existsSync(vrPath)) {
620
+ try {
621
+ const vr = JSON.parse(fs.readFileSync(vrPath, 'utf-8'));
622
+ if (vr)
623
+ meta.versions = vr;
624
+ }
625
+ catch { /* ignore malformed history file */ }
626
+ }
627
+ return meta;
628
+ }
534
629
  function applyRegistrySeeds(meta) {
535
630
  const seeded = new Set(meta.seededPresets || []);
536
631
  let changed = false;
@@ -590,10 +685,7 @@ export function readMeta() {
590
685
  // what we last parsed. Reduces N readMeta calls per CLI invocation to ~2 stat
591
686
  // syscalls plus an in-memory object spread.
592
687
  if (metaCache) {
593
- const userMtime = safeMtimeMs(META_FILE);
594
- const systemMtime = safeMtimeMs(SYSTEM_META_FILE);
595
- const stamp = userMtime + systemMtime * 1e-3;
596
- if (stamp === metaCache.mtime) {
688
+ if (currentMetaStamp() === metaCache.stamp) {
597
689
  return metaCache.meta;
598
690
  }
599
691
  }
@@ -666,6 +758,7 @@ export function readMeta() {
666
758
  ...userMeta?.registries,
667
759
  };
668
760
  }
761
+ overlayMachineLocal(meta);
669
762
  if (applyRegistrySeeds(meta)) {
670
763
  writeMeta(meta);
671
764
  return rememberMeta(meta);
@@ -673,6 +766,7 @@ export function readMeta() {
673
766
  return rememberMeta(meta);
674
767
  }
675
768
  const meta = createDefaultMeta();
769
+ overlayMachineLocal(meta);
676
770
  if (applyRegistrySeeds(meta)) {
677
771
  writeMeta(meta);
678
772
  }