@phnx-labs/agents-cli 1.20.73 → 1.20.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/CHANGELOG.md +989 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +31 -1
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/export.d.ts +11 -0
  15. package/dist/commands/export.js +215 -0
  16. package/dist/commands/feed.d.ts +7 -1
  17. package/dist/commands/feed.js +47 -4
  18. package/dist/commands/go.d.ts +9 -2
  19. package/dist/commands/go.js +16 -6
  20. package/dist/commands/harness.d.ts +14 -0
  21. package/dist/commands/harness.js +145 -0
  22. package/dist/commands/import.js +87 -9
  23. package/dist/commands/inspect.js +5 -2
  24. package/dist/commands/profiles.d.ts +20 -0
  25. package/dist/commands/profiles.js +68 -21
  26. package/dist/commands/repo.js +14 -0
  27. package/dist/commands/routines.js +15 -2
  28. package/dist/commands/secrets.js +44 -21
  29. package/dist/commands/sessions-browser.js +1 -1
  30. package/dist/commands/sessions-export.js +19 -5
  31. package/dist/commands/sessions-migrate.d.ts +29 -0
  32. package/dist/commands/sessions-migrate.js +596 -0
  33. package/dist/commands/sessions-picker.js +16 -1
  34. package/dist/commands/sessions.d.ts +36 -13
  35. package/dist/commands/sessions.js +210 -40
  36. package/dist/commands/setup-computer.js +2 -2
  37. package/dist/commands/setup.js +6 -1
  38. package/dist/commands/ssh.js +71 -35
  39. package/dist/commands/status.js +10 -2
  40. package/dist/commands/versions.js +51 -11
  41. package/dist/commands/view.d.ts +5 -0
  42. package/dist/commands/view.js +99 -31
  43. package/dist/commands/watchdog.d.ts +7 -2
  44. package/dist/commands/watchdog.js +73 -57
  45. package/dist/index.js +59 -7
  46. package/dist/lib/activity.d.ts +14 -4
  47. package/dist/lib/activity.js +17 -5
  48. package/dist/lib/actor.d.ts +50 -0
  49. package/dist/lib/actor.js +166 -0
  50. package/dist/lib/agent-spec/provider.js +2 -1
  51. package/dist/lib/agent-spec/resolve.js +19 -5
  52. package/dist/lib/agent-spec/types.d.ts +9 -1
  53. package/dist/lib/agents.d.ts +56 -0
  54. package/dist/lib/agents.js +133 -7
  55. package/dist/lib/cloud/codex.d.ts +2 -0
  56. package/dist/lib/cloud/codex.js +14 -3
  57. package/dist/lib/cloud/session-index.d.ts +32 -0
  58. package/dist/lib/cloud/session-index.js +58 -0
  59. package/dist/lib/cloud/store.d.ts +7 -0
  60. package/dist/lib/cloud/store.js +25 -0
  61. package/dist/lib/config-transfer.d.ts +16 -0
  62. package/dist/lib/config-transfer.js +60 -0
  63. package/dist/lib/daemon.js +18 -2
  64. package/dist/lib/devices/connect.d.ts +15 -1
  65. package/dist/lib/devices/connect.js +15 -1
  66. package/dist/lib/devices/fleet.d.ts +21 -0
  67. package/dist/lib/devices/fleet.js +26 -0
  68. package/dist/lib/devices/health-report.d.ts +38 -0
  69. package/dist/lib/devices/health-report.js +214 -0
  70. package/dist/lib/devices/health.js +4 -1
  71. package/dist/lib/devices/reachability.d.ts +31 -0
  72. package/dist/lib/devices/reachability.js +40 -0
  73. package/dist/lib/devices/registry.d.ts +33 -0
  74. package/dist/lib/devices/registry.js +37 -0
  75. package/dist/lib/devices/resolve-target.d.ts +15 -27
  76. package/dist/lib/devices/resolve-target.js +63 -102
  77. package/dist/lib/devices/sync.d.ts +18 -0
  78. package/dist/lib/devices/sync.js +23 -1
  79. package/dist/lib/devices/tailscale.d.ts +3 -0
  80. package/dist/lib/devices/tailscale.js +1 -0
  81. package/dist/lib/events.d.ts +1 -1
  82. package/dist/lib/exec.d.ts +66 -0
  83. package/dist/lib/exec.js +195 -6
  84. package/dist/lib/export.d.ts +72 -0
  85. package/dist/lib/export.js +269 -0
  86. package/dist/lib/feed-post.d.ts +63 -0
  87. package/dist/lib/feed-post.js +204 -0
  88. package/dist/lib/fleet/apply.d.ts +32 -2
  89. package/dist/lib/fleet/apply.js +97 -10
  90. package/dist/lib/fleet/types.d.ts +11 -0
  91. package/dist/lib/fs-walk.d.ts +13 -0
  92. package/dist/lib/fs-walk.js +16 -7
  93. package/dist/lib/hooks.d.ts +1 -0
  94. package/dist/lib/hooks.js +156 -0
  95. package/dist/lib/hosts/dispatch.d.ts +7 -0
  96. package/dist/lib/hosts/dispatch.js +2 -0
  97. package/dist/lib/hosts/passthrough.js +2 -0
  98. package/dist/lib/hosts/reconnect.d.ts +78 -0
  99. package/dist/lib/hosts/reconnect.js +127 -0
  100. package/dist/lib/hosts/registry.d.ts +75 -14
  101. package/dist/lib/hosts/registry.js +205 -30
  102. package/dist/lib/hosts/remote-cmd.js +1 -0
  103. package/dist/lib/hosts/run-target.d.ts +13 -5
  104. package/dist/lib/hosts/run-target.js +26 -10
  105. package/dist/lib/hosts/session-index.d.ts +18 -1
  106. package/dist/lib/hosts/session-index.js +37 -2
  107. package/dist/lib/hosts/session-marker.d.ts +33 -0
  108. package/dist/lib/hosts/session-marker.js +51 -0
  109. package/dist/lib/hosts/tasks.d.ts +8 -4
  110. package/dist/lib/import.d.ts +25 -0
  111. package/dist/lib/import.js +107 -2
  112. package/dist/lib/isolation-boundary-report.d.ts +10 -0
  113. package/dist/lib/isolation-boundary-report.js +35 -0
  114. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  115. package/dist/lib/menubar/install-menubar.js +45 -1
  116. package/dist/lib/migrate.d.ts +16 -0
  117. package/dist/lib/migrate.js +36 -0
  118. package/dist/lib/models.d.ts +27 -0
  119. package/dist/lib/models.js +54 -1
  120. package/dist/lib/overdue.d.ts +7 -2
  121. package/dist/lib/overdue.js +18 -11
  122. package/dist/lib/picker.js +4 -1
  123. package/dist/lib/platform/process.d.ts +17 -0
  124. package/dist/lib/platform/process.js +70 -0
  125. package/dist/lib/profiles-presets.js +9 -7
  126. package/dist/lib/profiles.d.ts +31 -0
  127. package/dist/lib/profiles.js +70 -0
  128. package/dist/lib/project-resources.js +20 -5
  129. package/dist/lib/pty-server.d.ts +2 -10
  130. package/dist/lib/pty-server.js +4 -38
  131. package/dist/lib/registry.d.ts +1 -1
  132. package/dist/lib/registry.js +48 -8
  133. package/dist/lib/rotate.d.ts +18 -0
  134. package/dist/lib/rotate.js +28 -0
  135. package/dist/lib/routines.d.ts +26 -0
  136. package/dist/lib/routines.js +19 -2
  137. package/dist/lib/runner.js +104 -7
  138. package/dist/lib/secrets/agent.d.ts +66 -1
  139. package/dist/lib/secrets/agent.js +154 -90
  140. package/dist/lib/secrets/bundles.d.ts +34 -0
  141. package/dist/lib/secrets/bundles.js +179 -19
  142. package/dist/lib/secrets/index.d.ts +15 -1
  143. package/dist/lib/secrets/index.js +21 -3
  144. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  145. package/dist/lib/secrets/rc-hygiene.js +154 -0
  146. package/dist/lib/secrets/remote.d.ts +7 -4
  147. package/dist/lib/secrets/remote.js +7 -4
  148. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  149. package/dist/lib/secrets/sync-commands.js +21 -0
  150. package/dist/lib/self-update.d.ts +23 -2
  151. package/dist/lib/self-update.js +86 -5
  152. package/dist/lib/session/active.d.ts +114 -7
  153. package/dist/lib/session/active.js +323 -52
  154. package/dist/lib/session/cloud.js +2 -1
  155. package/dist/lib/session/db.d.ts +48 -0
  156. package/dist/lib/session/db.js +238 -14
  157. package/dist/lib/session/detached.d.ts +30 -0
  158. package/dist/lib/session/detached.js +92 -0
  159. package/dist/lib/session/discover.d.ts +465 -2
  160. package/dist/lib/session/discover.js +1648 -468
  161. package/dist/lib/session/fork.js +2 -1
  162. package/dist/lib/session/hook-sessions.d.ts +43 -0
  163. package/dist/lib/session/hook-sessions.js +135 -0
  164. package/dist/lib/session/linear.d.ts +6 -0
  165. package/dist/lib/session/linear.js +55 -0
  166. package/dist/lib/session/migrate-targets.d.ts +65 -0
  167. package/dist/lib/session/migrate-targets.js +94 -0
  168. package/dist/lib/session/migrations.d.ts +37 -0
  169. package/dist/lib/session/migrations.js +60 -0
  170. package/dist/lib/session/parse.d.ts +18 -0
  171. package/dist/lib/session/parse.js +130 -30
  172. package/dist/lib/session/pid-registry.d.ts +34 -2
  173. package/dist/lib/session/pid-registry.js +49 -2
  174. package/dist/lib/session/render.d.ts +7 -2
  175. package/dist/lib/session/render.js +22 -10
  176. package/dist/lib/session/short-id.d.ts +17 -0
  177. package/dist/lib/session/short-id.js +20 -0
  178. package/dist/lib/session/state.js +6 -2
  179. package/dist/lib/shims.d.ts +27 -1
  180. package/dist/lib/shims.js +88 -6
  181. package/dist/lib/sqlite.d.ts +3 -2
  182. package/dist/lib/sqlite.js +27 -4
  183. package/dist/lib/staleness/detectors/commands.js +1 -1
  184. package/dist/lib/staleness/detectors/workflows.js +13 -2
  185. package/dist/lib/staleness/writers/commands.js +7 -7
  186. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  187. package/dist/lib/startup/command-registry.d.ts +2 -0
  188. package/dist/lib/startup/command-registry.js +5 -0
  189. package/dist/lib/state.d.ts +1 -1
  190. package/dist/lib/state.js +15 -36
  191. package/dist/lib/teams/agents.d.ts +19 -10
  192. package/dist/lib/teams/agents.js +40 -39
  193. package/dist/lib/types.d.ts +48 -2
  194. package/dist/lib/uninstall.js +1 -45
  195. package/dist/lib/usage.d.ts +33 -1
  196. package/dist/lib/usage.js +172 -12
  197. package/dist/lib/versions.d.ts +22 -2
  198. package/dist/lib/versions.js +98 -10
  199. package/dist/lib/watchdog/log.d.ts +43 -0
  200. package/dist/lib/watchdog/log.js +69 -0
  201. package/dist/lib/watchdog/routine.d.ts +44 -0
  202. package/dist/lib/watchdog/routine.js +69 -0
  203. package/dist/lib/watchdog/runner.d.ts +51 -7
  204. package/dist/lib/watchdog/runner.js +239 -64
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +31 -16
  207. package/dist/lib/workflows.d.ts +16 -0
  208. package/dist/lib/workflows.js +110 -1
  209. package/package.json +1 -1
