@phnx-labs/agents-cli 1.22.56 → 1.22.58

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +11 -2
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/accounts.js +7 -3
  7. package/dist/commands/apply.js +10 -2
  8. package/dist/commands/exec.js +1 -1
  9. package/dist/commands/fork.d.ts +23 -10
  10. package/dist/commands/fork.js +115 -58
  11. package/dist/commands/hooks.js +4 -4
  12. package/dist/commands/insights.d.ts +7 -5
  13. package/dist/commands/insights.js +16 -9
  14. package/dist/commands/monitors.js +11 -0
  15. package/dist/commands/perf.d.ts +16 -7
  16. package/dist/commands/perf.js +29 -20
  17. package/dist/commands/prune.js +5 -3
  18. package/dist/commands/routines.d.ts +8 -0
  19. package/dist/commands/routines.js +57 -3
  20. package/dist/commands/rules.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +11 -0
  22. package/dist/commands/sessions-picker.js +16 -0
  23. package/dist/commands/sessions.js +1 -0
  24. package/dist/commands/share.d.ts +14 -0
  25. package/dist/commands/share.js +43 -2
  26. package/dist/commands/ssh.js +24 -14
  27. package/dist/commands/status.js +1 -1
  28. package/dist/commands/sync.js +83 -7
  29. package/dist/commands/traces.js +7 -0
  30. package/dist/commands/trash.d.ts +2 -2
  31. package/dist/commands/trash.js +2 -6
  32. package/dist/commands/versions.d.ts +2 -2
  33. package/dist/commands/versions.js +1 -10
  34. package/dist/commands/view.d.ts +2 -2
  35. package/dist/commands/view.js +7 -6
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +14 -0
  38. package/dist/lib/account-registry.d.ts +5 -1
  39. package/dist/lib/account-registry.js +47 -14
  40. package/dist/lib/accounting/capacity.d.ts +18 -7
  41. package/dist/lib/accounting/capacity.js +19 -8
  42. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  43. package/dist/lib/accounting/usage-ingest.js +75 -0
  44. package/dist/lib/accounting/usage-sync.d.ts +97 -0
  45. package/dist/lib/accounting/usage-sync.js +203 -0
  46. package/dist/lib/accounting/usage.d.ts +48 -2
  47. package/dist/lib/accounting/usage.js +79 -2
  48. package/dist/lib/agent-spec/agents.js +1 -1
  49. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  50. package/dist/lib/analytics/mix-commands.js +50 -73
  51. package/dist/lib/auth-mint.d.ts +11 -1
  52. package/dist/lib/auth-mint.js +21 -6
  53. package/dist/lib/browser/ipc.d.ts +8 -0
  54. package/dist/lib/browser/ipc.js +87 -0
  55. package/dist/lib/browser/service.d.ts +19 -0
  56. package/dist/lib/browser/service.js +96 -11
  57. package/dist/lib/browser/sessions-list.js +10 -1
  58. package/dist/lib/daemon/daemon.js +5 -0
  59. package/dist/lib/daemon/runner.d.ts +3 -0
  60. package/dist/lib/daemon/runner.js +95 -53
  61. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  62. package/dist/lib/daemon/usage-sync-service.js +42 -0
  63. package/dist/lib/daemon-services.d.ts +1 -1
  64. package/dist/lib/daemon-services.js +5 -0
  65. package/dist/lib/device-config.d.ts +17 -6
  66. package/dist/lib/device-config.js +25 -11
  67. package/dist/lib/devices/connect.d.ts +17 -8
  68. package/dist/lib/devices/connect.js +31 -14
  69. package/dist/lib/devices/pool.d.ts +4 -3
  70. package/dist/lib/devices/pool.js +13 -5
  71. package/dist/lib/doctor-diff.js +77 -7
  72. package/dist/lib/exec.d.ts +6 -41
  73. package/dist/lib/exec.js +6 -41
  74. package/dist/lib/fleet/manifest.d.ts +17 -0
  75. package/dist/lib/fleet/manifest.js +26 -0
  76. package/dist/lib/git.d.ts +13 -1
  77. package/dist/lib/git.js +36 -7
  78. package/dist/lib/harness/adapter.d.ts +7 -7
  79. package/dist/lib/harness/adapters/claude.js +3 -2
  80. package/dist/lib/hooks/install.d.ts +27 -11
  81. package/dist/lib/hooks/install.js +42 -17
  82. package/dist/lib/hosts/reconnect.d.ts +52 -203
  83. package/dist/lib/hosts/reconnect.js +64 -284
  84. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  85. package/dist/lib/hosts/remote-cmd.js +22 -0
  86. package/dist/lib/installations/migrate.d.ts +6 -120
  87. package/dist/lib/installations/migrate.js +27 -259
  88. package/dist/lib/installations/shims.d.ts +13 -95
  89. package/dist/lib/installations/shims.js +22 -139
  90. package/dist/lib/installations/store.js +1 -1
  91. package/dist/lib/installations/versions.d.ts +26 -133
  92. package/dist/lib/installations/versions.js +41 -204
  93. package/dist/lib/perf/db.d.ts +1 -1
  94. package/dist/lib/perf/db.js +1 -1
  95. package/dist/lib/plugins/skills.d.ts +8 -1
  96. package/dist/lib/plugins/skills.js +18 -2
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/reaper.d.ts +28 -70
  108. package/dist/lib/secrets/reaper.js +30 -85
  109. package/dist/lib/secrets/remote.d.ts +42 -129
  110. package/dist/lib/secrets/remote.js +55 -173
  111. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  112. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  113. package/dist/lib/self-heal/registry.js +2 -0
  114. package/dist/lib/self-heal/types.d.ts +1 -1
  115. package/dist/lib/self-update.d.ts +23 -0
  116. package/dist/lib/self-update.js +50 -0
  117. package/dist/lib/session/active.d.ts +16 -32
  118. package/dist/lib/session/active.js +10 -68
  119. package/dist/lib/session/db.d.ts +24 -36
  120. package/dist/lib/session/db.js +143 -44
  121. package/dist/lib/session/discover.d.ts +6 -58
  122. package/dist/lib/session/discover.js +5 -43
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/parse.d.ts +1 -19
  126. package/dist/lib/session/parse.js +2 -15
  127. package/dist/lib/session/tool-calls.d.ts +43 -1
  128. package/dist/lib/session/tool-calls.js +74 -44
  129. package/dist/lib/session/tool-store.d.ts +33 -2
  130. package/dist/lib/session/tool-store.js +56 -3
  131. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  132. package/dist/lib/staleness/writers/sources.js +2 -1
  133. package/dist/lib/startup/command-registry.d.ts +8 -2
  134. package/dist/lib/startup/command-registry.js +12 -4
  135. package/dist/lib/sync-status.d.ts +22 -0
  136. package/dist/lib/sync-status.js +27 -0
  137. package/dist/lib/sync-umbrella.d.ts +9 -0
  138. package/dist/lib/sync-umbrella.js +21 -2
  139. package/dist/lib/traces/insights.d.ts +47 -14
  140. package/dist/lib/traces/insights.js +92 -21
  141. package/dist/lib/traces/phenotype.d.ts +23 -3
  142. package/dist/lib/traces/phenotype.js +72 -24
  143. package/dist/lib/traces/sync.d.ts +15 -0
  144. package/dist/lib/traces/sync.js +104 -19
  145. package/dist/lib/traces/worker-template.js +154 -1
  146. package/package.json +1 -1
