@phnx-labs/agents-cli 1.22.66 → 1.22.69

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 (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. package/dist/lib/account-state-service.js +0 -60
@@ -1,107 +1,52 @@
1
- import { type DeviceProfile } from '../devices/registry.js';
2
- import { type ConfiguredDeviceRole } from '../device-config.js';
3
- import { type CachedUsageSnapshot } from './usage.js';
4
- /** How long a single peer push may take before it is abandoned for this tick. */
5
- export declare const USAGE_PUSH_DEADLINE_MS = 20000;
6
- export declare const USAGE_PULL_DEADLINE_MS = 20000;
7
1
  /**
8
- * A worker's newest local usage row older than this triggers a pull from the
9
- * primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
10
- * RUSH-2392), so its only source of a fresh row is the daemon push every
11
- * `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
12
- * cycle is tolerated before the worker actively pulls. Bounds worker staleness
13
- * to ~30m instead of the days-old caches this replaces.
2
+ * Fleet distribution of identity-keyed Claude usage through the user repo.
3
+ *
4
+ * A headed device can read authoritative usage; a worker's setup-token cannot.
5
+ * Headed daemons therefore publish one snapshot into their conflict-free
6
+ * `~/.agents/devices/<device>/daemon-state.json`. The daemon's bounded Git
7
+ * transport delivers those files automatically; workers merge newest-wins.
8
+ * There is deliberately no device-to-device SSH in this module or its tick.
14
9
  */
15
- export declare const USAGE_SYNC_MAX_AGE_MS: number;
16
- /** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
10
+ import { type ConfiguredDeviceRole } from '../device-config.js';
11
+ import { type CachedUsageSnapshot } from './usage.js';
12
+ /** Legacy hidden ingest/export envelope kept for older fleet CLI compatibility. */
17
13
  export interface UsageSyncPayload {
18
14
  v: 1;
19
15
  rows: Record<string, CachedUsageSnapshot>;
20
16
  }
21
- /** One peer the local publisher is considering, reduced to the plan inputs. */
22
- export interface UsagePushTarget {
23
- name: string;
24
- /** The peer's configured role — headed peers are skipped (own reader). */
25
- role: ConfiguredDeviceRole | undefined;
26
- /** Live-ish reachability from the tailscale snapshot; offline peers are skipped. */
27
- online: boolean;
28
- /** Managed known-hosts pin — an unpinned host is skipped, never TOFU-accepted. */
29
- pinned: boolean;
17
+ export interface PublishUsageSnapshotOptions {
18
+ userAgentsDir?: string;
19
+ cachePath?: string;
20
+ role?: ConfiguredDeviceRole;
21
+ device?: string;
30
22
  }
31
- export type UsageSyncPlanItem = {
32
- action: 'push';
33
- device: string;
34
- } | {
35
- action: 'skip';
36
- device: string;
37
- reason: string;
38
- };
39
- /**
40
- * Decide, per peer, whether to push the local usage snapshot. Pure.
41
- *
42
- * - `selfIsPublisher` is false on a `worker`/unmarked box: it has no authoritative
43
- * usage to publish, so every peer is skipped.
44
- * - `hasLocalRows` false means the local cache is empty (nothing to teach yet).
45
- * - A HEADED peer is skipped — it reads its own usage; pushing risks nothing
46
- * (the merge is newest-wins) but is wasted work, and keeping the fan-out to
47
- * consumers only makes the intent legible.
48
- */
49
- export declare function planUsagePush(selfIsPublisher: boolean, hasLocalRows: boolean, targets: UsagePushTarget[]): UsageSyncPlanItem[];
50
- export interface UsageSyncResult {
51
- pushed: string[];
52
- skipped: Array<{
53
- device: string;
54
- reason: string;
55
- }>;
56
- errors: Array<{
57
- device: string;
58
- message: string;
59
- }>;
23
+ export interface PublishUsageSnapshotResult {
24
+ published: boolean;
25
+ changed: boolean;
26
+ skipped: string | null;
27
+ error: string | null;
28
+ path: string | null;
60
29
  }
61
- export interface UsageSyncDeps {
62
- selfRole?: () => ConfiguredDeviceRole | undefined;
63
- listDevices?: () => DeviceProfile[];
64
- /** Configured roles by device name; default reads the fleet config. */
65
- listRoles?: () => Record<string, ConfiguredDeviceRole>;
66
- localName?: () => string;
67
- isPinned?: (name: string) => boolean;
68
- exportRows?: () => Record<string, CachedUsageSnapshot>;
69
- /** Deliver the serialized payload to one peer. Default: ssh `__usage-ingest`. */
70
- push?: (device: DeviceProfile, payload: string) => {
71
- ok: boolean;
72
- message?: string;
73
- };
30
+ /** Publish this headed device's stable usage snapshot into its owned store file. */
31
+ export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): PublishUsageSnapshotResult;
32
+ export interface ConsumeUsageSnapshotsOptions {
33
+ userAgentsDir?: string;
34
+ cachePath?: string;
35
+ role?: ConfiguredDeviceRole;
36
+ device?: string;
37
+ roles?: Record<string, ConfiguredDeviceRole>;
74
38
  }
75
- export interface UsagePullResult {
76
- pulledFrom: string | null;
39
+ export interface ConsumeUsageSnapshotsResult {
40
+ sources: string[];
77
41
  merged: number;
78
42
  skipped: string | null;
79
- error: string | null;
80
- }
81
- export interface UsagePullDeps {
82
- selfRole?: () => ConfiguredDeviceRole | undefined;
83
- listDevices?: () => DeviceProfile[];
84
- listRoles?: () => Record<string, ConfiguredDeviceRole>;
85
- isPinned?: (name: string) => boolean;
86
- exportRows?: () => Record<string, CachedUsageSnapshot>;
87
- ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
88
- /** Injectable clock for the staleness gate; defaults to `Date.now`. */
89
- now?: () => number;
90
- /** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
91
- pull?: (device: DeviceProfile) => {
92
- ok: boolean;
93
- stdout?: string;
94
- message?: string;
95
- };
43
+ errors: Array<{
44
+ device: string;
45
+ message: string;
46
+ }>;
96
47
  }
97
48
  /**
98
- * Pull usage from the fleet's primary headed device when this worker's local
99
- * cache is empty or contains an expired row. `personal` is the primary role;
100
- * a `desktop` is used only when the fleet has no personal device.
101
- */
102
- export declare function pullUsageFromPrimary(deps?: UsagePullDeps): UsagePullResult;
103
- /**
104
- * Push the local identity-keyed usage snapshot to every reachable, pinned,
105
- * non-headed peer. A no-op on a non-headed box or when the local cache is empty.
49
+ * Read headed publishers from the local user-repo checkout and merge one
50
+ * newest-wins batch into the worker cache. No network or subprocess is touched.
106
51
  */
107
- export declare function syncFleetUsageSnapshots(deps?: UsageSyncDeps): UsageSyncResult;
52
+ export declare function consumeUsageSnapshotsFromSharedStore(options?: ConsumeUsageSnapshotsOptions): ConsumeUsageSnapshotsResult;
@@ -1,231 +1,87 @@
1
1
  /**
2
- * Fleet sync of the identity-keyed Claude usage snapshot (PHNX-3392 follow-up).
2
+ * Fleet distribution of identity-keyed Claude usage through the user repo.
3
3
  *
4
- * A rate limit is metered per ACCOUNT, so an account's 5h/weekly usage is the
5
- * same number on every box. But only a HEADED device (`personal`/`desktop`) can
6
- * read it: the interactive OAuth login it holds carries the `user:profile` scope
7
- * `/api/oauth/usage` requires, and its interactive Claude runs feed the native
8
- * windows through the status-line writer. A headless `worker` has only the
9
- * `user:inference` setup-token, which the usage endpoint 403s (RUSH-2392), so its
10
- * local `claude-usage.json` stays blank and `agents view` shows no S:/W: bars.
11
- *
12
- * This closes that gap the same way {@link ../secrets/reserved-sync.ts} closes
13
- * the auth-token gap: each headed daemon PUSHES its identity-keyed usage rows to
14
- * the worker peers that cannot read them, which merge NEWEST-WINS
15
- * ({@link ingestPeerClaudeUsageRows}). Publish direction is role-driven —
16
- * personal/desktop publish, worker/unmarked consume — so it composes with the
17
- * device-role taxonomy rather than adding a second notion of "which box is real".
18
- *
19
- * Double-fire safety: each daemon only writes the DESTINATION's own cache, and
20
- * the merge is idempotent + timestamp-guarded, so two headed publishers pushing
21
- * the same account to one worker converge on the freshest snapshot regardless of
22
- * order. The input a publisher reads is its OWN cache (device-local state), so an
23
- * unrestricted per-device fire is correct — no shared queue.
24
- *
25
- * The planner is pure so tests cover every skip/push branch with no SSH.
4
+ * A headed device can read authoritative usage; a worker's setup-token cannot.
5
+ * Headed daemons therefore publish one snapshot into their conflict-free
6
+ * `~/.agents/devices/<device>/daemon-state.json`. The daemon's bounded Git
7
+ * transport delivers those files automatically; workers merge newest-wins.
8
+ * There is deliberately no device-to-device SSH in this module or its tick.
26
9
  */
27
- import { sshExec } from '../ssh-exec.js';
28
- import { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor, stripClixml } from '../hosts/remote-cmd.js';
29
- import { resolveRemoteOsSync } from '../hosts/remote-os.js';
30
- import { loadDevicesSync } from '../devices/registry.js';
31
- import { sshTargetFor } from '../devices/connect.js';
32
- import { isHostPinned, isDevicePinned } from '../devices/known-hosts.js';
10
+ import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole } from '../device-config.js';
11
+ import { readFleetSharedDeviceStates, updateFleetSharedDeviceState, } from '../fleet-shared-state.js';
12
+ import { getUserAgentsDir } from '../state.js';
33
13
  import { machineId, normalizeHost } from '../session/sync/config.js';
34
- import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
35
14
  import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
36
- /** How long a single peer push may take before it is abandoned for this tick. */
37
- export const USAGE_PUSH_DEADLINE_MS = 20_000;
38
- export const USAGE_PULL_DEADLINE_MS = 20_000;
39
- /**
40
- * A worker's newest local usage row older than this triggers a pull from the
41
- * primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
42
- * RUSH-2392), so its only source of a fresh row is the daemon push every
43
- * `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
44
- * cycle is tolerated before the worker actively pulls. Bounds worker staleness
45
- * to ~30m instead of the days-old caches this replaces.
46
- */
47
- export const USAGE_SYNC_MAX_AGE_MS = 30 * 60_000;
48
- /** Epoch ms of a cache row's capture time, or null when unparseable/absent. */
49
- function capturedAtMs(capturedAt) {
50
- if (!capturedAt)
51
- return null;
52
- const ms = Date.parse(capturedAt);
53
- return Number.isFinite(ms) ? ms : null;
54
- }
55
- /**
56
- * Decide, per peer, whether to push the local usage snapshot. Pure.
57
- *
58
- * - `selfIsPublisher` is false on a `worker`/unmarked box: it has no authoritative
59
- * usage to publish, so every peer is skipped.
60
- * - `hasLocalRows` false means the local cache is empty (nothing to teach yet).
61
- * - A HEADED peer is skipped — it reads its own usage; pushing risks nothing
62
- * (the merge is newest-wins) but is wasted work, and keeping the fan-out to
63
- * consumers only makes the intent legible.
64
- */
65
- export function planUsagePush(selfIsPublisher, hasLocalRows, targets) {
66
- if (!selfIsPublisher) {
67
- return targets.map((d) => ({
68
- action: 'skip',
69
- device: d.name,
70
- reason: 'this device is not a usage publisher (mark it personal or desktop)',
71
- }));
72
- }
73
- if (!hasLocalRows) {
74
- return targets.map((d) => ({ action: 'skip', device: d.name, reason: 'no local usage snapshot to publish' }));
75
- }
76
- return targets.map((d) => {
77
- if (isHeadedDeviceRole(d.role))
78
- return { action: 'skip', device: d.name, reason: 'headed peer reads its own usage' };
79
- if (!d.online)
80
- return { action: 'skip', device: d.name, reason: 'offline' };
81
- if (!d.pinned) {
82
- return { action: 'skip', device: d.name, reason: `host key not pinned; run \`agents ssh ${d.name}\` once` };
83
- }
84
- return { action: 'push', device: d.name };
85
- });
86
- }
87
- /** Production push: pipe the payload to the peer's `agents __usage-ingest` over ssh. */
88
- function defaultUsagePush(device, payload) {
89
- const target = sshTargetFor(device);
90
- const os = resolveRemoteOsSync(device.name);
91
- // A Windows peer's `agents.ps1` shim does not forward ssh-piped stdin, so hand
92
- // the payload through a temp file (`--from`) instead of stdin — same workaround
93
- // the secrets push uses. A POSIX peer reads stdin directly.
94
- const remoteCmd = remoteShellFor(os) === 'powershell'
95
- ? buildWindowsStdinAgentsCommand(['__usage-ingest'])
96
- : buildRemoteAgentsInvocation(['__usage-ingest'], undefined, os);
97
- const res = sshExec(target, remoteCmd, { input: payload, timeoutMs: USAGE_PUSH_DEADLINE_MS });
98
- if (res.timedOut)
99
- return { ok: false, message: 'timed out' };
100
- if (res.code !== 0)
101
- return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
102
- return { ok: true };
103
- }
104
- /** Production pull: read the primary's cache through its hidden export verb. */
105
- function defaultUsagePull(device) {
106
- const target = sshTargetFor(device);
107
- const os = resolveRemoteOsSync(device.name);
108
- const remoteCmd = buildRemoteAgentsInvocation(['__usage-export'], undefined, os);
109
- const res = sshExec(target, remoteCmd, { timeoutMs: USAGE_PULL_DEADLINE_MS });
110
- if (res.timedOut)
111
- return { ok: false, message: 'timed out' };
112
- if (res.code !== 0)
113
- return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
114
- return { ok: true, stdout: res.stdout };
115
- }
116
- /**
117
- * Pull usage from the fleet's primary headed device when this worker's local
118
- * cache is empty or contains an expired row. `personal` is the primary role;
119
- * a `desktop` is used only when the fleet has no personal device.
120
- */
121
- export function pullUsageFromPrimary(deps = {}) {
122
- const result = { pulledFrom: null, merged: 0, skipped: null, error: null };
123
- if ((deps.selfRole ?? selfConfiguredDeviceRole)() !== 'worker') {
124
- result.skipped = 'this device is not a worker';
15
+ /** Publish this headed device's stable usage snapshot into its owned store file. */
16
+ export function publishUsageSnapshotToSharedStore(options = {}) {
17
+ const result = {
18
+ published: false,
19
+ changed: false,
20
+ skipped: null,
21
+ error: null,
22
+ path: null,
23
+ };
24
+ const role = options.role ?? selfConfiguredDeviceRole();
25
+ if (!isHeadedDeviceRole(role)) {
26
+ result.skipped = 'this device is not a usage publisher (mark it personal or desktop)';
125
27
  return result;
126
28
  }
127
- const localRows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
128
- const localEntries = Object.entries(localRows);
129
- // Fresh means RECENT, not "the cache equals itself". The prior check read the
130
- // SAME on-disk cache twice (exportRows() and readRow()) and compared window
131
- // COUNT, so `fresh.windows.length === row.windows.length` was always true and
132
- // any non-empty worker cache skipped the pull forever — which is how a worker
133
- // served a days-old snapshot indefinitely and balanced launched into an
134
- // account already at its weekly cap. A worker cannot self-read usage, so any
135
- // row older than the sync cadence is stale and must trigger a pull; the merge
136
- // is newest-wins + idempotent, so pulling when the primary is no fresher is a
137
- // harmless no-op.
138
- const now = (deps.now ?? Date.now)();
139
- const staleCutoff = now - USAGE_SYNC_MAX_AGE_MS;
140
- const allFresh = localEntries.length > 0 &&
141
- localEntries.every(([, row]) => {
142
- const ms = capturedAtMs(row.capturedAt);
143
- return ms !== null && ms >= staleCutoff;
144
- });
145
- if (allFresh) {
146
- result.skipped = 'local usage cache is fresh';
29
+ const rows = exportClaudeUsageCacheRows(options.cachePath);
30
+ if (Object.keys(rows).length === 0) {
31
+ result.skipped = 'no local usage snapshot to publish';
147
32
  return result;
148
33
  }
149
- const devices = deps.listDevices?.() ?? Object.values(loadDevicesSync());
150
- const roles = deps.listRoles?.() ?? (deps.listDevices
151
- ? listConfiguredDeviceRoles(devices.map((device) => device.name))
152
- : listConfiguredDeviceRoles());
153
- const isPinned = deps.isPinned ?? isHostPinned;
154
- const primary = devices
155
- .filter((device) => roles[device.name] === 'personal')
156
- .concat(devices.filter((device) => roles[device.name] === 'desktop'))
157
- .find((device) => device.tailscale?.online !== false && isDevicePinned(device, isPinned));
158
- if (!primary) {
159
- result.error = 'no reachable, pinned personal or desktop device is configured as the usage primary';
160
- return result;
161
- }
162
- const outcome = (deps.pull ?? defaultUsagePull)(primary);
163
- if (!outcome.ok) {
164
- result.error = outcome.message ?? 'pull failed';
165
- return result;
166
- }
167
- let payload;
168
34
  try {
169
- // A Windows usage primary's `agents.ps1` shim prepends a PowerShell CLIXML
170
- // progress banner to stdout, exactly as every other remote-JSON boundary
171
- // strips (remote-cmd.ts:325). Without this, a headed Windows box (win-mini
172
- // is a live fleet device) makes every pull fail as "malformed JSON", the
173
- // worker's cache stays null, and the PHNX-3392 capacity floor silently
174
- // becomes the only thing standing between a blind pool and an exhausted pick.
175
- payload = JSON.parse(stripClixml(outcome.stdout ?? ''));
176
- }
177
- catch {
178
- result.error = 'primary returned malformed JSON';
179
- return result;
35
+ const write = updateFleetSharedDeviceState(options.device ?? machineId(), { usage: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
36
+ result.published = true;
37
+ result.changed = write.changed;
38
+ result.path = write.path;
180
39
  }
181
- if (!payload || payload.v !== 1 || typeof payload.rows !== 'object' || payload.rows === null || Array.isArray(payload.rows)) {
182
- result.error = 'primary returned an unrecognized usage-sync payload shape';
183
- return result;
40
+ catch (err) {
41
+ result.error = err.message;
184
42
  }
185
- result.pulledFrom = primary.name;
186
- result.merged = (deps.ingestRows ?? ingestPeerClaudeUsageRows)(payload.rows);
187
43
  return result;
188
44
  }
45
+ function capturedAtMs(row) {
46
+ if (!row.capturedAt)
47
+ return null;
48
+ const ms = Date.parse(row.capturedAt);
49
+ return Number.isFinite(ms) ? ms : null;
50
+ }
189
51
  /**
190
- * Push the local identity-keyed usage snapshot to every reachable, pinned,
191
- * non-headed peer. A no-op on a non-headed box or when the local cache is empty.
52
+ * Read headed publishers from the local user-repo checkout and merge one
53
+ * newest-wins batch into the worker cache. No network or subprocess is touched.
192
54
  */
193
- export function syncFleetUsageSnapshots(deps = {}) {
194
- const result = { pushed: [], skipped: [], errors: [] };
195
- const selfRole = (deps.selfRole ?? selfConfiguredDeviceRole)();
196
- const devices = deps.listDevices?.() ?? Object.values(loadDevicesSync());
197
- const self = deps.localName?.() ?? machineId();
198
- const selfNorm = normalizeHost(self);
199
- const roles = deps.listRoles?.() ?? (deps.listDevices ? listConfiguredDeviceRoles(devices.map((d) => d.name)) : listConfiguredDeviceRoles());
200
- const isPinned = deps.isPinned ?? isHostPinned;
201
- const byName = new Map(devices.map((d) => [d.name, d]));
202
- const targets = devices
203
- .filter((d) => normalizeHost(d.name) !== selfNorm)
204
- .map((d) => ({
205
- name: d.name,
206
- role: roles[d.name],
207
- online: d.tailscale?.online !== false,
208
- pinned: isDevicePinned(d, isPinned),
209
- }));
210
- const rows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
211
- const plan = planUsagePush(isHeadedDeviceRole(selfRole), Object.keys(rows).length > 0, targets);
212
- const payload = JSON.stringify({ v: 1, rows });
213
- const push = deps.push ?? defaultUsagePush;
214
- for (const item of plan) {
215
- if (item.action === 'skip') {
216
- result.skipped.push({ device: item.device, reason: item.reason });
217
- continue;
218
- }
219
- const device = byName.get(item.device);
220
- if (!device) {
221
- result.errors.push({ device: item.device, message: 'device left the registry mid-sync' });
55
+ export function consumeUsageSnapshotsFromSharedStore(options = {}) {
56
+ const result = { sources: [], merged: 0, skipped: null, errors: [] };
57
+ if ((options.role ?? selfConfiguredDeviceRole()) !== 'worker') {
58
+ result.skipped = 'this device is not a worker';
59
+ return result;
60
+ }
61
+ const read = readFleetSharedDeviceStates(options.userAgentsDir ?? getUserAgentsDir());
62
+ result.errors.push(...read.errors);
63
+ const self = normalizeHost(options.device ?? machineId());
64
+ const roles = options.roles ?? listConfiguredDeviceRoles(read.states.map((state) => state.device));
65
+ const rows = {};
66
+ for (const state of read.states) {
67
+ if (normalizeHost(state.device) === self || !isHeadedDeviceRole(roles[state.device]) || !state.usage)
222
68
  continue;
69
+ result.sources.push(state.device);
70
+ for (const [identity, incoming] of Object.entries(state.usage.rows)) {
71
+ if (!incoming || !Array.isArray(incoming.windows) || incoming.windows.length === 0)
72
+ continue;
73
+ const current = rows[identity];
74
+ if (!current) {
75
+ rows[identity] = incoming;
76
+ continue;
77
+ }
78
+ const incomingMs = capturedAtMs(incoming);
79
+ const currentMs = capturedAtMs(current);
80
+ if (incomingMs !== null && (currentMs === null || incomingMs > currentMs))
81
+ rows[identity] = incoming;
223
82
  }
224
- const outcome = push(device, payload);
225
- if (outcome.ok)
226
- result.pushed.push(item.device);
227
- else
228
- result.errors.push({ device: item.device, message: outcome.message ?? 'push failed' });
229
83
  }
84
+ result.sources.sort();
85
+ result.merged = ingestPeerClaudeUsageRows(rows, options.cachePath);
230
86
  return result;
231
87
  }
@@ -180,8 +180,13 @@ export interface UsageSnapshot {
180
180
  * MUST NEVER read this field — `isUsageVerified`/`hasStaleUsage`/
181
181
  * `hasUsageAvailable`/`deriveUsageStatusFromSnapshot` consult only `windows`,
182
182
  * so a stale number rendered here can never make a stale account read as
183
- * verified or eligible (the RUSH-2858 property). Never serialized to the
184
- * on-disk cache or `--json` (both project `windows` explicitly).
183
+ * verified or eligible (the RUSH-2858 property). Not a serialized key of its
184
+ * own, and dropped from `--json` (which projects `windows` explicitly) — but
185
+ * the READINGS it holds do round-trip through the on-disk cache:
186
+ * `serializeClaudeUsageSnapshot` persists the union of `windows` and
187
+ * `staleWindows`, and `deserializeClaudeUsageSnapshot` re-runs the freshness
188
+ * gate on read to re-partition them (so a collector like Grok that pre-splits
189
+ * an ended-period reading onto `staleWindows` still survives the round-trip).
185
190
  */
186
191
  staleWindows?: UsageWindow[];
187
192
  plan?: string | null;
@@ -229,6 +234,12 @@ interface UsageOptions {
229
234
  * absent, the backoff stays provider-wide.
230
235
  */
231
236
  usageScope?: string | null;
237
+ /**
238
+ * Caller-supplied abort signal (the daemon tick's deadline). Combined with each
239
+ * provider fetch's own timeout so a hung refresh is bounded by BOTH the
240
+ * per-fetch timeout and the supervisor deadline (PHNX-3608).
241
+ */
242
+ signal?: AbortSignal;
232
243
  /**
233
244
  * When true, never open the ACL-bound OS keychain item (macOS Touch ID).
234
245
  * Daemon usage refresh sets this so a background tick cannot pop biometrics.
@@ -362,6 +373,8 @@ export declare const USAGE_FETCH_CONCURRENCY = 3;
362
373
  export interface UsageLookupOptions {
363
374
  forceRefresh?: boolean;
364
375
  fileOnly?: boolean;
376
+ /** Daemon tick deadline signal, combined with each provider fetch's own timeout (PHNX-3608). */
377
+ signal?: AbortSignal;
365
378
  /**
366
379
  * Permit a foreground personal-device usage read to fall through to the
367
380
  * interactive login when no setup-token exists (USAGE-READ-2). Set ONLY by
@@ -626,11 +639,11 @@ export interface ProviderProbe {
626
639
  reason?: 'usage_scope';
627
640
  }
628
641
  /** 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
- export declare function probeClaudeStatus(home?: string, cliVersion?: string | null, usageScope?: string | null): Promise<ProviderProbe>;
642
+ export declare function probeClaudeStatus(home?: string, cliVersion?: string | null, usageScope?: string | null, signal?: AbortSignal): Promise<ProviderProbe>;
630
643
  /** Probe Kimi's OAuth token against the /usages endpoint. Never refreshes (single-use rotation — see getKimiUsageInfo). */
631
- export declare function probeKimiStatus(home?: string, usageScope?: string | null): Promise<ProviderProbe>;
644
+ export declare function probeKimiStatus(home?: string, usageScope?: string | null, signal?: AbortSignal): Promise<ProviderProbe>;
632
645
  /** Probe Droid's WorkOS token against the billing-limits endpoint. Never refreshes (single-use rotation — see getDroidUsageInfo). */
633
- export declare function probeDroidStatus(home?: string, usageScope?: string | null): Promise<ProviderProbe>;
646
+ export declare function probeDroidStatus(home?: string, usageScope?: string | null, signal?: AbortSignal): Promise<ProviderProbe>;
634
647
  /**
635
648
  * Normalize the Factory billing-limits payload into the common UsageWindow
636
649
  * shape. Orgs on the legacy (non token-rate-limit) billing model have no