@phnx-labs/agents-cli 1.22.66 → 1.22.68
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 +26 -0
- package/README.md +7 -2
- package/dist/bootstrap.js +3 -3
- package/dist/commands/browser.js +46 -21
- package/dist/commands/daemon-test-harness.d.ts +1 -1
- package/dist/commands/daemon-test-harness.js +2 -2
- package/dist/commands/daemon.js +21 -22
- package/dist/commands/exec.js +40 -0
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/repo.js +57 -19
- package/dist/commands/resume.d.ts +16 -0
- package/dist/commands/resume.js +41 -8
- package/dist/commands/routines.js +42 -21
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +33 -9
- package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
- package/dist/lib/accounting/account-pool-collect.js +6 -4
- package/dist/lib/accounting/usage-ingest.js +4 -2
- package/dist/lib/accounting/usage-sync.d.ts +38 -93
- package/dist/lib/accounting/usage-sync.js +66 -210
- package/dist/lib/accounting/usage.d.ts +11 -3
- package/dist/lib/accounting/usage.js +23 -10
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/browser/caller-identity.d.ts +12 -0
- package/dist/lib/browser/caller-identity.js +19 -0
- package/dist/lib/browser/ipc.d.ts +37 -32
- package/dist/lib/browser/ipc.js +146 -94
- package/dist/lib/browser/task-index.d.ts +10 -2
- package/dist/lib/browser/task-index.js +22 -3
- package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
- package/dist/lib/daemon/account-state-daemon-service.js +81 -18
- package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
- package/dist/lib/daemon/auth-sync-service.js +17 -6
- package/dist/lib/daemon/catchup-service.d.ts +51 -0
- package/dist/lib/daemon/catchup-service.js +51 -0
- package/dist/lib/daemon/daemon.d.ts +12 -22
- package/dist/lib/daemon/daemon.js +463 -176
- package/dist/lib/daemon/runner.js +2 -0
- package/dist/lib/daemon/service.d.ts +22 -4
- package/dist/lib/daemon/service.js +2 -2
- package/dist/lib/daemon/supervisor.d.ts +55 -15
- package/dist/lib/daemon/supervisor.js +119 -29
- package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
- package/dist/lib/daemon/usage-sync-service.js +22 -18
- package/dist/lib/daemon-health.js +36 -31
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +12 -2
- package/dist/lib/daemon-ticks.d.ts +9 -6
- package/dist/lib/daemon-ticks.js +14 -8
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/exec.js +11 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
- package/dist/lib/fleet-shared-repo-sync.js +333 -0
- package/dist/lib/fleet-shared-state.d.ts +38 -0
- package/dist/lib/fleet-shared-state.js +105 -0
- package/dist/lib/hosts/remote-cmd.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.js +12 -3
- package/dist/lib/lock-compromise.d.ts +8 -0
- package/dist/lib/lock-compromise.js +12 -0
- package/dist/lib/monitors/engine.d.ts +2 -1
- package/dist/lib/monitors/engine.js +9 -2
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +13 -0
- package/dist/lib/run-trace-sync.js +77 -0
- package/dist/lib/secrets/push.d.ts +10 -0
- package/dist/lib/secrets/push.js +86 -7
- package/dist/lib/secrets/remote.d.ts +18 -6
- package/dist/lib/secrets/remote.js +29 -4
- package/dist/lib/secrets/reserved-sync.d.ts +28 -27
- package/dist/lib/secrets/reserved-sync.js +119 -101
- package/dist/lib/session/active.d.ts +3 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/actor-sidecar.d.ts +12 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +5 -2
- package/dist/lib/session/db.js +54 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +106 -25
- package/dist/lib/session/prompt.d.ts +11 -0
- package/dist/lib/session/prompt.js +25 -0
- package/dist/lib/session/recovery.d.ts +30 -2
- package/dist/lib/session/recovery.js +56 -4
- package/dist/lib/session/remote/remote-list.d.ts +17 -1
- package/dist/lib/session/remote/remote-list.js +29 -4
- package/dist/lib/session/remote/watch.d.ts +25 -2
- package/dist/lib/session/remote/watch.js +185 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/publish.d.ts +55 -9
- package/dist/lib/share/publish.js +87 -9
- package/dist/lib/share/worker-template.js +100 -8
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
- package/dist/lib/testdata/daemon-health-writer.js +8 -0
- package/dist/lib/traces/sync.d.ts +7 -0
- package/dist/lib/traces/sync.js +9 -0
- package/dist/lib/usage-refresh.d.ts +8 -2
- package/dist/lib/usage-refresh.js +3 -3
- package/package.json +1 -1
- package/dist/lib/account-state-service.d.ts +0 -21
- package/dist/lib/account-state-service.js +0 -60
|
@@ -1,231 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fleet
|
|
2
|
+
* Fleet distribution of identity-keyed Claude usage through the user repo.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
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
|
-
/**
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
182
|
-
result.error =
|
|
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
|
-
*
|
|
191
|
-
*
|
|
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
|
|
194
|
-
const result = {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
}
|
|
@@ -229,6 +229,12 @@ interface UsageOptions {
|
|
|
229
229
|
* absent, the backoff stays provider-wide.
|
|
230
230
|
*/
|
|
231
231
|
usageScope?: string | null;
|
|
232
|
+
/**
|
|
233
|
+
* Caller-supplied abort signal (the daemon tick's deadline). Combined with each
|
|
234
|
+
* provider fetch's own timeout so a hung refresh is bounded by BOTH the
|
|
235
|
+
* per-fetch timeout and the supervisor deadline (PHNX-3608).
|
|
236
|
+
*/
|
|
237
|
+
signal?: AbortSignal;
|
|
232
238
|
/**
|
|
233
239
|
* When true, never open the ACL-bound OS keychain item (macOS Touch ID).
|
|
234
240
|
* Daemon usage refresh sets this so a background tick cannot pop biometrics.
|
|
@@ -362,6 +368,8 @@ export declare const USAGE_FETCH_CONCURRENCY = 3;
|
|
|
362
368
|
export interface UsageLookupOptions {
|
|
363
369
|
forceRefresh?: boolean;
|
|
364
370
|
fileOnly?: boolean;
|
|
371
|
+
/** Daemon tick deadline signal, combined with each provider fetch's own timeout (PHNX-3608). */
|
|
372
|
+
signal?: AbortSignal;
|
|
365
373
|
/**
|
|
366
374
|
* Permit a foreground personal-device usage read to fall through to the
|
|
367
375
|
* interactive login when no setup-token exists (USAGE-READ-2). Set ONLY by
|
|
@@ -626,11 +634,11 @@ export interface ProviderProbe {
|
|
|
626
634
|
reason?: 'usage_scope';
|
|
627
635
|
}
|
|
628
636
|
/** 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>;
|
|
637
|
+
export declare function probeClaudeStatus(home?: string, cliVersion?: string | null, usageScope?: string | null, signal?: AbortSignal): Promise<ProviderProbe>;
|
|
630
638
|
/** 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>;
|
|
639
|
+
export declare function probeKimiStatus(home?: string, usageScope?: string | null, signal?: AbortSignal): Promise<ProviderProbe>;
|
|
632
640
|
/** 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>;
|
|
641
|
+
export declare function probeDroidStatus(home?: string, usageScope?: string | null, signal?: AbortSignal): Promise<ProviderProbe>;
|
|
634
642
|
/**
|
|
635
643
|
* Normalize the Factory billing-limits payload into the common UsageWindow
|
|
636
644
|
* shape. Orgs on the legacy (non token-rate-limit) billing model have no
|
|
@@ -318,6 +318,16 @@ export async function getUsageInfo(agentId, options) {
|
|
|
318
318
|
const source = getUsageSource(agentId);
|
|
319
319
|
return source ? source.fetch(options) : { snapshot: null, error: null };
|
|
320
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Combine a caller-supplied abort signal (the daemon tick deadline) with a
|
|
323
|
+
* per-fetch timeout, so a provider fetch is bounded by whichever fires first
|
|
324
|
+
* (PHNX-3608). With no caller signal it degrades to the timeout alone —
|
|
325
|
+
* byte-identical to the previous `AbortSignal.timeout(ms)` behaviour.
|
|
326
|
+
*/
|
|
327
|
+
function usageFetchSignal(signal, timeoutMs) {
|
|
328
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
329
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
330
|
+
}
|
|
321
331
|
/** Derive a stable lookup key from account info for usage deduplication. */
|
|
322
332
|
export function getUsageLookupKey(info) {
|
|
323
333
|
return info?.usageKey || info?.accountKey || null;
|
|
@@ -421,6 +431,7 @@ export async function getUsageInfoForIdentity(input, opts) {
|
|
|
421
431
|
organizationId: input.info.organizationId,
|
|
422
432
|
fileOnly: opts?.fileOnly,
|
|
423
433
|
allowInteractiveLogin: opts?.allowInteractiveLogin,
|
|
434
|
+
signal: opts?.signal,
|
|
424
435
|
});
|
|
425
436
|
}
|
|
426
437
|
const cached = readClaudeUsageCache(usageKey);
|
|
@@ -458,6 +469,7 @@ export async function getUsageInfoForIdentity(input, opts) {
|
|
|
458
469
|
// Explicit refresh: block on the shared device collector.
|
|
459
470
|
return fetchLiveUsageDeduped(input, usageKey, cached, opts?.fileOnly === true, {
|
|
460
471
|
allowInteractiveLogin: opts?.allowInteractiveLogin === true,
|
|
472
|
+
signal: opts?.signal,
|
|
461
473
|
});
|
|
462
474
|
}
|
|
463
475
|
/**
|
|
@@ -489,6 +501,7 @@ async function fetchLiveUsageDeduped(input, usageKey, cached, fileOnly, opts) {
|
|
|
489
501
|
usageScope: usageKey,
|
|
490
502
|
fileOnly,
|
|
491
503
|
allowInteractiveLogin: opts?.allowInteractiveLogin === true,
|
|
504
|
+
signal: opts?.signal,
|
|
492
505
|
});
|
|
493
506
|
if (usage.snapshot) {
|
|
494
507
|
if (!usage.snapshot.capturedAt || usage.snapshot.capturedAt.getTime() <= previousCapturedAt) {
|
|
@@ -931,7 +944,7 @@ async function getClaudeUsageInfo(options) {
|
|
|
931
944
|
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
|
|
932
945
|
'User-Agent': getClaudeUserAgent(options?.cliVersion),
|
|
933
946
|
},
|
|
934
|
-
signal:
|
|
947
|
+
signal: usageFetchSignal(options?.signal, 5000),
|
|
935
948
|
});
|
|
936
949
|
if (!response.ok) {
|
|
937
950
|
if (response.status === 429) {
|
|
@@ -1038,7 +1051,7 @@ async function getKimiUsageInfo(options) {
|
|
|
1038
1051
|
Authorization: `Bearer ${accessToken}`,
|
|
1039
1052
|
Accept: 'application/json',
|
|
1040
1053
|
},
|
|
1041
|
-
signal:
|
|
1054
|
+
signal: usageFetchSignal(options?.signal, 5000),
|
|
1042
1055
|
});
|
|
1043
1056
|
// 401/403 => expired token, 404 => no Kimi For Coding subscription. Either
|
|
1044
1057
|
// way there are no bars to draw, and the status is what tells them apart.
|
|
@@ -1172,7 +1185,7 @@ async function getDroidUsageInfo(options) {
|
|
|
1172
1185
|
Authorization: `Bearer ${accessToken}`,
|
|
1173
1186
|
Accept: 'application/json',
|
|
1174
1187
|
},
|
|
1175
|
-
signal:
|
|
1188
|
+
signal: usageFetchSignal(options?.signal, 5000),
|
|
1176
1189
|
});
|
|
1177
1190
|
// 401 => revoked/expired token. No bars to draw, and the status says why.
|
|
1178
1191
|
if (!response.ok) {
|
|
@@ -1204,7 +1217,7 @@ async function getDroidUsageInfo(options) {
|
|
|
1204
1217
|
}
|
|
1205
1218
|
}
|
|
1206
1219
|
/** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
|
|
1207
|
-
export async function probeClaudeStatus(home, cliVersion, usageScope) {
|
|
1220
|
+
export async function probeClaudeStatus(home, cliVersion, usageScope, signal) {
|
|
1208
1221
|
// accessTokenCache: the daemon warms this probe every ~3 min per account, so it
|
|
1209
1222
|
// reads ONLY the file-based setup-token and never the interactive login —
|
|
1210
1223
|
// transmitting that ACL-bound token to the usage API from a background loop is
|
|
@@ -1240,7 +1253,7 @@ export async function probeClaudeStatus(home, cliVersion, usageScope) {
|
|
|
1240
1253
|
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
|
|
1241
1254
|
'User-Agent': getClaudeUserAgent(cliVersion),
|
|
1242
1255
|
},
|
|
1243
|
-
signal:
|
|
1256
|
+
signal: usageFetchSignal(signal, 8000),
|
|
1244
1257
|
});
|
|
1245
1258
|
if (response.status === 429) {
|
|
1246
1259
|
noteUsageRateLimited('claude', response.headers.get('retry-after'), { account: usageScope });
|
|
@@ -1271,7 +1284,7 @@ export async function probeClaudeStatus(home, cliVersion, usageScope) {
|
|
|
1271
1284
|
}
|
|
1272
1285
|
}
|
|
1273
1286
|
/** Probe Kimi's OAuth token against the /usages endpoint. Never refreshes (single-use rotation — see getKimiUsageInfo). */
|
|
1274
|
-
export async function probeKimiStatus(home, usageScope) {
|
|
1287
|
+
export async function probeKimiStatus(home, usageScope, signal) {
|
|
1275
1288
|
const credPath = resolveKimiCredentialPath(home);
|
|
1276
1289
|
if (!credPath)
|
|
1277
1290
|
return { status: null, token: 'missing' };
|
|
@@ -1301,7 +1314,7 @@ export async function probeKimiStatus(home, usageScope) {
|
|
|
1301
1314
|
const response = await fetch(KIMI_USAGES_URL, {
|
|
1302
1315
|
method: 'GET',
|
|
1303
1316
|
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
|
1304
|
-
signal:
|
|
1317
|
+
signal: usageFetchSignal(signal, 8000),
|
|
1305
1318
|
});
|
|
1306
1319
|
if (response.status === 429) {
|
|
1307
1320
|
noteUsageRateLimited('kimi', response.headers.get('retry-after'), { account: usageScope });
|
|
@@ -1313,7 +1326,7 @@ export async function probeKimiStatus(home, usageScope) {
|
|
|
1313
1326
|
}
|
|
1314
1327
|
}
|
|
1315
1328
|
/** Probe Droid's WorkOS token against the billing-limits endpoint. Never refreshes (single-use rotation — see getDroidUsageInfo). */
|
|
1316
|
-
export async function probeDroidStatus(home, usageScope) {
|
|
1329
|
+
export async function probeDroidStatus(home, usageScope, signal) {
|
|
1317
1330
|
const cred = decryptDroidAuthPayload(home || os.homedir());
|
|
1318
1331
|
const accessToken = cred?.access_token;
|
|
1319
1332
|
if (typeof accessToken !== 'string' || !accessToken)
|
|
@@ -1331,7 +1344,7 @@ export async function probeDroidStatus(home, usageScope) {
|
|
|
1331
1344
|
const response = await fetch(DROID_USAGE_URL, {
|
|
1332
1345
|
method: 'GET',
|
|
1333
1346
|
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
|
1334
|
-
signal:
|
|
1347
|
+
signal: usageFetchSignal(signal, 8000),
|
|
1335
1348
|
});
|
|
1336
1349
|
if (response.status === 429) {
|
|
1337
1350
|
noteUsageRateLimited('droid', response.headers.get('retry-after'), { account: usageScope });
|
|
@@ -2963,7 +2976,7 @@ async function getCursorUsageInfo(options) {
|
|
|
2963
2976
|
Cookie: cookie,
|
|
2964
2977
|
Accept: 'application/json',
|
|
2965
2978
|
},
|
|
2966
|
-
signal:
|
|
2979
|
+
signal: usageFetchSignal(options?.signal, 5000),
|
|
2967
2980
|
});
|
|
2968
2981
|
// 401/redirect => revoked/expired session. No bars to draw, and the status
|
|
2969
2982
|
// says why.
|
|
@@ -163,6 +163,8 @@ export declare function probeAuthHealth(agent: AgentId, home: string | undefined
|
|
|
163
163
|
* a genuinely live request that surfaces `revoked` immediately.
|
|
164
164
|
*/
|
|
165
165
|
forceLive?: boolean;
|
|
166
|
+
/** Daemon tick deadline signal, combined with each probe fetch's own timeout (PHNX-3608). */
|
|
167
|
+
signal?: AbortSignal;
|
|
166
168
|
}): Promise<AuthHealth>;
|
|
167
169
|
/** One probed install on a host. */
|
|
168
170
|
export interface AuthProbeRow {
|
|
@@ -225,6 +227,12 @@ export declare function probeLocalFleetAuth(opts?: {
|
|
|
225
227
|
agents?: readonly AgentId[];
|
|
226
228
|
/** Fire real network probes even when fresh usage evidence exists (RUSH-3036) — the `devices ping [--strict]` contract. */
|
|
227
229
|
forceLive?: boolean;
|
|
230
|
+
/**
|
|
231
|
+
* Deadline signal from the daemon's supervised auth tick (PHNX-3608). Aborts
|
|
232
|
+
* the probe's in-flight network work when the tick deadline elapses so it
|
|
233
|
+
* unwinds instead of leaking a runaway await; on-demand CLI callers omit it.
|
|
234
|
+
*/
|
|
235
|
+
signal?: AbortSignal;
|
|
228
236
|
}): Promise<AuthProbeRow[]>;
|
|
229
237
|
/** Persist a host's probed rows into the cache (keyed by host+agent+version). */
|
|
230
238
|
export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[]): void;
|
package/dist/lib/auth-health.js
CHANGED
|
@@ -347,11 +347,11 @@ export async function probeAuthHealth(agent, home, opts) {
|
|
|
347
347
|
}
|
|
348
348
|
let probe;
|
|
349
349
|
if (agent === 'claude')
|
|
350
|
-
probe = await probeClaudeStatus(home, opts?.cliVersion, usageScope);
|
|
350
|
+
probe = await probeClaudeStatus(home, opts?.cliVersion, usageScope, opts?.signal);
|
|
351
351
|
else if (agent === 'kimi')
|
|
352
|
-
probe = await probeKimiStatus(home, usageScope);
|
|
352
|
+
probe = await probeKimiStatus(home, usageScope, opts?.signal);
|
|
353
353
|
else
|
|
354
|
-
probe = await probeDroidStatus(home, usageScope);
|
|
354
|
+
probe = await probeDroidStatus(home, usageScope, opts?.signal);
|
|
355
355
|
return { verdict: verdictFromProbe(probe), checkedAt, detail: probeDetail(probe) };
|
|
356
356
|
}
|
|
357
357
|
const info = opts?.info !== undefined ? opts.info : await getAccountInfo(agent, home).catch(() => null);
|
|
@@ -426,7 +426,7 @@ export async function probeLocalFleetAuth(opts) {
|
|
|
426
426
|
// accounts, so no same-account concurrency is left to trip the throttle.
|
|
427
427
|
const perGroup = await Promise.all(groupFleetAuthInstalls(installs, (inst) => LIVE_PROBE_AGENTS.has(inst.agent)).map(async (group) => {
|
|
428
428
|
const rep = group.probe;
|
|
429
|
-
const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info, forceLive: opts?.forceLive });
|
|
429
|
+
const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info, forceLive: opts?.forceLive, signal: opts?.signal });
|
|
430
430
|
health.account = authAccountLabel(rep.info);
|
|
431
431
|
if (health.verdict === 'unconfigured')
|
|
432
432
|
return [];
|
|
@@ -13,6 +13,18 @@ export interface CallerIdentity {
|
|
|
13
13
|
* agent comm is found.
|
|
14
14
|
*/
|
|
15
15
|
export declare function resolveCallerIdentity(env?: NodeJS.ProcessEnv): CallerIdentity;
|
|
16
|
+
/**
|
|
17
|
+
* The env a `browser --device` dispatch forwards so the HUB resolves the SAME
|
|
18
|
+
* caller identity the worker would stamp locally. Without it a forwarded verb
|
|
19
|
+
* lands on the hub with BLANK identity, and the daemon's no-identity task bucket
|
|
20
|
+
* matches any other no-identity task — so a cold verb could attach to (or a
|
|
21
|
+
* `done`/`stop` could close) an unrelated agent's or a human's browser. This is
|
|
22
|
+
* the same surface-parity fix the `run --device` path already carries via
|
|
23
|
+
* `--env AGENT_LAUNCH_ID` (remote-session-id.ts). Absent fields are omitted so a
|
|
24
|
+
* genuinely-unidentifiable caller forwards nothing rather than an empty string
|
|
25
|
+
* that would read as a real (blank) identity on the far side.
|
|
26
|
+
*/
|
|
27
|
+
export declare function callerIdentityEnv(caller: Pick<CallerIdentity, 'sessionId' | 'launchId'>): Record<string, string>;
|
|
16
28
|
/**
|
|
17
29
|
* True when a live task belongs to this caller. Match on sessionId or
|
|
18
30
|
* launchId — either is enough. A task with no recorded identity never
|
|
@@ -141,6 +141,25 @@ export function resolveCallerIdentity(env = process.env) {
|
|
|
141
141
|
}
|
|
142
142
|
return { actor, sessionId, launchId, agentPid };
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* The env a `browser --device` dispatch forwards so the HUB resolves the SAME
|
|
146
|
+
* caller identity the worker would stamp locally. Without it a forwarded verb
|
|
147
|
+
* lands on the hub with BLANK identity, and the daemon's no-identity task bucket
|
|
148
|
+
* matches any other no-identity task — so a cold verb could attach to (or a
|
|
149
|
+
* `done`/`stop` could close) an unrelated agent's or a human's browser. This is
|
|
150
|
+
* the same surface-parity fix the `run --device` path already carries via
|
|
151
|
+
* `--env AGENT_LAUNCH_ID` (remote-session-id.ts). Absent fields are omitted so a
|
|
152
|
+
* genuinely-unidentifiable caller forwards nothing rather than an empty string
|
|
153
|
+
* that would read as a real (blank) identity on the far side.
|
|
154
|
+
*/
|
|
155
|
+
export function callerIdentityEnv(caller) {
|
|
156
|
+
const env = {};
|
|
157
|
+
if (caller.sessionId)
|
|
158
|
+
env.AGENT_SESSION_ID = caller.sessionId;
|
|
159
|
+
if (caller.launchId)
|
|
160
|
+
env.AGENT_LAUNCH_ID = caller.launchId;
|
|
161
|
+
return env;
|
|
162
|
+
}
|
|
144
163
|
/**
|
|
145
164
|
* True when a live task belongs to this caller. Match on sessionId or
|
|
146
165
|
* launchId — either is enough. A task with no recorded identity never
|