@@ -10,15 +10,26 @@
10
10
  * floor, so an account racing toward its 5h cap loses priority before it maxes.
11
11
  */
12
12
  export declare const PROJECTION_HORIZON_MIN = 30;
13
+ /**
14
+ * The weight an account with NO usage snapshot draws. Absence of a usage signal
15
+ * is NOT capacity (specifications.md GWT-E5c, SING-1a): a null snapshot means
16
+ * "unverifiable", not "empty" — on a worker box every account reads null
17
+ * (setup-token lacks the `user:profile` scope, RUSH-2392), so scoring null as
18
+ * full capacity made the blind pool's top pick an account that was actually
19
+ * weekly-exhausted (PHNX-3392). Floored at 1, never 0: an all-unverified pool
20
+ * must still draw a pick rather than strand the launch.
21
+ */
22
+ export declare const UNVERIFIED_WEIGHT = 1;
13
23
  /**
14
24
  * Weight one candidate by remaining routing capacity, deprioritized by how soon
15
25
  * it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
16
- * an account with no live snapshot is treated as full-capacity (100) since there
17
- * is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
18
- * projection on the 5h session window) then scales that base: >= horizon (or
19
- * unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
20
- * so a launch avoids an account projected to cap soon, not just a 100%-maxed
21
- * one. Pure + exported so the deprioritization is unit-tested directly (a
22
- * weighted-random draw is not).
26
+ * an account with no live snapshot is NOT full-capacity — absence of a usage
27
+ * signal is not evidence of headroom, so it draws `UNVERIFIED_WEIGHT` and any
28
+ * verified-healthy account outranks it (GWT-E5c). `minutesToLimit` (the daemon's
29
+ * burn-rate projection on the 5h session window) then scales that base: >=
30
+ * horizon (or unknown) keeps full weight, and closer-to-cap scales toward the
31
+ * floor of 1 — so a launch avoids an account projected to cap soon, not just a
32
+ * 100%-maxed one. Pure + exported so the deprioritization is unit-tested
33
+ * directly (a weighted-random draw is not).
23
34
  */
24
35
  export declare function capacityWeight(usedPercent: number | null, minutesToLimit: number | null): number;
@@ -10,19 +10,30 @@
10
10
  * floor, so an account racing toward its 5h cap loses priority before it maxes.
11
11
  */
12
12
  export const PROJECTION_HORIZON_MIN = 30;