@@ -151,6 +151,22 @@ export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot |
151
151
  export declare function formatUsageStatusBadge(usageStatus: 'available' | 'rate_limited' | 'out_of_credits' | null | undefined): string;
152
152
  /** Format a multi-line usage section for detailed agent views. */
153
153
  export declare function formatUsageSection(usage: UsageInfo): string[];
154
+ /**
155
+ * The access token to use for a READ-ONLY Claude usage fetch, or null when the
156
+ * stored token is within the refresh leeway.
157
+ *
158
+ * Returns null instead of refreshing on purpose. Claude's refresh token is
159
+ * single-use and rotates server-side on every refresh; with one account signed
160
+ * into several machines, refreshing here would stampede that one token and
161
+ * silently invalidate every other holder — the RUSH-1822 failure, except in the
162
+ * usage path (fired in the background by the SWR cache and by `agents run`'s
163
+ * default "balanced" rotation on every unpinned run) rather than the health
164
+ * probe. So a usage read must never rotate: a near-expiry token yields "no usage
165
+ * right now" instead of a fleet-wide logout. Mirrors {@link probeClaudeStatus};
166
+ * the single legitimate refresh belongs to the actual claude run, never a read.
167
+ * Pure — unit-tested.
168
+ */
169
+ export declare function claudeUsageAccessTokenNoRefresh(oauth: Pick<ClaudeOauthCredentials, 'accessToken' | 'expiresAt'>): string | null;
154
170
  /** Raw quota bucket from the Kimi /usages response (numbers arrive as strings). */
