@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.
- package/CHANGELOG.md +70 -0
- package/README.md +4 -4
- package/dist/bootstrap.js +11 -2
- package/dist/cli/command-registry.d.ts +0 -1
- package/dist/cli/command-registry.js +0 -3
- package/dist/commands/accounts.js +7 -3
- package/dist/commands/apply.js +10 -2
- package/dist/commands/exec.js +1 -1
- package/dist/commands/fork.d.ts +23 -10
- package/dist/commands/fork.js +115 -58
- package/dist/commands/hooks.js +4 -4
- package/dist/commands/insights.d.ts +7 -5
- package/dist/commands/insights.js +16 -9
- package/dist/commands/monitors.js +11 -0
- package/dist/commands/perf.d.ts +16 -7
- package/dist/commands/perf.js +29 -20
- package/dist/commands/prune.js +5 -3
- package/dist/commands/routines.d.ts +8 -0
- package/dist/commands/routines.js +57 -3
- package/dist/commands/rules.js +1 -1
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +16 -0
- package/dist/commands/sessions.js +1 -0
- package/dist/commands/share.d.ts +14 -0
- package/dist/commands/share.js +43 -2
- package/dist/commands/ssh.js +24 -14
- package/dist/commands/status.js +1 -1
- package/dist/commands/sync.js +83 -7
- package/dist/commands/traces.js +7 -0
- package/dist/commands/trash.d.ts +2 -2
- package/dist/commands/trash.js +2 -6
- package/dist/commands/versions.d.ts +2 -2
- package/dist/commands/versions.js +1 -10
- package/dist/commands/view.d.ts +2 -2
- package/dist/commands/view.js +7 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +14 -0
- package/dist/lib/account-registry.d.ts +5 -1
- package/dist/lib/account-registry.js +47 -14
- package/dist/lib/accounting/capacity.d.ts +18 -7
- package/dist/lib/accounting/capacity.js +19 -8
- package/dist/lib/accounting/usage-ingest.d.ts +1 -0
- package/dist/lib/accounting/usage-ingest.js +75 -0
- package/dist/lib/accounting/usage-sync.d.ts +97 -0
- package/dist/lib/accounting/usage-sync.js +203 -0
- package/dist/lib/accounting/usage.d.ts +48 -2
- package/dist/lib/accounting/usage.js +79 -2
- package/dist/lib/agent-spec/agents.js +1 -1
- package/dist/lib/analytics/mix-commands.d.ts +8 -7
- package/dist/lib/analytics/mix-commands.js +50 -73
- package/dist/lib/auth-mint.d.ts +11 -1
- package/dist/lib/auth-mint.js +21 -6
- package/dist/lib/browser/ipc.d.ts +8 -0
- package/dist/lib/browser/ipc.js +87 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +96 -11
- package/dist/lib/browser/sessions-list.js +10 -1
- package/dist/lib/daemon/daemon.js +5 -0
- package/dist/lib/daemon/runner.d.ts +3 -0
- package/dist/lib/daemon/runner.js +95 -53
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
- package/dist/lib/daemon/usage-sync-service.js +42 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +17 -6
- package/dist/lib/device-config.js +25 -11
- package/dist/lib/devices/connect.d.ts +17 -8
- package/dist/lib/devices/connect.js +31 -14
- package/dist/lib/devices/pool.d.ts +4 -3
- package/dist/lib/devices/pool.js +13 -5
- package/dist/lib/doctor-diff.js +77 -7
- package/dist/lib/exec.d.ts +6 -41
- package/dist/lib/exec.js +6 -41
- package/dist/lib/fleet/manifest.d.ts +17 -0
- package/dist/lib/fleet/manifest.js +26 -0
- package/dist/lib/git.d.ts +13 -1
- package/dist/lib/git.js +36 -7
- package/dist/lib/harness/adapter.d.ts +7 -7
- package/dist/lib/harness/adapters/claude.js +3 -2
- package/dist/lib/hooks/install.d.ts +27 -11
- package/dist/lib/hooks/install.js +42 -17
- package/dist/lib/hosts/reconnect.d.ts +52 -203
- package/dist/lib/hosts/reconnect.js +64 -284
- package/dist/lib/hosts/remote-cmd.d.ts +9 -0
- package/dist/lib/hosts/remote-cmd.js +22 -0
- package/dist/lib/installations/migrate.d.ts +6 -120
- package/dist/lib/installations/migrate.js +27 -259
- package/dist/lib/installations/shims.d.ts +13 -95
- package/dist/lib/installations/shims.js +22 -139
- package/dist/lib/installations/store.js +1 -1
- package/dist/lib/installations/versions.d.ts +26 -133
- package/dist/lib/installations/versions.js +41 -204
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +1 -1
- package/dist/lib/plugins/skills.d.ts +8 -1
- package/dist/lib/plugins/skills.js +18 -2
- package/dist/lib/refresh.d.ts +9 -0
- package/dist/lib/refresh.js +3 -1
- package/dist/lib/routine-readiness.d.ts +15 -1
- package/dist/lib/routine-readiness.js +41 -0
- package/dist/lib/sandbox.d.ts +4 -1
- package/dist/lib/sandbox.js +30 -1
- package/dist/lib/secrets/agent.d.ts +80 -225
- package/dist/lib/secrets/agent.js +139 -401
- package/dist/lib/secrets/bundles.d.ts +73 -222
- package/dist/lib/secrets/bundles.js +168 -467
- package/dist/lib/secrets/reaper.d.ts +28 -70
- package/dist/lib/secrets/reaper.js +30 -85
- package/dist/lib/secrets/remote.d.ts +42 -129
- package/dist/lib/secrets/remote.js +55 -173
- package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
- package/dist/lib/self-heal/checks/install-staging.js +96 -0
- package/dist/lib/self-heal/registry.js +2 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/self-update.d.ts +23 -0
- package/dist/lib/self-update.js +50 -0
- package/dist/lib/session/active.d.ts +16 -32
- package/dist/lib/session/active.js +10 -68
- package/dist/lib/session/db.d.ts +24 -36
- package/dist/lib/session/db.js +143 -44
- package/dist/lib/session/discover.d.ts +6 -58
- package/dist/lib/session/discover.js +5 -43
- package/dist/lib/session/fork.d.ts +45 -26
- package/dist/lib/session/fork.js +32 -95
- package/dist/lib/session/parse.d.ts +1 -19
- package/dist/lib/session/parse.js +2 -15
- package/dist/lib/session/tool-calls.d.ts +43 -1
- package/dist/lib/session/tool-calls.js +74 -44
- package/dist/lib/session/tool-store.d.ts +33 -2
- package/dist/lib/session/tool-store.js +56 -3
- package/dist/lib/staleness/writers/sources.d.ts +5 -0
- package/dist/lib/staleness/writers/sources.js +2 -1
- package/dist/lib/startup/command-registry.d.ts +8 -2
- package/dist/lib/startup/command-registry.js +12 -4
- package/dist/lib/sync-status.d.ts +22 -0
- package/dist/lib/sync-status.js +27 -0
- package/dist/lib/sync-umbrella.d.ts +9 -0
- package/dist/lib/sync-umbrella.js +21 -2
- package/dist/lib/traces/insights.d.ts +47 -14
- package/dist/lib/traces/insights.js +92 -21
- package/dist/lib/traces/phenotype.d.ts +23 -3
- package/dist/lib/traces/phenotype.js +72 -24
- package/dist/lib/traces/sync.d.ts +15 -0
- package/dist/lib/traces/sync.js +104 -19
- package/dist/lib/traces/worker-template.js +154 -1
- 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
|
|
17
|
-
* is
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
17
|
-
* is
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 ?
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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}'` };
|