13
+ /**
14
+ * The weight an account with NO usage snapshot draws. Absence of a usage signal
15
+ * is NOT capacity (specifications.md GWT-E5c, SING-1a): a null snapshot means
16
+ * "unverifiable", not "empty" — on a worker box every account reads null
17
+ * (setup-token lacks the `user:profile` scope, RUSH-2392), so scoring null as
18
+ * full capacity made the blind pool's top pick an account that was actually
19
+ * weekly-exhausted (PHNX-3392). Floored at 1, never 0: an all-unverified pool
20
+ * must still draw a pick rather than strand the launch.
21
+ */
22
+ export const UNVERIFIED_WEIGHT = 1;
13
23
  /**
14
24
  * Weight one candidate by remaining routing capacity, deprioritized by how soon
15
25
  * it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
16
- * an account with no live snapshot is treated as full-capacity (100) since there
17
- * is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
18
- * projection on the 5h session window) then scales that base: >= horizon (or
19
- * unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
20
- * so a launch avoids an account projected to cap soon, not just a 100%-maxed
21
- * one. Pure + exported so the deprioritization is unit-tested directly (a
22
- * weighted-random draw is not).
26
+ * an account with no live snapshot is NOT full-capacity — absence of a usage
27
+ * signal is not evidence of headroom, so it draws `UNVERIFIED_WEIGHT` and any
28
+ * verified-healthy account outranks it (GWT-E5c). `minutesToLimit` (the daemon's
29
+ * burn-rate projection on the 5h session window) then scales that base: >=
30
+ * horizon (or unknown) keeps full weight, and closer-to-cap scales toward the
31
+ * floor of 1 — so a launch avoids an account projected to cap soon, not just a
32
+ * 100%-maxed one. Pure + exported so the deprioritization is unit-tested
33
+ * directly (a weighted-random draw is not).
23
34
  */
