@phnx-labs/agents-cli 1.22.65 → 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.
Files changed (112) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +7 -2
  3. package/dist/bootstrap.js +3 -3
  4. package/dist/commands/apply.js +9 -2
  5. package/dist/commands/browser.js +46 -21
  6. package/dist/commands/daemon-test-harness.d.ts +1 -1
  7. package/dist/commands/daemon-test-harness.js +2 -2
  8. package/dist/commands/daemon.js +21 -22
  9. package/dist/commands/exec.js +40 -0
  10. package/dist/commands/monitors.js +2 -2
  11. package/dist/commands/repo.js +57 -19
  12. package/dist/commands/resume.d.ts +16 -0
  13. package/dist/commands/resume.js +41 -8
  14. package/dist/commands/routines.js +42 -21
  15. package/dist/commands/sessions-optimize.js +1 -1
  16. package/dist/commands/sessions.js +4 -11
  17. package/dist/commands/share.d.ts +5 -3
  18. package/dist/commands/share.js +33 -9
  19. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  20. package/dist/lib/accounting/account-pool-collect.js +6 -4
  21. package/dist/lib/accounting/usage-ingest.js +4 -2
  22. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  23. package/dist/lib/accounting/usage-sync.js +66 -210
  24. package/dist/lib/accounting/usage.d.ts +11 -3
  25. package/dist/lib/accounting/usage.js +23 -10
  26. package/dist/lib/auth-health.d.ts +8 -0
  27. package/dist/lib/auth-health.js +4 -4
  28. package/dist/lib/browser/caller-identity.d.ts +12 -0
  29. package/dist/lib/browser/caller-identity.js +19 -0
  30. package/dist/lib/browser/ipc.d.ts +37 -32
  31. package/dist/lib/browser/ipc.js +146 -94
  32. package/dist/lib/browser/task-index.d.ts +10 -2
  33. package/dist/lib/browser/task-index.js +22 -3
  34. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  35. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  36. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  37. package/dist/lib/daemon/auth-sync-service.js +17 -6
  38. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  39. package/dist/lib/daemon/catchup-service.js +51 -0
  40. package/dist/lib/daemon/daemon.d.ts +12 -22
  41. package/dist/lib/daemon/daemon.js +463 -176
  42. package/dist/lib/daemon/runner.js +2 -0
  43. package/dist/lib/daemon/service.d.ts +22 -4
  44. package/dist/lib/daemon/service.js +2 -2
  45. package/dist/lib/daemon/supervisor.d.ts +55 -15
  46. package/dist/lib/daemon/supervisor.js +119 -29
  47. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  48. package/dist/lib/daemon/usage-sync-service.js +22 -18
  49. package/dist/lib/daemon-health.js +36 -31
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +12 -2
  52. package/dist/lib/daemon-ticks.d.ts +9 -6
  53. package/dist/lib/daemon-ticks.js +14 -8
  54. package/dist/lib/devices/known-hosts.d.ts +15 -0
  55. package/dist/lib/devices/known-hosts.js +18 -0
  56. package/dist/lib/devices/registry.js +2 -0
  57. package/dist/lib/exec.js +11 -0
  58. package/dist/lib/feed/watch.d.ts +3 -0
  59. package/dist/lib/feed/watch.js +13 -3
  60. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  61. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  62. package/dist/lib/fleet-shared-state.d.ts +38 -0
  63. package/dist/lib/fleet-shared-state.js +105 -0
  64. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  65. package/dist/lib/hosts/remote-cmd.js +12 -3
  66. package/dist/lib/lock-compromise.d.ts +8 -0
  67. package/dist/lib/lock-compromise.js +12 -0
  68. package/dist/lib/monitors/engine.d.ts +2 -1
  69. package/dist/lib/monitors/engine.js +9 -2
  70. package/dist/lib/refresh-coordinator.js +2 -0
  71. package/dist/lib/run-trace-sync.d.ts +13 -0
  72. package/dist/lib/run-trace-sync.js +77 -0
  73. package/dist/lib/secrets/push.d.ts +10 -0
  74. package/dist/lib/secrets/push.js +86 -7
  75. package/dist/lib/secrets/remote.d.ts +18 -6
  76. package/dist/lib/secrets/remote.js +29 -4
  77. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  78. package/dist/lib/secrets/reserved-sync.js +120 -102
  79. package/dist/lib/session/active.d.ts +3 -1
  80. package/dist/lib/session/active.js +2 -0
  81. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  82. package/dist/lib/session/actor-sidecar.js +2 -0
  83. package/dist/lib/session/db.d.ts +5 -2
  84. package/dist/lib/session/db.js +54 -22
  85. package/dist/lib/session/discover.d.ts +12 -3
  86. package/dist/lib/session/discover.js +106 -25
  87. package/dist/lib/session/prompt.d.ts +11 -0
  88. package/dist/lib/session/prompt.js +25 -0
  89. package/dist/lib/session/recovery.d.ts +30 -2
  90. package/dist/lib/session/recovery.js +56 -4
  91. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  92. package/dist/lib/session/remote/remote-list.js +29 -4
  93. package/dist/lib/session/remote/watch.d.ts +25 -2
  94. package/dist/lib/session/remote/watch.js +185 -11
  95. package/dist/lib/session/session-cache.d.ts +2 -1
  96. package/dist/lib/session/session-cache.js +1 -0
  97. package/dist/lib/session/types.d.ts +2 -0
  98. package/dist/lib/share/publish.d.ts +55 -9
  99. package/dist/lib/share/publish.js +87 -9
  100. package/dist/lib/share/worker-template.js +100 -8
  101. package/dist/lib/ssh-exec.d.ts +2 -0
  102. package/dist/lib/ssh-exec.js +20 -4
  103. package/dist/lib/teams/registry.js +2 -0
  104. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  105. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  106. package/dist/lib/traces/sync.d.ts +7 -0
  107. package/dist/lib/traces/sync.js +9 -0
  108. package/dist/lib/usage-refresh.d.ts +8 -2
  109. package/dist/lib/usage-refresh.js +3 -3
  110. package/package.json +1 -1
  111. package/dist/lib/account-state-service.d.ts +0 -21
  112. package/dist/lib/account-state-service.js +0 -60