155
171
  interface KimiUsageQuota {
156
172
  limit?: string | number | null;
@@ -242,8 +258,24 @@ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse):
242
258
  * Without step 2 the live usage fetch got no token on Linux, so `agents view`
243
259
  * (run remotely over SSH by `--host`) rendered no usage bars even though the
244
260
  * account + plan — read from the plaintext `.claude.json` — showed fine.
261
+ *
262
+ * `opts.accessTokenCache` opts INTO the no-ACL access-token cache (the Touch ID
263
+ * storm fix, see the cache helpers above). It is OFF by default so every caller
264
+ * keeps the full ACL-read credential — the cached copy deliberately omits the
265
+ * refresh token, so callers that refresh (`isClaudeAuthValid` ->
266
+ * `getClaudeAccessToken`) or export the full blob (`readClaudeCredentialsBlob`
267
+ * for Rush Cloud dispatch) must NOT pass it. Only the read-only, high-frequency
268
+ * access-token-only consumers (the usage fetch and the auth-health probe) opt in.
269
+ */
270
+ export declare function loadClaudeOauth(home?: string, opts?: {
271
+ accessTokenCache?: boolean;
272
+ }): Promise<ClaudeOauthCredentials | null>;
273
+ /**
274
+ * Save Claude OAuth credentials to the system keychain/keyring.
275
+ * Reads the existing payload, merges the new OAuth fields, and writes back.
276
+ * Exported for regression tests; not part of the public command surface.
245
277
  */
246
- export declare function loadClaudeOauth(home?: string): Promise<ClaudeOauthCredentials | null>;
278
+ export declare function saveClaudeOauth(home: string | undefined, credentials: ClaudeOauthCredentials): Promise<boolean>;
247
279
  /**
248
280
  * Derive the Keychain service name for a Claude home directory.
249
281
  * Managed (non-default) homes get a hash suffix for isolation.
package/dist/lib/usage.js CHANGED
@@ -17,7 +17,7 @@ import { promisify } from 'util';
17
17
  import chalk from 'chalk';
18
18
  import { decodeJwtPayload, decryptDroidAuthPayload } from './agents.js';
19
19
  import { walkForFiles } from './fs-walk.js';
20
- import { getKeychainToken, setKeychainToken, deleteKeychainToken, } from './secrets/index.js';
20
+ import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden, } from './secrets/index.js';
21
21
  import { getCacheDir } from './state.js';
22
22
  const execFileAsync = promisify(execFile);
23
23
  const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
@@ -375,10 +375,33 @@ async function getCodexUsageInfo(options) {
375
375
  return { snapshot: null, error: null };
376
376
  }
377
377
  }
378
+ /**
379
+ * The access token to use for a READ-ONLY Claude usage fetch, or null when the
380
+ * stored token is within the refresh leeway.
381
+ *
382
+ * Returns null instead of refreshing on purpose. Claude's refresh token is
383
+ * single-use and rotates server-side on every refresh; with one account signed
384
+ * into several machines, refreshing here would stampede that one token and
385
+ * silently invalidate every other holder — the RUSH-1822 failure, except in the
386
+ * usage path (fired in the background by the SWR cache and by `agents run`'s
387
+ * default "balanced" rotation on every unpinned run) rather than the health
388
+ * probe. So a usage read must never rotate: a near-expiry token yields "no usage
389
+ * right now" instead of a fleet-wide logout. Mirrors {@link probeClaudeStatus};
390
+ * the single legitimate refresh belongs to the actual claude run, never a read.
391
+ * Pure — unit-tested.
392
+ */
393
+ export function claudeUsageAccessTokenNoRefresh(oauth) {
394
+ if (claudeAccessTokenNeedsRefresh(oauth.expiresAt ?? null))
395
+ return null;
396
+ const token = oauth.accessToken?.trim();
397
+ return token ? token : null;
398
+ }
378
399
  /** Fetch Claude usage via the Anthropic OAuth usage API. */