24
35
  export function capacityWeight(usedPercent, minutesToLimit) {
25
- const base = usedPercent === null ? 100 : Math.max(1, 100 - usedPercent);
36
+ const base = usedPercent === null ? UNVERIFIED_WEIGHT : Math.max(1, 100 - usedPercent);
26
37
  if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
27
38
  return base;
28
39
  const factor = Math.max(0, Math.min(1, minutesToLimit / PROJECTION_HORIZON_MIN));
@@ -0,0 +1 @@
1
+ export declare function runUsageIngest(): Promise<number>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The `agents __usage-ingest` receiver (PHNX-3392 usage-sync).
3
+ *
4
+ * A headed peer's daemon pipes a {@link UsageSyncPayload} JSON envelope to our
5
+ * stdin; we merge its identity-keyed rows into the local usage cache newest-wins
6
+ * ({@link ingestPeerClaudeUsageRows}). Hidden internal verb — intercepted in
7
+ * index.ts before bootstrap, so it never triggers an update check or a detached
8
+ * sync, and it writes NOTHING to stdout (the caller only reads the exit code).
9
+ *
10
+ * Exit codes: 0 = merged (or nothing to merge — an empty payload is not an error),
11
+ * 2 = malformed input. It fails loud on a bad envelope rather than silently
12
+ * accepting a wrong shape, but a busy cache lock degrades to best-effort inside
13
+ * `ingestPeerClaudeUsageRows` like every other cache writer.
14
+ *
15
+ * The payload arrives on stdin, EXCEPT on a Windows receiver: the `agents.ps1`
16
+ * shim does not forward ssh-piped stdin to the node process, so the pusher writes
17
+ * the payload to a temp file and passes `agents __usage-ingest --from <path>`
18
+ * (the same workaround the secrets push uses — `buildWindowsStdinImportCommand`).
19
+ */
20
+ import * as fs from 'fs';
21
+ import { ingestPeerClaudeUsageRows } from './usage.js';
22
+ function readStdin() {
23
+ return new Promise((resolve) => {
24
+ let buf = '';
25
+ process.stdin.setEncoding('utf-8');
26
+ process.stdin.on('data', (chunk) => {
27
+ buf += chunk;
28
+ });
29
+ process.stdin.on('end', () => resolve(buf));
30
+ process.stdin.on('error', () => resolve(buf));
31
+ });
32
+ }
33
+ /** `--from <path>` reads the payload from a file instead of stdin (Windows path). */
34
+ function fromFileArg(argv) {
35
+ const i = argv.indexOf('--from');
36
+ return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
37
+ }
38
+ export async function runUsageIngest() {
39
+ const fromPath = fromFileArg(process.argv.slice(3));
40
+ let source;
41
+ if (fromPath) {
42
+ try {
43
+ source = fs.readFileSync(fromPath, 'utf-8');
44
+ }
45
+ catch (err) {
46
+ process.stderr.write(`[agents] __usage-ingest: cannot read --from ${fromPath}: ${err.message}\n`);
47
+ return 2;
48
+ }
49
+ }
50
+ else {
51
+ source = await readStdin();
52
+ }
53
+ const raw = source.trim();
54
+ if (!raw)
55
+ return 0; // nothing piped — a no-op tick, not a failure.
56
+ let payload;
57
+ try {
58
+ payload = JSON.parse(raw);
59
+ }
60
+ catch {
61
+ process.stderr.write('[agents] __usage-ingest: malformed JSON payload\n');
62
+ return 2;
63
+ }
64
+ if (!payload ||
65
+ payload.v !== 1 ||
66
+ typeof payload.rows !== 'object' ||
67
+ payload.rows === null ||
68
+ Array.isArray(payload.rows) // `typeof [] === 'object'` — an array is NOT a rows map
69
+ ) {
70
+ process.stderr.write('[agents] __usage-ingest: unrecognized usage-sync payload shape\n');
71
+ return 2;
72
+ }
73
+ ingestPeerClaudeUsageRows(payload.rows);
74
+ return 0;
75
+ }
@@ -0,0 +1,97 @@
1
+ import { type DeviceProfile } from '../devices/registry.js';
2
+ import { type ConfiguredDeviceRole } from '../device-config.js';
3
+ import { type CachedUsageSnapshot, type UsageSnapshot } 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
+ /** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
8
+ export interface UsageSyncPayload {
9
+ v: 1;
10
+ rows: Record<string, CachedUsageSnapshot>;
11
+ }
12
+ /** One peer the local publisher is considering, reduced to the plan inputs. */
13
+ export interface UsagePushTarget {
14
+ name: string;
15
+ /** The peer's configured role — headed peers are skipped (own reader). */
16
+ role: ConfiguredDeviceRole | undefined;
17
+ /** Live-ish reachability from the tailscale snapshot; offline peers are skipped. */
18
+ online: boolean;
19
+ /** Managed known-hosts pin — an unpinned host is skipped, never TOFU-accepted. */
20
+ pinned: boolean;
21
+ }
22
+ export type UsageSyncPlanItem = {
23
+ action: 'push';
24
+ device: string;
25
+ } | {
26
+ action: 'skip';
27
+ device: string;
28
+ reason: string;
29
+ };
30
+ /**
31
+ * Decide, per peer, whether to push the local usage snapshot. Pure.
32
+ *
33
+ * - `selfIsPublisher` is false on a `worker`/unmarked box: it has no authoritative
34
+ * usage to publish, so every peer is skipped.
35
+ * - `hasLocalRows` false means the local cache is empty (nothing to teach yet).
36
+ * - A HEADED peer is skipped — it reads its own usage; pushing risks nothing
37
+ * (the merge is newest-wins) but is wasted work, and keeping the fan-out to
38
+ * consumers only makes the intent legible.
39
+ */
40
+ export declare function planUsagePush(selfIsPublisher: boolean, hasLocalRows: boolean, targets: UsagePushTarget[]): UsageSyncPlanItem[];
41
+ export interface UsageSyncResult {
42
+ pushed: string[];
43
+ skipped: Array<{
44
+ device: string;
45
+ reason: string;
46
+ }>;
47
+ errors: Array<{
48
+ device: string;
49
+ message: string;
50
+ }>;
51
+ }
52
+ export interface UsageSyncDeps {
53
+ selfRole?: () => ConfiguredDeviceRole | undefined;
54
+ listDevices?: () => DeviceProfile[];
55
+ /** Configured roles by device name; default reads the fleet config. */
56
+ listRoles?: () => Record<string, ConfiguredDeviceRole>;
57
+ localName?: () => string;
58
+ isPinned?: (name: string) => boolean;
59
+ exportRows?: () => Record<string, CachedUsageSnapshot>;
60
+ /** Deliver the serialized payload to one peer. Default: ssh `__usage-ingest`. */
61
+ push?: (device: DeviceProfile, payload: string) => {
62
+ ok: boolean;
63
+ message?: string;
64
+ };
65
+ }
66
+ export interface UsagePullResult {
67
+ pulledFrom: string | null;
68
+ merged: number;
69
+ skipped: string | null;
70
+ error: string | null;
71
+ }
72
+ export interface UsagePullDeps {
73
+ selfRole?: () => ConfiguredDeviceRole | undefined;
74
+ listDevices?: () => DeviceProfile[];
75
+ listRoles?: () => Record<string, ConfiguredDeviceRole>;
76
+ isPinned?: (name: string) => boolean;
77
+ exportRows?: () => Record<string, CachedUsageSnapshot>;
78
+ readRow?: (usageKey: string) => Pick<UsageSnapshot, 'windows'> | null;
79
+ ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
80
+ /** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
81
+ pull?: (device: DeviceProfile) => {
82
+ ok: boolean;
83
+ stdout?: string;
84
+ message?: string;
85
+ };
86
+ }
87
+ /**
88
+ * Pull usage from the fleet's primary headed device when this worker's local
89
+ * cache is empty or contains an expired row. `personal` is the primary role;
90
+ * a `desktop` is used only when the fleet has no personal device.
91
+ */
92
+ export declare function pullUsageFromPrimary(deps?: UsagePullDeps): UsagePullResult;
93
+ /**
94
+ * Push the local identity-keyed usage snapshot to every reachable, pinned,
95
+ * non-headed peer. A no-op on a non-headed box or when the local cache is empty.
96
+ */
97
+ export declare function syncFleetUsageSnapshots(deps?: UsageSyncDeps): UsageSyncResult;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Fleet sync of the identity-keyed Claude usage snapshot (PHNX-3392 follow-up).
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.
26
+ */
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 } from '../devices/known-hosts.js';
33
+ import { machineId, normalizeHost } from '../session/sync/config.js';
34
+ import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
35
+ import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, readClaudeUsageCache, } 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
+ * 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 function planUsagePush(selfIsPublisher, hasLocalRows, targets) {
50
+ if (!selfIsPublisher) {
51
+ return targets.map((d) => ({
52
+ action: 'skip',
53
+ device: d.name,
54
+ reason: 'this device is not a usage publisher (mark it personal or desktop)',
55
+ }));
56
+ }
57
+ if (!hasLocalRows) {
58
+ return targets.map((d) => ({ action: 'skip', device: d.name, reason: 'no local usage snapshot to publish' }));
59
+ }
60
+ return targets.map((d) => {
61
+ if (isHeadedDeviceRole(d.role))
62
+ return { action: 'skip', device: d.name, reason: 'headed peer reads its own usage' };
63
+ if (!d.online)
64
+ return { action: 'skip', device: d.name, reason: 'offline' };
65
+ if (!d.pinned) {
66
+ return { action: 'skip', device: d.name, reason: `host key not pinned; run \`agents ssh ${d.name}\` once` };
67
+ }
68
+ return { action: 'push', device: d.name };
69
+ });
70
+ }
71
+ /** Production push: pipe the payload to the peer's `agents __usage-ingest` over ssh. */
72
+ function defaultUsagePush(device, payload) {
73
+ const target = sshTargetFor(device);
74
+ const os = resolveRemoteOsSync(device.name);
75
+ // A Windows peer's `agents.ps1` shim does not forward ssh-piped stdin, so hand
76
+ // the payload through a temp file (`--from`) instead of stdin — same workaround
77
+ // the secrets push uses. A POSIX peer reads stdin directly.
78
+ const remoteCmd = remoteShellFor(os) === 'powershell'
79
+ ? buildWindowsStdinAgentsCommand(['__usage-ingest'])
80
+ : buildRemoteAgentsInvocation(['__usage-ingest'], undefined, os);
81
+ const res = sshExec(target, remoteCmd, { input: payload, timeoutMs: USAGE_PUSH_DEADLINE_MS });
82
+ if (res.timedOut)
83
+ return { ok: false, message: 'timed out' };
84
+ if (res.code !== 0)
85
+ return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
86
+ return { ok: true };
87
+ }
88
+ /** Production pull: read the primary's cache through its hidden export verb. */
89
+ function defaultUsagePull(device) {
90
+ const target = sshTargetFor(device);
91
+ const os = resolveRemoteOsSync(device.name);
92
+ const remoteCmd = buildRemoteAgentsInvocation(['__usage-export'], undefined, os);
93
+ const res = sshExec(target, remoteCmd, { timeoutMs: USAGE_PULL_DEADLINE_MS });
94
+ if (res.timedOut)
95
+ return { ok: false, message: 'timed out' };
96
+ if (res.code !== 0)
97
+ return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
98
+ return { ok: true, stdout: res.stdout };
99
+ }
100
+ /**
101
+ * Pull usage from the fleet's primary headed device when this worker's local
102
+ * cache is empty or contains an expired row. `personal` is the primary role;
103
+ * a `desktop` is used only when the fleet has no personal device.
104
+ */
105
+ export function pullUsageFromPrimary(deps = {}) {
106
+ const result = { pulledFrom: null, merged: 0, skipped: null, error: null };
107
+ if ((deps.selfRole ?? selfConfiguredDeviceRole)() !== 'worker') {
108
+ result.skipped = 'this device is not a worker';
109
+ return result;
110
+ }
111
+ const localRows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
112
+ const readRow = deps.readRow ?? readClaudeUsageCache;
113
+ const localEntries = Object.entries(localRows);
114
+ if (localEntries.length > 0 && localEntries.every(([key, row]) => {
115
+ const fresh = readRow(key);
116
+ return fresh !== null && fresh.windows.length === row.windows.length;
117
+ })) {
118
+ result.skipped = 'local usage cache is fresh';
119
+ return result;
120
+ }
121
+ const devices = deps.listDevices?.() ?? Object.values(loadDevicesSync());
122
+ const roles = deps.listRoles?.() ?? (deps.listDevices
123
+ ? listConfiguredDeviceRoles(devices.map((device) => device.name))
124
+ : listConfiguredDeviceRoles());
125
+ const isPinned = deps.isPinned ?? isHostPinned;
126
+ const primary = devices
127
+ .filter((device) => roles[device.name] === 'personal')
128
+ .concat(devices.filter((device) => roles[device.name] === 'desktop'))
129
+ .find((device) => device.tailscale?.online !== false && isPinned(device.name));
130
+ if (!primary) {
131
+ result.error = 'no reachable, pinned personal or desktop device is configured as the usage primary';
132
+ return result;
133
+ }
134
+ const outcome = (deps.pull ?? defaultUsagePull)(primary);
135
+ if (!outcome.ok) {
136
+ result.error = outcome.message ?? 'pull failed';
137
+ return result;
138
+ }
139
+ let payload;
140
+ try {
141
+ // A Windows usage primary's `agents.ps1` shim prepends a PowerShell CLIXML
142
+ // progress banner to stdout, exactly as every other remote-JSON boundary
143
+ // strips (remote-cmd.ts:325). Without this, a headed Windows box (win-mini
144
+ // is a live fleet device) makes every pull fail as "malformed JSON", the
145
+ // worker's cache stays null, and the PHNX-3392 capacity floor silently
146
+ // becomes the only thing standing between a blind pool and an exhausted pick.
147
+ payload = JSON.parse(stripClixml(outcome.stdout ?? ''));
148
+ }
149
+ catch {
150
+ result.error = 'primary returned malformed JSON';
151
+ return result;
152
+ }
153
+ if (!payload || payload.v !== 1 || typeof payload.rows !== 'object' || payload.rows === null || Array.isArray(payload.rows)) {
154
+ result.error = 'primary returned an unrecognized usage-sync payload shape';
155
+ return result;
156
+ }
157
+ result.pulledFrom = primary.name;
158
+ result.merged = (deps.ingestRows ?? ingestPeerClaudeUsageRows)(payload.rows);
159
+ return result;
160
+ }
161
+ /**
162
+ * Push the local identity-keyed usage snapshot to every reachable, pinned,
163
+ * non-headed peer. A no-op on a non-headed box or when the local cache is empty.
164
+ */
165
+ export function syncFleetUsageSnapshots(deps = {}) {
166
+ const result = { pushed: [], skipped: [], errors: [] };
167
+ const selfRole = (deps.selfRole ?? selfConfiguredDeviceRole)();
168
+ const devices = deps.listDevices?.() ?? Object.values(loadDevicesSync());
169
+ const self = deps.localName?.() ?? machineId();
170
+ const selfNorm = normalizeHost(self);
171
+ const roles = deps.listRoles?.() ?? (deps.listDevices ? listConfiguredDeviceRoles(devices.map((d) => d.name)) : listConfiguredDeviceRoles());
172
+ const isPinned = deps.isPinned ?? isHostPinned;
173
+ const byName = new Map(devices.map((d) => [d.name, d]));
174
+ const targets = devices
175
+ .filter((d) => normalizeHost(d.name) !== selfNorm)
176
+ .map((d) => ({
177
+ name: d.name,
178
+ role: roles[d.name],
179
+ online: d.tailscale?.online !== false,
180
+ pinned: isPinned(d.name),
181
+ }));
182
+ const rows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
183
+ const plan = planUsagePush(isHeadedDeviceRole(selfRole), Object.keys(rows).length > 0, targets);
184
+ const payload = JSON.stringify({ v: 1, rows });
185
+ const push = deps.push ?? defaultUsagePush;
186
+ for (const item of plan) {
187
+ if (item.action === 'skip') {
188
+ result.skipped.push({ device: item.device, reason: item.reason });
189
+ continue;
190
+ }
191
+ const device = byName.get(item.device);
192
+ if (!device) {
193
+ result.errors.push({ device: item.device, message: 'device left the registry mid-sync' });
194
+ continue;
195
+ }
196
+ const outcome = push(device, payload);
197
+ if (outcome.ok)
198
+ result.pushed.push(item.device);
199
+ else
200
+ result.errors.push({ device: item.device, message: outcome.message ?? 'push failed' });
201
+ }
202
+ return result;
203
+ }
@@ -228,8 +228,8 @@ interface UsageOptions {
228
228
  * When true, a read that finds no file-based setup-token MAY fall through to
229
229
  * Claude Code's interactive OAuth login (the only credential carrying
230
230
  * `user:profile`, which `/api/oauth/usage` requires). OFF by default and set