@@ -1,231 +1,87 @@
1
1
  /**
2
- * Fleet sync of the identity-keyed Claude usage snapshot (PHNX-3392 follow-up).
2
+ * Fleet distribution of identity-keyed Claude usage through the user repo.
3
3
  *
4
- * A rate limit is metered per ACCOUNT, so an account's 5h/weekly usage is the
5
- * same number on every box. But only a HEADED device (`personal`/`desktop`) can
6
- * read it: the interactive OAuth login it holds carries the `user:profile` scope
7
- * `/api/oauth/usage` requires, and its interactive Claude runs feed the native
8
- * windows through the status-line writer. A headless `worker` has only the
9
- * `user:inference` setup-token, which the usage endpoint 403s (RUSH-2392), so its
10
- * local `claude-usage.json` stays blank and `agents view` shows no S:/W: bars.
11
- *
12
- * This closes that gap the same way {@link ../secrets/reserved-sync.ts} closes
13
- * the auth-token gap: each headed daemon PUSHES its identity-keyed usage rows to
14
- * the worker peers that cannot read them, which merge NEWEST-WINS
15
- * ({@link ingestPeerClaudeUsageRows}). Publish direction is role-driven —
16
- * personal/desktop publish, worker/unmarked consume — so it composes with the
17
- * device-role taxonomy rather than adding a second notion of "which box is real".
18
- *
19
- * Double-fire safety: each daemon only writes the DESTINATION's own cache, and
20
- * the merge is idempotent + timestamp-guarded, so two headed publishers pushing
21
- * the same account to one worker converge on the freshest snapshot regardless of
22
- * order. The input a publisher reads is its OWN cache (device-local state), so an
23
- * unrestricted per-device fire is correct — no shared queue.
24
- *
25
- * The planner is pure so tests cover every skip/push branch with no SSH.
4
+ * A headed device can read authoritative usage; a worker's setup-token cannot.
5
+ * Headed daemons therefore publish one snapshot into their conflict-free
6
+ * `~/.agents/devices/<device>/daemon-state.json`. The daemon's bounded Git
7
+ * transport delivers those files automatically; workers merge newest-wins.
8
+ * There is deliberately no device-to-device SSH in this module or its tick.
26
9
  */