379
400
  async function getClaudeUsageInfo(options) {
380
401
  try {
381
- const oauth = await loadClaudeOauth(options?.home);
402
+ // Opt into the no-ACL access-token cache: this is the every-60s watchdog hot
403
+ // path and usage needs only the access token, so it kills the Touch ID storm.
404
+ const oauth = await loadClaudeOauth(options?.home, { accessTokenCache: true });
382
405
  if (!oauth?.accessToken) {
383
406
  return { snapshot: null, error: null };
384
407
  }
@@ -387,7 +410,8 @@ async function getClaudeUsageInfo(options) {
387
410
  if (!isClaudeUsageOrgMatch(requestedOrgId, liveOrgId)) {
388
411
  return { snapshot: null, error: null };
389
412
  }
390
- const accessToken = await getClaudeAccessToken(oauth, options?.home);
413
+ // Read-only: never refresh a single-use token just to read usage (RUSH-1822).
414
+ const accessToken = claudeUsageAccessTokenNoRefresh(oauth);
391
415
  if (!accessToken) {
392
416
  return { snapshot: null, error: null };
393
417
  }
@@ -627,7 +651,10 @@ async function getDroidUsageInfo(options) {
627
651
  }
628
652
  /** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
629
653
  export async function probeClaudeStatus(home, cliVersion) {
630
- const oauth = await loadClaudeOauth(home);
654
+ // Opt into the no-ACL access-token cache: the daemon warms this probe every ~3
655
+ // min per account and it never refreshes (access token only), so caching is safe
656
+ // and stops it from adding to the Touch ID storm.
657
+ const oauth = await loadClaudeOauth(home, { accessTokenCache: true });
631
658
  const accessToken = oauth?.accessToken?.trim();
632
659
  if (!accessToken)
633
660
  return { status: null, token: 'missing' };
@@ -862,6 +889,103 @@ function parseClaudeOauthPayload(raw) {
862
889
  return null;
863
890
  }
864
891
  }
892
+ // ── No-ACL cache for Claude's OAuth token (kills the macOS Touch ID storm) ──
893
+ //
894
+ // The source item `Claude Code-credentials-<hash>` is ACL-bound to Claude Code's
895
+ // own process, so every read agents-cli makes (via `/usr/bin/security`) pops
896
+ // Touch ID. The Factory watchdog polls `agents view --json` every 60s per agent,
897
+ // and each poll that crosses the 2-minute usage cache fires a background refresh
898
+ // -> loadClaudeOauth -> keychain read -> a biometric prompt. With many agents and
899
+ // accounts that is a prompt every couple of minutes, per account.
900
+ //
901
+ // Fix: after one real (prompting) read, cache the ACCESS token in a device-local
902
+ // NO-ACL keychain item — the same `set-no-acl` mechanism secrets/session-store.ts
903
+ // uses for unlocked bundles, whose reads never prompt — and serve every later read
904
+ // from it until the token's own `expiresAt`. The ACL read then happens at most once
905
+ // per token lifetime (~a few times a day), shared across every agent process.
906
+ //
907
+ // Security posture: only the short-lived access token is cached, never the refresh
908
+ // token, and the entry dies at the token's expiry (capped below). This mirrors the
909
+ // no-ACL posture session-store.ts already accepts for held bundles.
910
+ const CLAUDE_OAUTH_CACHE_PREFIX = 'agents-cli.claude-oauth-cache.';
911
+ /** Hard cap so a token with a distant or absent `expiresAt` can't pin a stale entry. */
912
+ const CLAUDE_OAUTH_CACHE_MAX_TTL_MS = 8 * 60 * 60 * 1000;
913
+ /** The no-ACL cache item name for a Claude keychain service (hashed to stay tidy). */
914
+ function claudeOauthCacheItem(service) {
915
+ const hash = createHash('sha256').update(service).digest('hex').slice(0, 16);
916
+ return `${CLAUDE_OAUTH_CACHE_PREFIX}${hash}`;
917
+ }
918
+ /** Whether the no-ACL cache is meaningful: macOS (where the source read prompts) or
919
+ * whenever a test keychain backend is installed, so the path is exercisable on CI.
920
+ * Off macOS in production the source read is prompt-free, so there is nothing to cache. */
921
+ function claudeOauthCacheActive() {
922
+ return process.platform === 'darwin' || isKeychainBackendOverridden();
923
+ }
924
+ /** Read the cached access token (prompt-free). Null on miss, or when the cache entry
925
+ * or the token itself has expired — in which case the stale entry is dropped. */
926
+ function readCachedClaudeOauth(service) {
927
+ try {
928
+ const entry = JSON.parse(getKeychainToken(claudeOauthCacheItem(service)));
929
+ if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken)
930
+ return null;
931
+ const now = Date.now();
932
+ const tokenExpired = typeof entry.expiresAt === 'number' && entry.expiresAt > 0 && now >= entry.expiresAt;
933
+ if (now >= entry.cacheExpiresAt || tokenExpired) {
934
+ try {
935
+ deleteKeychainToken(claudeOauthCacheItem(service));
936
+ }
937
+ catch {
938
+ /* best-effort eviction */
939
+ }
940
+ return null;
941
+ }
942
+ return {
943
+ accessToken: entry.accessToken,
944
+ refreshToken: null,
945
+ expiresAt: entry.expiresAt ?? null,
946
+ scopes: entry.scopes ?? null,
947
+ subscriptionType: entry.subscriptionType ?? null,
948
+ rateLimitTier: entry.rateLimitTier ?? null,
949
+ organizationUuid: entry.organizationUuid ?? null,
950
+ };
951
+ }
952
+ catch {
953
+ return null;
954
+ }
955
+ }
956
+ /** Populate the no-ACL cache after a real (prompting) source read. Best-effort — the
957
+ * cache is an optimization, so any failure just means the next read prompts again. */
958
+ function writeCachedClaudeOauth(service, creds) {
959
+ if (!creds.accessToken)
960
+ return;
961
+ try {
962
+ const now = Date.now();
963
+ const cap = now + CLAUDE_OAUTH_CACHE_MAX_TTL_MS;
964
+ const tokenExp = typeof creds.expiresAt === 'number' && creds.expiresAt > now ? creds.expiresAt : cap;
965
+ const entry = {
966
+ accessToken: creds.accessToken,
967
+ expiresAt: creds.expiresAt ?? null,
968
+ scopes: creds.scopes ?? null,
969
+ subscriptionType: creds.subscriptionType ?? null,
970
+ rateLimitTier: creds.rateLimitTier ?? null,
971
+ organizationUuid: creds.organizationUuid ?? null,
972
+ cacheExpiresAt: Math.min(tokenExp, cap),
973
+ };
974
+ setKeychainToken(claudeOauthCacheItem(service), JSON.stringify(entry), { noAcl: true });
975
+ }
976
+ catch {
977
+ /* best-effort — cache is an optimization */
978
+ }
979
+ }
980
+ /** Evict the no-ACL cache so a source rotation or sign-out is reflected immediately. */
981
+ function deleteCachedClaudeOauth(service) {
982
+ try {
983
+ deleteKeychainToken(claudeOauthCacheItem(service));
984
+ }
985
+ catch {
986
+ /* best-effort — cache is an optimization */
987
+ }
988
+ }
865
989
  /**
866
990
  * Load a version home's Claude OAuth credential from the two stores Claude Code
867
991
  * uses, tried in order:
@@ -878,19 +1002,48 @@ function parseClaudeOauthPayload(raw) {
878
1002
  * Without step 2 the live usage fetch got no token on Linux, so `agents view`
879
1003
  * (run remotely over SSH by `--host`) rendered no usage bars even though the
880
1004
  * account + plan — read from the plaintext `.claude.json` — showed fine.
1005
+ *
1006
+ * `opts.accessTokenCache` opts INTO the no-ACL access-token cache (the Touch ID
1007
+ * storm fix, see the cache helpers above). It is OFF by default so every caller
1008
+ * keeps the full ACL-read credential — the cached copy deliberately omits the
1009
+ * refresh token, so callers that refresh (`isClaudeAuthValid` ->
1010
+ * `getClaudeAccessToken`) or export the full blob (`readClaudeCredentialsBlob`
1011
+ * for Rush Cloud dispatch) must NOT pass it. Only the read-only, high-frequency
1012
+ * access-token-only consumers (the usage fetch and the auth-health probe) opt in.
881
1013
  */
882
- export async function loadClaudeOauth(home) {
1014
+ export async function loadClaudeOauth(home, opts) {
883
1015
  // The OS keychain/keyring step is macOS/Linux-only. Windows skips straight
884
1016
  // to the .credentials.json fallback — the Claude CLI has no keychain
885
- // integration there and stores its OAuth token in that file too.
886
- if (process.platform === 'darwin' || process.platform === 'linux') {
1017
+ // integration there and stores its OAuth token in that file too. An injected
1018
+ // test backend is the exception: it makes the keychain path exercisable
1019
+ // anywhere, so the platform check must yield to it exactly as the inner
1020
+ // claudeOauthCacheActive() gate does — otherwise this whole block is dead on
1021
+ // a Windows runner and the tests below it read an empty home.
1022
+ if (process.platform === 'darwin' || process.platform === 'linux' || isKeychainBackendOverridden()) {
1023
+ const service = getClaudeKeychainService(home);
1024
+ // Serve the no-ACL cache first (opt-in, macOS/test only) so the ACL-gated read
1025
+ // below — the one that pops Touch ID — happens at most once per token lifetime
1026
+ // instead of on every usage refresh. Off unless the caller opts in, because the
1027
+ // cached copy drops the refresh token (see the doc comment above).
1028
+ const cacheActive = opts?.accessTokenCache === true && claudeOauthCacheActive();
1029
+ if (cacheActive) {
1030
+ const cached = readCachedClaudeOauth(service);
1031
+ if (cached)
1032
+ return cached;
1033
+ }
887
1034
  try {
888
- const fromKeychain = parseClaudeOauthPayload(getKeychainToken(getClaudeKeychainService(home)));
889
- if (fromKeychain)
1035
+ const fromKeychain = parseClaudeOauthPayload(getKeychainToken(service));
1036
+ if (fromKeychain) {
1037
+ if (cacheActive)
1038
+ writeCachedClaudeOauth(service, fromKeychain);
890
1039
  return fromKeychain;
1040
+ }
891
1041
  }
892
1042
  catch {
893
1043
  // No keychain item, or no reachable keyring (headless Linux) — fall through.
1044
+ // Evict any stale no-ACL cache so a sign-out/deletion isn't masked.
1045
+ if (cacheActive)
1046
+ deleteCachedClaudeOauth(service);
894
1047
  }
895
1048
  }
896
1049
  const credsPath = path.join(home ?? os.homedir(), '.claude', '.credentials.json');
@@ -907,10 +1060,15 @@ export async function loadClaudeOauth(home) {
907
1060
  /**
908
1061
  * Save Claude OAuth credentials to the system keychain/keyring.
909
1062
  * Reads the existing payload, merges the new OAuth fields, and writes back.
1063
+ * Exported for regression tests; not part of the public command surface.
910
1064
  */
911
- async function saveClaudeOauth(home, credentials) {
912
- // Windows not yet supported
913
- if (process.platform !== 'darwin' && process.platform !== 'linux') {
1065
+ export async function saveClaudeOauth(home, credentials) {
1066
+ // Windows not yet supported. An injected test backend is the exception, for
1067
+ // the same reason as the loadClaudeOauth guard above: it makes the keychain
1068
+ // path exercisable anywhere, and without it this returns before the rotated
1069
+ // credential is written OR the no-ACL cache is evicted — so a cached reader
1070
+ // keeps serving the pre-rotation access token.
1071
+ if (process.platform !== 'darwin' && process.platform !== 'linux' && !isKeychainBackendOverridden()) {
914
1072
  return false;
915
1073
  }
916
1074
  try {
@@ -944,6 +1102,8 @@ async function saveClaudeOauth(home, credentials) {
944
1102
  // Entry might not exist, ignore
945
1103
  }
946
1104
  setKeychainToken(service, payloadJson);
1105
+ // A new credential rotation means any cached access token is stale.
1106
+ deleteCachedClaudeOauth(service);
947
1107
  return true;
948
1108
  }
949
1109
  catch {
@@ -216,6 +216,19 @@ export declare function getGlobalDefault(agent: AgentId): string | null;
216
216
  * Set the global default version for an agent.
217
217
  */
218
218
  export declare function setGlobalDefault(agent: AgentId, version: string | undefined): void;
219
+ /**
220
+ * Get the preferred ISOLATED version for an agent — the copy a bare
221
+ * `agents run <agent>` falls back to when there is no global default.
222
+ */
223
+ export declare function getIsolatedDefault(agent: AgentId): string | null;
224
+ /**
225
+ * Set (or clear, with `undefined`) the preferred isolated version.
226
+ *
227
+ * Deliberately does NOT touch the launcher, the bare shim, the `~/.<agent>` config
228
+ * symlink or the global default — the five things `setDefaultVersion` does. This is
229
+ * a pointer inside the sandbox, so it stays inside the sandbox.
230
+ */
231
+ export declare function setIsolatedDefault(agent: AgentId, version: string | undefined): void;
219
232
  /**
220
233
  * Mark an installed version as an isolated install (`agents add --isolated`).
221
234
  *
@@ -431,8 +444,15 @@ export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: s
431
444
  * the exact failure this repairs. Agents with a global/native binary (grok,
432
445
  * droid) have no such tarball and are returned unchanged.
433
446
  */
434
- export declare function ensureAgentRunnable(agent: AgentId, version: string, log?: (message: string) => void): Promise<string | null>;
435
- export declare function healBrokenDefaultLaunches(log?: (m: string) => void): Promise<string[]>;
447
+ export declare function ensureAgentRunnable(agent: AgentId, version: string, log?: (message: string) => void, opts?: {
448
+ allowDefaultSwitch?: boolean;
449
+ }): Promise<string | null>;
450
+ export declare function healBrokenDefaultLaunches(log?: (m: string) => void, opts?: {
451
+ allowDefaultSwitch?: boolean;
452
+ }): Promise<{
453
+ repaired: string[];
454
+ unhealed: string[];
455
+ }>;
436
456
  /** Outcome of syncing resources to a version home, keyed by resource type. */
437
457
  export interface SyncResult {
438
458
  commands: boolean;
@@ -32,12 +32,12 @@ import { VERSION_RE, compareVersions } from './agent-spec/primitives.js';
32
32
  import { AGENTS, agentConfigDirName, getAccountEmail, resolveAgentName, formatAgentError, findInPath, isSelfUpdatingAgent } from './agents.js';
33
33
  import { discoverPermissionGroups, getActivePermissionPresetName, readPermissionPresetRecipe, PERMISSION_PRESET_ENV_VAR } from './permissions.js';
34
34
  import { parseMcpServerConfig, isProjectMcpTrusted } from './mcp.js';
35
- import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
35
+ import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink, assertIsolationBoundary, } from './shims.js';
36
36
  import { importInstallScriptBinary } from './import.js';
37
37
  import { IS_WINDOWS, composeWin32CommandLine } from './platform/index.js';
38
38
  import { listInstalledSubagents } from './subagents.js';
39
39
  import { listInstalledWorkflows } from './workflows.js';
40
- import { pruneVersionHomeHookEntriesFromSettings } from './hooks.js';
40
+ import { parseHookManifest, registerHooksToSettings, selectHookManifest, pruneVersionHomeHookEntriesFromSettings } from './hooks.js';
41
41
  import { supports, explainSkip } from './capabilities.js';
42
42
  import { discoverPlugins, marketplaceSpecForName } from './plugins.js';
43
43
  import { loadManifest, saveManifest, buildManifest as buildSyncManifest, isStale } from './staleness/index.js';
@@ -1186,6 +1186,14 @@ export function getGlobalDefault(agent) {
1186
1186
  * Set the global default version for an agent.
1187
1187
  */
1188
1188
  export function setGlobalDefault(agent, version) {
1189
+ // A global default is what owns the launcher and arms the self-heal `shadowing`
1190
+ // check, so recording one for an isolated-only agent is the root of the original
1191
+ // breach. Clearing is always allowed — `removeVersion` legitimately clears a
1192
+ // default as the last non-isolated version goes away, which is the very moment
1193
+ // an agent BECOMES isolated-only.
1194
+ if (version !== undefined) {
1195
+ assertIsolationBoundary(agent, 'set a global default');
1196
+ }
1189
1197
  const meta = readMeta();
1190
1198
  if (!meta.agents) {
1191
1199
  meta.agents = {};
@@ -1199,6 +1207,34 @@ export function setGlobalDefault(agent, version) {
1199
1207
  }
1200
1208
  writeMeta(meta);
1201
1209
  }
1210
+ /**
1211
+ * Get the preferred ISOLATED version for an agent — the copy a bare
1212
+ * `agents run <agent>` falls back to when there is no global default.
1213
+ */
1214
+ export function getIsolatedDefault(agent) {
1215
+ const meta = readMeta();
1216
+ return meta.isolatedAgents?.[agent] || null;
1217
+ }
1218
+ /**
1219
+ * Set (or clear, with `undefined`) the preferred isolated version.
1220
+ *
1221
+ * Deliberately does NOT touch the launcher, the bare shim, the `~/.<agent>` config
1222
+ * symlink or the global default — the five things `setDefaultVersion` does. This is
1223
+ * a pointer inside the sandbox, so it stays inside the sandbox.
1224
+ */
1225
+ export function setIsolatedDefault(agent, version) {
1226
+ const meta = readMeta();
1227
+ if (!meta.isolatedAgents) {
1228
+ meta.isolatedAgents = {};
1229
+ }
1230
+ if (version === undefined) {
1231
+ delete meta.isolatedAgents[agent];
1232
+ }
1233
+ else {
1234
+ meta.isolatedAgents[agent] = version;
1235
+ }
1236
+ writeMeta(meta);
1237
+ }
1202
1238
  /**
1203
1239
  * Path to the sentinel file that marks a version as an isolated install.
1204
1240
  *
@@ -1784,6 +1820,13 @@ export function removeVersion(agent, version) {
1784
1820
  console.log(chalk.yellow(`Removed the last non-isolated ${agent} version and cleared its default. Reinstall with: agents add ${agent}, then set one with: agents use ${agent}@<version>`));
1785
1821
  }
1786
1822
  }
1823
+ // Same for the isolated pointer: a removed version must not stay the answer to a
1824
+ // bare `agents run <agent>`. Prefer the newest remaining isolated copy so removing
1825
+ // one of several does not silently drop the user back to their PATH binary.
1826
+ if (getIsolatedDefault(agent) === version) {
1827
+ const survivors = listInstalledVersions(agent).filter((v) => isVersionIsolated(agent, v));
1828
+ setIsolatedDefault(agent, survivors.length > 0 ? survivors[survivors.length - 1] : undefined);
1829
+ }
1787
1830
  // Clean up dangling config symlink if it pointed to the removed version
1788
1831
  const symlinkVersion = getConfigSymlinkVersion(agent);
1789
1832
  if (symlinkVersion === version) {
@@ -1855,7 +1898,23 @@ export function resolveVersion(agent, projectPath) {
1855
1898
  }
1856
1899
  }
1857
1900
  // Fall back to global default
1858
- return getGlobalDefault(agent);
1901
+ const globalDefault = getGlobalDefault(agent);
1902
+ if (globalDefault)
1903
+ return globalDefault;
1904
+ // Last resort: the preferred isolated copy. Strictly a fallback — a global
1905
+ // default always wins, so nothing changes for anyone who has one. Without this,
1906
+ // an isolated-only user cannot reach their installs by bare name at all: the
1907
+ // resolution chain ended here, so `agents run codex` fell through to whatever
1908
+ // `codex` meant on PATH, and only `agents run codex@<version>` worked.
1909
+ //
1910
+ // The pointer is verified on read. It survives in agents.yaml across a trash +
1911
+ // restore cycle, but a version removed for good would otherwise leave a dangling
1912
+ // pin that resolves to a directory that is not there.
1913
+ const isolated = getIsolatedDefault(agent);
1914
+ if (isolated && isVersionInstalled(agent, isolated) && isVersionIsolated(agent, isolated)) {
1915
+ return isolated;
1916
+ }
1917
+ return null;
1859
1918
  }
1860
1919
  /**
1861
1920
  * Normalize a user-supplied @version token across CLI subcommands.
@@ -2091,7 +2150,17 @@ export async function verifyInstalledBinaryLaunches(agent, version) {
2091
2150
  * the exact failure this repairs. Agents with a global/native binary (grok,
2092
2151
  * droid) have no such tarball and are returned unchanged.
2093
2152
  */
2094
- export async function ensureAgentRunnable(agent, version, log) {
2153
+ export async function ensureAgentRunnable(agent, version, log, opts) {
2154
+ // Whether this heal may REPOINT the global default (adopt another version, or
2155
+ // install `latest` and pin it). Interactive callers (`agents run`, `agents
2156
+ // add`) allow it. The daemon's unattended 6-hourly launch-health pass sets
2157
+ // this false: a background pass that silently switches the default installs a
2158
+ // fresh version home → a fresh, empty Claude credential scope (macOS keychain
2159
+ // keyed off CLAUDE_CONFIG_DIR; Linux per-version token file), i.e. an
2160
+ // "unprovoked logout" at a time uncorrelated with anything the user did. In
2161
+ // refuse-mode we still repair the CURRENT default in place (no scope change),
2162
+ // but never repoint — we return null so the caller can alert instead.
2163
+ const allowDefaultSwitch = opts?.allowDefaultSwitch ?? true;
2095
2164
  const cfg = AGENTS[agent];
2096
2165
  if (!cfg?.npmPackage)
2097
2166
  return version;
@@ -2113,6 +2182,14 @@ export async function ensureAgentRunnable(agent, version, log) {
2113
2182
  log?.(`${cfg.name}@${version} is an isolated install and could not be repaired — leaving your default ${cfg.name} untouched.`);
2114
2183
  return null;
2115
2184
  }
2185
+ // In-place repair failed → normally adopt another installed version and repoint
2186
+ // the default. When default-switching is disallowed (unattended daemon pass),
2187
+ // refuse: repointing would swap the live credential scope out from under the
2188
+ // user with no signal. Surface it so the caller can notify.
2189
+ if (!allowDefaultSwitch) {
2190
+ log?.(`${cfg.name}@${version} won't launch and could not be repaired in place — NOT repointing your default ${cfg.name} unattended. Run \`agents use ${agent} <version>\` or \`agents add ${agent}@latest\` to choose one.`);
2191
+ return null;
2192
+ }
2116
2193
  // In-place repair failed → adopt another installed version that launches.
2117
2194
  // Isolated copies are excluded: they are deliberately not promotable.
2118
2195
  const others = listInstalledVersions(agent)
@@ -2162,14 +2239,18 @@ export async function ensureAgentRunnable(agent, version, log) {
2162
2239
  * `agents run` hits a raw ENOENT — the run-time heal (ensureAgentRunnable) only
2163
2240
  * fires once a run is already starting; this catches it in the background.
2164
2241
  *
2165
- * Returns a label (`agent@broken→healed`) for each version actually repaired, so
2166
- * the daemon can log/notify. A version that already launches costs one cheap
2167
- * `--version` probe and is left untouched.
2242
+ * Returns `{ repaired, unhealed }`: `repaired` is a label (`agent@broken→healed`)
2243
+ * for each version actually fixed; `unhealed` is `agent@version` for each broken
2244
+ * default that could not be fixed (including, under `allowDefaultSwitch:false`,
2245
+ * one that was deliberately not repointed), so the daemon can log/notify. A
2246
+ * version that already launches costs one cheap `--version` probe and is left
2247
+ * untouched.
2168
2248
  */
2169
2249
  const failedRepairAt = new Map();
2170
2250
  const REPAIR_COOLDOWN_MS = 24 * 60 * 60_000;
2171
- export async function healBrokenDefaultLaunches(log) {
2251
+ export async function healBrokenDefaultLaunches(log, opts) {
2172
2252
  const repaired = [];
2253
+ const unhealed = [];
2173
2254
  for (const agent of Object.keys(AGENTS)) {
2174
2255
  if (!AGENTS[agent].npmPackage)
2175
2256
  continue; // native/global agents have no gutted-tarball failure mode
@@ -2189,16 +2270,17 @@ export async function healBrokenDefaultLaunches(log) {
2189
2270
  continue;
2190
2271
  }
2191
2272
  log?.(`${AGENTS[agent].name}@${version} won't launch — repairing…`);
2192
- const healed = await ensureAgentRunnable(agent, version, log);
2273
+ const healed = await ensureAgentRunnable(agent, version, log, opts);
2193
2274
  if (healed) {
2194
2275
  failedRepairAt.delete(key);
2195
2276
  repaired.push(`${agent}@${version}${healed === version ? '' : `→${healed}`}`);
2196
2277
  }
2197
2278
  else {
2198
2279
  failedRepairAt.set(key, Date.now());
2280
+ unhealed.push(key);
2199
2281
  }
2200
2282
  }
2201
- return repaired;
2283
+ return { repaired, unhealed };
2202
2284
  }
2203
2285
  async function getCliVersionFromPath(agent) {
2204
2286
  const agentConfig = AGENTS[agent];
@@ -2581,6 +2663,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2581
2663
  const hooksToSync = selection
2582
2664
  ? resolveSelection(selection.hooks, available.hooks)
2583
2665
  : available.hooks;
2666
+ let hookManifest = {};
2584
2667
  if (hooksToSync.length > 0) {
2585
2668
  const r = hooksWriter.write({ version, versionHome, selection: hooksToSync, cwd });
2586
2669
  // Remove orphan files from version home. The trusted set is the
@@ -2598,6 +2681,11 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2598
2681
  }
2599
2682
  }
2600
2683
  result.hooks = r.synced.length > 0;
2684
+ hookManifest = selectHookManifest(parseHookManifest(), hooksToSync);
2685
+ }
2686
+ const hooksInScope = !userPassedSelection || selection?.hooks !== undefined;
2687
+ if (agent === 'opencode' && hooksInScope) {
2688
+ registerHooksToSettings(agent, versionHome, hookManifest);
2601
2689
  }
2602
2690
  }
2603
2691
  }
@@ -0,0 +1,43 @@
1
+ /** Same path the Factory reader pulls (apps/factory/src/core/watchdogLog.ts). */
2
+ export declare const WATCHDOG_LOG_PATH: string;
3
+ /** Kinds the Factory reader accepts. Keep in lockstep with watchdogLog.ts. */
4
+ export type WatchdogEventKind = 'tick' | 'decision' | 'nudge' | 'rotate' | 'error';
5
+ /** JSONL row shape — a verbatim replica of the Factory reader's WatchdogEvent. */
6
+ export interface WatchdogEvent {
7
+ ts: number;
8
+ kind: WatchdogEventKind;
9
+ terminalId?: string;
10
+ agentType?: string;
11
+ message: string;
12
+ reason?: string;
13
+ /** For 'tick' events: the session lines the watchdog actually read. */
14
+ tailLines?: string[];
15
+ /** For 'tick' / 'decision' events: how long the terminal had been stalled. */
16
+ stalledForMs?: number;
17
+ /** Carried so the UI can show what the agent was stuck on. */
18
+ lastUserMessage?: string;
19
+ lastAssistantMessage?: string;
20
+ /** For a 'decision'/'nudge' that injects: the exact text delivered. */
21
+ nudgeText?: string;
22
+ }
23
+ /** Serialize one event to a JSONL line (no trailing newline). */
24
+ export declare function formatEvent(ev: WatchdogEvent): string;
25
+ /**
26
+ * Cap on retained log lines. Mirrors the Factory reader's expectation that the
27
+ * writer trims (watchdogLog.ts trimToLast). Large enough to keep a useful
28
+ * history, small enough to bound the file the UI polls.
29
+ */
30
+ export declare const WATCHDOG_LOG_MAX_LINES = 2000;
31
+ /** Trim a JSONL body to the last `maxLines` events (same logic as the reader). */
32
+ export declare function trimToLast(text: string, maxLines: number): string;
33
+ /**
34
+ * Append events to the log under a file lock, trimming to WATCHDOG_LOG_MAX_LINES
35
+ * so the file never grows unbounded. Concurrent watchdog ticks share the lock so
36
+ * their appends never interleave into a corrupt line. Best-effort: a filesystem
37
+ * error is swallowed (the Factory card tolerates a stale/missing log), never
38
+ * throwing into the tick.
39
+ */
40
+ export declare function appendWatchdogEvents(events: WatchdogEvent[], opts?: {
41
+ logPath?: string;
42
+ maxLines?: number;
43
+ }): void;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Canonical watchdog event log (watchdog-brain-v2).
3
+ *
4
+ * The Factory Floor renders a read-only "Watchdog activity" card from the JSONL
5
+ * feed at ~/.agents/.cache/logs/watchdog.log. That feed was historically written
6
+ * by the retired extension-side watchdog; now the always-on CLI watchdog owns it.
7
+ *
8
+ * The event SHAPE here is a deliberate, verbatim replica of the reader in
9
+ * apps/factory/src/core/watchdogLog.ts (WatchdogEvent / WatchdogEventKind /
10
+ * WATCHDOG_LOG_PATH / the trim cap). The repo forbids cross-app imports
11
+ * (CLAUDE.md repo map), so the two files are kept in sync by hand — a change to
12
+ * the Factory reader's shape must be mirrored here. There is no import between
13
+ * them.
14
+ */
15
+ import * as fs from 'fs';
16
+ import * as os from 'os';
17
+ import * as path from 'path';
18
+ import { withFileLock, atomicWriteFileSync, ensureLockTarget } from '../fs-atomic.js';
19
+ /** Same path the Factory reader pulls (apps/factory/src/core/watchdogLog.ts). */
20
+ export const WATCHDOG_LOG_PATH = path.join(os.homedir(), '.agents', '.cache', 'logs', 'watchdog.log');
21
+ /** Serialize one event to a JSONL line (no trailing newline). */
22
+ export function formatEvent(ev) {
23
+ return JSON.stringify(ev);
24
+ }
25
+ /**
26
+ * Cap on retained log lines. Mirrors the Factory reader's expectation that the
27
+ * writer trims (watchdogLog.ts trimToLast). Large enough to keep a useful
28
+ * history, small enough to bound the file the UI polls.
29
+ */
30
+ export const WATCHDOG_LOG_MAX_LINES = 2000;
31
+ /** Trim a JSONL body to the last `maxLines` events (same logic as the reader). */
32
+ export function trimToLast(text, maxLines) {
33
+ const lines = text.split('\n').filter((l) => l.trim().length > 0);
34
+ if (lines.length <= maxLines)
35
+ return lines.join('\n') + (lines.length ? '\n' : '');
36
+ return lines.slice(lines.length - maxLines).join('\n') + '\n';
37
+ }
38
+ /**
39
+ * Append events to the log under a file lock, trimming to WATCHDOG_LOG_MAX_LINES
40
+ * so the file never grows unbounded. Concurrent watchdog ticks share the lock so
41
+ * their appends never interleave into a corrupt line. Best-effort: a filesystem
42
+ * error is swallowed (the Factory card tolerates a stale/missing log), never
43
+ * throwing into the tick.
44
+ */
45
+ export function appendWatchdogEvents(events, opts = {}) {
46
+ if (events.length === 0)
47
+ return;
48
+ const logPath = opts.logPath ?? WATCHDOG_LOG_PATH;
49
+ const maxLines = opts.maxLines ?? WATCHDOG_LOG_MAX_LINES;
50
+ const lockPath = path.join(path.dirname(logPath), '.watchdog.log.lock');
51
+ try {
52
+ ensureLockTarget(lockPath);
53
+ withFileLock(lockPath, () => {
54
+ let existing = '';
55
+ try {
56
+ existing = fs.readFileSync(logPath, 'utf8');
57
+ }
58
+ catch {
59
+ /* first write — no file yet */
60
+ }
61
+ const appended = events.map(formatEvent).join('\n') + '\n';
62
+ const body = trimToLast(existing + appended, maxLines);
63
+ atomicWriteFileSync(logPath, body);
64
+ });
65
+ }
66
+ catch {
67
+ /* best-effort: never let logging break a tick */
68
+ }
69
+ }