231
- * ONLY by a foreground human `agents view` on a `personal` device (see
232
- * USAGE-READ-2). Every background caller — daemon usage warm, auth-health
231
+ * ONLY by a foreground human `agents view` on a headed device (personal or
232
+ * desktop; see USAGE-READ-2). Every background caller — daemon usage warm, auth-health
233
233
  * probe, watchdog — leaves it unset, preserving the RUSH-1822 guarantee that
234
234
  * an unattended loop never transmits the interactive login to Anthropic.
235
235
  */
@@ -252,6 +252,26 @@ interface ClaudeOauthCredentials {
252
252
  rateLimitTier?: string | null;
253
253
  organizationUuid?: string | null;
254
254
  }
255
+ /** Serialized usage window for the on-disk cache. */
256
+ interface CachedUsageWindow {
257
+ key: UsageWindowKey;
258
+ label: string;
259
+ shortLabel: string;
260
+ usedPercent: number;
261
+ resetsAt: string | null;
262
+ windowMinutes: number | null;
263
+ }
264
+ /** Serialized usage snapshot for the on-disk cache. */
265
+ export interface CachedUsageSnapshot {
266
+ capturedAt: string | null;
267
+ windows: CachedUsageWindow[];
268
+ plan?: string | null;
269
+ refreshHint?: string | null;
270
+ unavailable?: {
271
+ reason: 'session_limit' | 'out_of_credits';
272
+ resetsAt?: string;
273
+ };
274
+ }
255
275
  /** The single registry of agent usage sources and their transport. */