27
- import { sshExec } from '../ssh-exec.js';
28
- import { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor, stripClixml } from '../hosts/remote-cmd.js';
29
- import { resolveRemoteOsSync } from '../hosts/remote-os.js';
30
- import { loadDevicesSync } from '../devices/registry.js';
31
- import { sshTargetFor } from '../devices/connect.js';
32
- import { isHostPinned } from '../devices/known-hosts.js';
10
+ import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole } from '../device-config.js';
11
+ import { readFleetSharedDeviceStates, updateFleetSharedDeviceState, } from '../fleet-shared-state.js';
12
+ import { getUserAgentsDir } from '../state.js';
33
13
  import { machineId, normalizeHost } from '../session/sync/config.js';
34
- import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
35
14
  import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
36
- /** How long a single peer push may take before it is abandoned for this tick. */
37
- export const USAGE_PUSH_DEADLINE_MS = 20_000;
38
- export const USAGE_PULL_DEADLINE_MS = 20_000;
39
- /**
40
- * A worker's newest local usage row older than this triggers a pull from the
41
- * primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
42
- * RUSH-2392), so its only source of a fresh row is the daemon push every
43
- * `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
44
- * cycle is tolerated before the worker actively pulls. Bounds worker staleness
45
- * to ~30m instead of the days-old caches this replaces.
46
- */
47
- export const USAGE_SYNC_MAX_AGE_MS = 30 * 60_000;
48
- /** Epoch ms of a cache row's capture time, or null when unparseable/absent. */
49
- function capturedAtMs(capturedAt) {
50
- if (!capturedAt)
51
- return null;
52
- const ms = Date.parse(capturedAt);
53
- return Number.isFinite(ms) ? ms : null;
54
- }
55
- /**
56
- * Decide, per peer, whether to push the local usage snapshot. Pure.
57
- *
58
- * - `selfIsPublisher` is false on a `worker`/unmarked box: it has no authoritative
59
- * usage to publish, so every peer is skipped.
60
- * - `hasLocalRows` false means the local cache is empty (nothing to teach yet).
61
- * - A HEADED peer is skipped — it reads its own usage; pushing risks nothing
62
- * (the merge is newest-wins) but is wasted work, and keeping the fan-out to
63
- * consumers only makes the intent legible.
64
- */
65
- export function planUsagePush(selfIsPublisher, hasLocalRows, targets) {
66
- if (!selfIsPublisher) {
67
- return targets.map((d) => ({
68
- action: 'skip',
69
- device: d.name,
70
- reason: 'this device is not a usage publisher (mark it personal or desktop)',
71
- }));
72
- }
73
- if (!hasLocalRows) {
74
- return targets.map((d) => ({ action: 'skip', device: d.name, reason: 'no local usage snapshot to publish' }));
75
- }
76
- return targets.map((d) => {
77
- if (isHeadedDeviceRole(d.role))
78
- return { action: 'skip', device: d.name, reason: 'headed peer reads its own usage' };
79
- if (!d.online)
80
- return { action: 'skip', device: d.name, reason: 'offline' };
81
- if (!d.pinned) {
82
- return { action: 'skip', device: d.name, reason: `host key not pinned; run \`agents ssh ${d.name}\` once` };
83
- }
84
- return { action: 'push', device: d.name };
85
- });
86
- }
87
- /** Production push: pipe the payload to the peer's `agents __usage-ingest` over ssh. */
88
- function defaultUsagePush(device, payload) {
89
- const target = sshTargetFor(device);
90
- const os = resolveRemoteOsSync(device.name);
91
- // A Windows peer's `agents.ps1` shim does not forward ssh-piped stdin, so hand
92
- // the payload through a temp file (`--from`) instead of stdin — same workaround
93
- // the secrets push uses. A POSIX peer reads stdin directly.
94
- const remoteCmd = remoteShellFor(os) === 'powershell'
95
- ? buildWindowsStdinAgentsCommand(['__usage-ingest'])
96
- : buildRemoteAgentsInvocation(['__usage-ingest'], undefined, os);
97
- const res = sshExec(target, remoteCmd, { input: payload, timeoutMs: USAGE_PUSH_DEADLINE_MS });
98
- if (res.timedOut)
99
- return { ok: false, message: 'timed out' };
100
- if (res.code !== 0)
101
- return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
102
- return { ok: true };
103
- }
104
- /** Production pull: read the primary's cache through its hidden export verb. */
105
- function defaultUsagePull(device) {
106
- const target = sshTargetFor(device);
107
- const os = resolveRemoteOsSync(device.name);
108
- const remoteCmd = buildRemoteAgentsInvocation(['__usage-export'], undefined, os);
109
- const res = sshExec(target, remoteCmd, { timeoutMs: USAGE_PULL_DEADLINE_MS });
110
- if (res.timedOut)
111
- return { ok: false, message: 'timed out' };
112
- if (res.code !== 0)
113
- return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
114
- return { ok: true, stdout: res.stdout };
115
- }
116
- /**
117
- * Pull usage from the fleet's primary headed device when this worker's local
118
- * cache is empty or contains an expired row. `personal` is the primary role;
119
- * a `desktop` is used only when the fleet has no personal device.
120
- */
121
- export function pullUsageFromPrimary(deps = {}) {
122
- const result = { pulledFrom: null, merged: 0, skipped: null, error: null };
123
- if ((deps.selfRole ?? selfConfiguredDeviceRole)() !== 'worker') {
124
- result.skipped = 'this device is not a worker';
15
+ /** Publish this headed device's stable usage snapshot into its owned store file. */
16
+ export function publishUsageSnapshotToSharedStore(options = {}) {
17
+ const result = {
18
+ published: false,
19
+ changed: false,
20
+ skipped: null,
21
+ error: null,
22
+ path: null,
23
+ };
24
+ const role = options.role ?? selfConfiguredDeviceRole();
25
+ if (!isHeadedDeviceRole(role)) {
26
+ result.skipped = 'this device is not a usage publisher (mark it personal or desktop)';
125
27
  return result;
126
28
  }
127
- const localRows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
128
- const localEntries = Object.entries(localRows);
129
- // Fresh means RECENT, not "the cache equals itself". The prior check read the
130
- // SAME on-disk cache twice (exportRows() and readRow()) and compared window
131
- // COUNT, so `fresh.windows.length === row.windows.length` was always true and
132
- // any non-empty worker cache skipped the pull forever — which is how a worker
133
- // served a days-old snapshot indefinitely and balanced launched into an
134
- // account already at its weekly cap. A worker cannot self-read usage, so any
135
- // row older than the sync cadence is stale and must trigger a pull; the merge
136
- // is newest-wins + idempotent, so pulling when the primary is no fresher is a
137
- // harmless no-op.
138
- const now = (deps.now ?? Date.now)();
139
- const staleCutoff = now - USAGE_SYNC_MAX_AGE_MS;
140
- const allFresh = localEntries.length > 0 &&
141
- localEntries.every(([, row]) => {
142
- const ms = capturedAtMs(row.capturedAt);
143
- return ms !== null && ms >= staleCutoff;
144
- });
145
- if (allFresh) {
146
- result.skipped = 'local usage cache is fresh';
29
+ const rows = exportClaudeUsageCacheRows(options.cachePath);
30
+ if (Object.keys(rows).length === 0) {
31
+ result.skipped = 'no local usage snapshot to publish';
147
32
  return result;
148
33
  }
149
- const devices = deps.listDevices?.() ?? Object.values(loadDevicesSync());
150
- const roles = deps.listRoles?.() ?? (deps.listDevices
151
- ? listConfiguredDeviceRoles(devices.map((device) => device.name))
152
- : listConfiguredDeviceRoles());
153
- const isPinned = deps.isPinned ?? isHostPinned;
154
- const primary = devices
155
- .filter((device) => roles[device.name] === 'personal')
156
- .concat(devices.filter((device) => roles[device.name] === 'desktop'))
157
- .find((device) => device.tailscale?.online !== false && isPinned(device.name));
158
- if (!primary) {
159
- result.error = 'no reachable, pinned personal or desktop device is configured as the usage primary';
160
- return result;
161
- }
162
- const outcome = (deps.pull ?? defaultUsagePull)(primary);
163
- if (!outcome.ok) {
164
- result.error = outcome.message ?? 'pull failed';
165
- return result;
166
- }
167
- let payload;
168
34
  try {
169
- // A Windows usage primary's `agents.ps1` shim prepends a PowerShell CLIXML
170
- // progress banner to stdout, exactly as every other remote-JSON boundary
171
- // strips (remote-cmd.ts:325). Without this, a headed Windows box (win-mini
172
- // is a live fleet device) makes every pull fail as "malformed JSON", the
173
- // worker's cache stays null, and the PHNX-3392 capacity floor silently
174
- // becomes the only thing standing between a blind pool and an exhausted pick.
175
- payload = JSON.parse(stripClixml(outcome.stdout ?? ''));
176
- }
177
- catch {
178
- result.error = 'primary returned malformed JSON';
179
- return result;
35
+ const write = updateFleetSharedDeviceState(options.device ?? machineId(), { usage: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
36
+ result.published = true;
37
+ result.changed = write.changed;
38
+ result.path = write.path;
180
39
  }
181
- if (!payload || payload.v !== 1 || typeof payload.rows !== 'object' || payload.rows === null || Array.isArray(payload.rows)) {
182
- result.error = 'primary returned an unrecognized usage-sync payload shape';
183
- return result;
40
+ catch (err) {
41
+ result.error = err.message;
184
42
  }
185
- result.pulledFrom = primary.name;
186
- result.merged = (deps.ingestRows ?? ingestPeerClaudeUsageRows)(payload.rows);
187
43
  return result;
188
44
  }
45
+ function capturedAtMs(row) {
46
+ if (!row.capturedAt)
47
+ return null;
48
+ const ms = Date.parse(row.capturedAt);
49
+ return Number.isFinite(ms) ? ms : null;
50
+ }
189
51
  /**
190
- * Push the local identity-keyed usage snapshot to every reachable, pinned,
191
- * non-headed peer. A no-op on a non-headed box or when the local cache is empty.
52
+ * Read headed publishers from the local user-repo checkout and merge one
53
+ * newest-wins batch into the worker cache. No network or subprocess is touched.
192
54
  */
193
- export function syncFleetUsageSnapshots(deps = {}) {
194
- const result = { pushed: [], skipped: [], errors: [] };
195
- const selfRole = (deps.selfRole ?? selfConfiguredDeviceRole)();
196
- const devices = deps.listDevices?.() ?? Object.values(loadDevicesSync());
197
- const self = deps.localName?.() ?? machineId();
198
- const selfNorm = normalizeHost(self);
199
- const roles = deps.listRoles?.() ?? (deps.listDevices ? listConfiguredDeviceRoles(devices.map((d) => d.name)) : listConfiguredDeviceRoles());
200
- const isPinned = deps.isPinned ?? isHostPinned;
201
- const byName = new Map(devices.map((d) => [d.name, d]));
202
- const targets = devices
203
- .filter((d) => normalizeHost(d.name) !== selfNorm)
204
- .map((d) => ({
205
- name: d.name,
206
- role: roles[d.name],
207
- online: d.tailscale?.online !== false,
208
- pinned: isPinned(d.name),
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: AbortSignal.timeout(5000),
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: AbortSignal.timeout(5000),
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: AbortSignal.timeout(5000),
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: AbortSignal.timeout(8000),
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: AbortSignal.timeout(8000),
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: AbortSignal.timeout(8000),
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: AbortSignal.timeout(5000),
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;
@@ -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