256
276
  declare const USAGE_SOURCES: {
257
277
  readonly claude: {
@@ -666,6 +686,32 @@ export declare function pruneExpiredClaudeUsageCacheEntry(usageKey: string, cach
666
686
  export declare function writeClaudeUsageCache(usageKey: string, snapshot: UsageSnapshot, cachePath?: string): void;
667
687
  /** Atomically merge partial native Claude windows into the current fresh row. */
668
688
  export declare function mergeClaudeUsageCacheWindows(usageKey: string, snapshot: UsageSnapshot, cachePath?: string): void;
689
+ /**
690
+ * Export the local usage cache rows worth publishing to fleet peers (PHNX-3392
691
+ * usage-sync). Returns the raw serialized rows keyed by usage identity, filtered
692
+ * to those carrying at least one window — an empty row has nothing to teach a
693
+ * worker. The transport is the on-disk cache form, so there is no Date round-trip.
694
+ */
695
+ export declare function exportClaudeUsageCacheRows(cachePath?: string): Record<string, CachedUsageSnapshot>;
696
+ /**
697
+ * Merge usage rows received from a fleet peer into the local cache, NEWEST-WINS
698
+ * per identity by `capturedAt` (PHNX-3392 usage-sync). A worker has no local
699
+ * usage writer, so an incoming row is almost always the freshest it will get; the
700
+ * timestamp guard exists so a stale push from one headed peer can never overwrite
701
+ * a fresher row another peer (or, on a headed receiver, the local status-line)
702
+ * already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
703
+ * it never displaces an existing timestamped row. Returns the count updated.
704
+ * Locked + atomic like every other cache writer.
705
+ *
706
+ * Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
707
+ * is a SENDER-side optimization (don't waste a push on a headed peer that reads
708
+ * its own usage), not a safety invariant — the actual safety property is this
709
+ * newest-wins guard. Receiving on a headed box is harmless (its fresher local
710
+ * status-line row survives) or helpful (an account it is signed into but never
711
+ * runs now shows a usage bar), so gating here on the receiver's own — laggier —
712
+ * view of its role would only reject legitimate data.
713
+ */
714
+ export declare function ingestPeerClaudeUsageRows(rows: Record<string, CachedUsageSnapshot>, cachePath?: string): number;
669
715
  /**
670
716
  * Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
671
717
  * spend limit`) from a real run. Unlike a rate/session limit this does NOT reset
@@ -284,6 +284,12 @@ const USAGE_BAR_LEN = 10;
284
284
  const FULL = '\u2588';
285
285
  const EMPTY = '\u2591';
286
286
  const PARTIAL_BLOCKS = ['', '\u258F', '\u258E', '\u258D', '\u258C', '\u258B', '\u258A', '\u2589'];
287
+ // A window we EXPECTED but have no reading for \u2014 e.g. Claude's 5h "session"
288
+ // window when the account has no usage in the current rolling window, so the
289
+ // usage API returns five_hour.utilization = null and no session bar is written.
290
+ // It must read as neither 0% (EMPTY '\u2591') nor 100% (FULL '\u2588') \u2014 a full block was
291
+ // alarming and looked maxed-out \u2014 so use a dashed row that says "no data".
292
+ const NO_DATA = '\u2504';
287
293
  /** Construct the benign no-local-log result without overloading `error`. */
288
294
  export function usageNoRecentUsageInfo() {
289
295
  return { snapshot: null, error: null, [USAGE_BENIGN_STATE]: 'no-recent-usage' };
@@ -590,7 +596,7 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
590
596
  : selected.map((window) => ({ window, shortLabel: window.shortLabel }));
591
597
  const windowParts = windowsToRender.map(({ window, shortLabel }, index) => {
592
598
  if (!window) {
593
- const missing = chalk.red(`${shortLabel}: ${FULL.repeat(COMPACT_BAR_LEN)} unavailable`);
599
+ const missing = chalk.dim(`${shortLabel}: ${NO_DATA.repeat(COMPACT_BAR_LEN)} unavailable`);
594
600
  return index < windowsToRender.length - 1 ? padToWidth(missing, 20) : missing;
595
601
  }
596
602
  const bar = renderCompactUsageBar(window.usedPercent);
@@ -1558,7 +1564,7 @@ export async function loadClaudeOauth(home, opts) {
1558
1564
  // setup-token via the mint-auth path to restore usage/probe for the account.
1559
1565
  //
1560
1566
  // The single sanctioned exception (USAGE-READ-1/2): a foreground human
1561
- // `agents view` on a `personal` device sets allowInteractiveLogin, and only
1567
+ // `agents view` on a headed device (personal or desktop) sets allowInteractiveLogin, and only
1562
1568
  // then do we fall through to the interactive-login read below — the one
1563
1569
  // credential carrying `user:profile`, which the usage endpoint requires. This
1564
1570
  // is a human running one command, not an unattended loop, so it is not the
@@ -1750,6 +1756,77 @@ export function mergeClaudeUsageCacheWindows(usageKey, snapshot, cachePath = get
1750
1756
  /* best-effort cache write — lock busy or disk full */
1751
1757
  }
1752
1758
  }
1759
+ /**
1760
+ * Export the local usage cache rows worth publishing to fleet peers (PHNX-3392
1761
+ * usage-sync). Returns the raw serialized rows keyed by usage identity, filtered
1762
+ * to those carrying at least one window — an empty row has nothing to teach a
1763
+ * worker. The transport is the on-disk cache form, so there is no Date round-trip.
1764
+ */
1765
+ export function exportClaudeUsageCacheRows(cachePath = getClaudeUsageCachePath()) {
1766
+ const cache = readClaudeUsageCacheFile(cachePath);
1767
+ const out = {};
1768
+ for (const [key, row] of Object.entries(cache)) {
1769
+ if (row && Array.isArray(row.windows) && row.windows.length > 0)
1770
+ out[key] = row;
1771
+ }
1772
+ return out;
1773
+ }
1774
+ function parseCapturedAtMs(capturedAt) {
1775
+ if (!capturedAt)
1776
+ return null;
1777
+ const ms = Date.parse(capturedAt);
1778
+ return Number.isFinite(ms) ? ms : null;
1779
+ }
1780
+ /**
1781
+ * Merge usage rows received from a fleet peer into the local cache, NEWEST-WINS
1782
+ * per identity by `capturedAt` (PHNX-3392 usage-sync). A worker has no local
1783
+ * usage writer, so an incoming row is almost always the freshest it will get; the
1784
+ * timestamp guard exists so a stale push from one headed peer can never overwrite
1785
+ * a fresher row another peer (or, on a headed receiver, the local status-line)
1786
+ * already wrote. An incoming row with no `capturedAt` cannot prove it is newer, so
1787
+ * it never displaces an existing timestamped row. Returns the count updated.
1788
+ * Locked + atomic like every other cache writer.
1789
+ *
1790
+ * Deliberately NOT role-gated on the receiver. "Consume only on worker/unmarked"
1791
+ * is a SENDER-side optimization (don't waste a push on a headed peer that reads
1792
+ * its own usage), not a safety invariant — the actual safety property is this
1793
+ * newest-wins guard. Receiving on a headed box is harmless (its fresher local
1794
+ * status-line row survives) or helpful (an account it is signed into but never
1795
+ * runs now shows a usage bar), so gating here on the receiver's own — laggier —
1796
+ * view of its role would only reject legitimate data.
1797
+ */
1798
+ export function ingestPeerClaudeUsageRows(rows, cachePath = getClaudeUsageCachePath()) {
1799
+ const incoming = Object.entries(rows).filter(([, row]) => row && Array.isArray(row.windows) && row.windows.length > 0);
1800
+ if (incoming.length === 0)
1801
+ return 0;
1802
+ let merged = 0;
1803
+ try {
1804
+ ensureLockTarget(cachePath, '{}');
1805
+ withFileLock(cachePath, () => {
1806
+ const cache = readClaudeUsageCacheFile(cachePath);
1807
+ for (const [key, row] of incoming) {
1808
+ const prior = cache[key];
1809
+ if (prior) {
1810
+ const priorMs = parseCapturedAtMs(prior.capturedAt);
1811
+ const incomingMs = parseCapturedAtMs(row.capturedAt);
1812
+ // Keep local unless the incoming row PROVES it is strictly newer.
1813
+ if (incomingMs === null)
1814
+ continue;
1815
+ if (priorMs !== null && priorMs >= incomingMs)
1816
+ continue;
1817
+ }
1818
+ cache[key] = row;
1819
+ merged += 1;
1820
+ }
1821
+ if (merged > 0)
1822
+ writeClaudeUsageCacheFile(cache, cachePath);
1823
+ });
1824
+ }
1825
+ catch {
1826
+ /* best-effort cache write — lock busy or disk full */
1827
+ }
1828
+ return merged;
1829
+ }
1753
1830
  /** Read the entire usage cache file from disk. */
1754
1831
  function readClaudeUsageCacheFile(cachePath) {
1755
1832
  if (!fs.existsSync(cachePath)) {
@@ -3093,7 +3093,7 @@ export function parseAgentVersionSpec(raw) {
3093
3093
  const [rawAgent, rawVersion] = parts;
3094
3094
  const agent = resolveAgentName(rawAgent);
3095
3095
  if (!agent) {
3096
- return { error: `Unknown agent, profile, or workflow: ${rawAgent}. See \`agents list\` for the installed harnesses.` };
3096
+ return { error: `Unknown agent, profile, or workflow: ${rawAgent}. See \`agents view\` for the installed harnesses.` };
3097
3097
  }
3098
3098
  if (rawVersion !== undefined && (rawVersion === '' || !VERSION_RE.test(rawVersion))) {
3099
3099
  return { error: `Invalid version '${rawVersion}' in '${raw}'` };