@phnx-labs/agents-cli 1.22.109 → 1.22.111

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 (66) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/bootstrap.js +3 -2
  3. package/dist/commands/browser-sessions-picker.js +2 -1
  4. package/dist/commands/computer-sessions-picker.js +2 -1
  5. package/dist/commands/cost.js +2 -1
  6. package/dist/commands/fork.js +6 -4
  7. package/dist/commands/logs.js +2 -1
  8. package/dist/commands/sessions-backfill.d.ts +30 -0
  9. package/dist/commands/sessions-backfill.js +72 -0
  10. package/dist/commands/sessions-inject.d.ts +3 -3
  11. package/dist/commands/sessions-inject.js +5 -11
  12. package/dist/commands/sessions-picker.js +10 -7
  13. package/dist/commands/sessions-resume.d.ts +3 -1
  14. package/dist/commands/sessions-resume.js +32 -3
  15. package/dist/commands/sessions.js +44 -24
  16. package/dist/commands/utils.d.ts +9 -0
  17. package/dist/commands/utils.js +18 -0
  18. package/dist/commands/watchdog.js +4 -1
  19. package/dist/lib/accounting/rotate.d.ts +9 -0
  20. package/dist/lib/accounting/rotate.js +17 -1
  21. package/dist/lib/accounting/usage-sync.d.ts +22 -0
  22. package/dist/lib/accounting/usage-sync.js +70 -6
  23. package/dist/lib/accounting/usage.d.ts +32 -0
  24. package/dist/lib/accounting/usage.js +37 -2
  25. package/dist/lib/auth-health.js +19 -0
  26. package/dist/lib/claude-statusline.js +5 -0
  27. package/dist/lib/computer/sessions-list.js +2 -1
  28. package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
  29. package/dist/lib/daemon/auth-sync-service.js +2 -2
  30. package/dist/lib/daemon/daemon.js +7 -0
  31. package/dist/lib/daemon/session-title-service.d.ts +41 -0
  32. package/dist/lib/daemon/session-title-service.js +76 -0
  33. package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
  34. package/dist/lib/daemon/usage-sync-service.js +10 -2
  35. package/dist/lib/daemon-services.d.ts +1 -1
  36. package/dist/lib/daemon-services.js +5 -0
  37. package/dist/lib/daemon-ticks.js +6 -0
  38. package/dist/lib/fleet-shared-state.d.ts +2 -0
  39. package/dist/lib/hooks/install.js +14 -7
  40. package/dist/lib/mailbox-target.js +2 -1
  41. package/dist/lib/remote-agents-json.js +1 -1
  42. package/dist/lib/session/active.d.ts +93 -16
  43. package/dist/lib/session/active.js +80 -14
  44. package/dist/lib/session/db.d.ts +44 -1
  45. package/dist/lib/session/db.js +122 -17
  46. package/dist/lib/session/fork.d.ts +10 -2
  47. package/dist/lib/session/fork.js +11 -2
  48. package/dist/lib/session/live-metadata.js +6 -0
  49. package/dist/lib/session/mirror.d.ts +3 -2
  50. package/dist/lib/session/mirror.js +5 -2
  51. package/dist/lib/session/remote/remote-list.js +1 -1
  52. package/dist/lib/session/remote/watch.js +18 -8
  53. package/dist/lib/session/title.d.ts +256 -0
  54. package/dist/lib/session/title.js +312 -0
  55. package/dist/lib/session/tool-index.d.ts +5 -0
  56. package/dist/lib/session/tool-index.js +1 -0
  57. package/dist/lib/session/types.d.ts +11 -0
  58. package/dist/lib/startup/root-command.d.ts +2 -0
  59. package/dist/lib/startup/root-command.js +22 -0
  60. package/dist/lib/traces/sync.js +1 -0
  61. package/dist/lib/usage-refresh.d.ts +58 -7
  62. package/dist/lib/usage-refresh.js +145 -23
  63. package/dist/lib/watchdog/runner.d.ts +3 -0
  64. package/dist/lib/watchdog/runner.js +1 -0
  65. package/dist/session-tracker/dist/install-hook.js +4 -4
  66. package/package.json +1 -1
@@ -37,6 +37,7 @@ import { inferSessionState } from '../lib/session/state.js';
37
37
  import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
38
38
  import { findSessionsById, querySessions, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
39
39
  import { liveSessionMetas, fleetExecutionMachineById, reconcileLiveMetaMachine } from '../lib/session/live-metadata.js';
40
+ import { sessionHeadline } from '../lib/session/title.js';
40
41
  import { filterTeamSessions, shouldShowTeamSessions, safeTeamText, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
41
42
  import { parseSession } from '../lib/session/parse.js';
42
43
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
@@ -351,6 +352,7 @@ export function buildSessionDescription(s) {
351
352
  return cleanPreview(parts.filter(Boolean).join(' · '));
352
353
  }
353
354
  // Terminal, headless, or sub-agent: todos + live preview, then label, then topic.
355
+ // ladder-exempt: the compact --active preview BASE (a live snippet), not the row's headline title.
354
356
  const base = s.preview || s.label || s.topic || '';
355
357
  return cleanPreview([todo, base].filter(Boolean).join(' · '));
356
358
  }
@@ -759,22 +761,39 @@ export function renderActiveRowLines(s, indent, termW) {
759
761
  // Line 2: label/topic + checklist (the identity, no longer buried) then the
760
762
  // jump locator. Skipped entirely when there is nothing to say. desc may carry a
761
763
  // clickable project link, so it also goes through fitCell.
764
+ const contIndent = indent + ' '.repeat(ROW_ID_W);
762
765
  const desc = formatActiveRowDescription(s);
763
766
  const loc = locatorBadge(s);
764
- if (!desc && !loc)
765
- return lines;
766
- const contIndent = indent + ' '.repeat(ROW_ID_W);
767
- const room2 = Math.max(0, termW - stringWidth(contIndent) - 2);
768
- const locCell = fitCell(loc, room2);
769
- const locW = stringWidth(locCell);
770
- const descRoom = Math.max(0, room2 - (locW ? locW + 2 : 0));
771
- const descCell = chalk.white(fitCell(desc || '-', descRoom));
772
- let line2 = contIndent + chalk.dim('└ ') + descCell;
773
- if (locCell)
774
- line2 += ' ' + locCell;
775
- if (stringWidth(line2) > termW)
776
- line2 = truncateToWidth(line2, termW);
777
- lines.push(line2);
767
+ if (desc || loc) {
768
+ const room2 = Math.max(0, termW - stringWidth(contIndent) - 2);
769
+ const locCell = fitCell(loc, room2);
770
+ const locW = stringWidth(locCell);
771
+ const descRoom = Math.max(0, room2 - (locW ? locW + 2 : 0));
772
+ const descCell = chalk.white(fitCell(desc || '-', descRoom));
773
+ let line2 = contIndent + chalk.dim('└ ') + descCell;
774
+ if (locCell)
775
+ line2 += ' ' + locCell;
776
+ if (stringWidth(line2) > termW)
777
+ line2 = truncateToWidth(line2, termW);
778
+ lines.push(line2);
779
+ }
780
+ // Secondary line (PHNX-3797 owner feedback): surface the most important recent
781
+ // agent message when it is a pending QUESTION or a NEEDS-YOU block — the urgency
782
+ // a rolling activity preview buries. Plain `activity` is already the preview on
783
+ // line 2, so it earns no extra line here; the full ranked message still rides
784
+ // the JSON/mirror feed for AGI EXT via `s.importantMessage`.
785
+ const important = s.importantMessage;
786
+ if (important && (important.kind === 'question' || important.kind === 'needs_you')) {
787
+ const glyph = important.kind === 'question' ? '? ' : '! ';
788
+ const room3 = Math.max(0, termW - stringWidth(contIndent) - 2 - glyph.length);
789
+ const msgCell = fitCell(cleanPreview(important.text), room3);
790
+ if (msgCell) {
791
+ let line3 = contIndent + chalk.dim(glyph + msgCell);
792
+ if (stringWidth(line3) > termW)
793
+ line3 = truncateToWidth(line3, termW);
794
+ lines.push(line3);
795
+ }
796
+ }
778
797
  return lines;
779
798
  }
780
799
  /** Render a single agent-session row inside an already-printed group header. */
@@ -2200,7 +2219,7 @@ export function printToolSearch(envelope) {
2200
2219
  ? truncate(sanitizeForTerminal(session.machine).replace(/\s+/g, ' '), 80)
2201
2220
  : '';
2202
2221
  const machine = machineName ? ` @ ${machineName}` : '';
2203
- const rawHeading = session.label || session.topic || session.project || session.shortId;
2222
+ const rawHeading = sessionHeadline(session) || session.project || session.shortId;
2204
2223
  const heading = truncate(sanitizeForTerminal(rawHeading).replace(/\s+/g, ' '), Math.max(30, terminalWidth() - 20));
2205
2224
  console.log(`${chalk.cyan(session.shortId)}${chalk.gray(machine)} ${heading}`);
2206
2225
  for (const call of session.calls) {
@@ -3374,7 +3393,7 @@ function printTeamsView(pool, liveIndex, hiddenUnmanaged = 0) {
3374
3393
  // degradation the --active teams rows use (active.ts resolveOrchestratorLabels).
3375
3394
  const labelById = new Map();
3376
3395
  for (const s of pool) {
3377
- const label = s.label || s.topic;
3396
+ const label = sessionHeadline(s);
3378
3397
  if (label)
3379
3398
  labelById.set(s.id, cleanPreview(label));
3380
3399
  }
@@ -3504,7 +3523,7 @@ function renderArchivedSession(session, mode, options = {}) {
3504
3523
  const shown = sessionDisplayAgent(session);
3505
3524
  const agentColor = colorAgent(shown);
3506
3525
  const absTime = formatAbsoluteTime(session.timestamp);
3507
- const title = session.label || session.topic;
3526
+ const title = sessionHeadline(session);
3508
3527
  console.log('');
3509
3528
  if (title)
3510
3529
  console.log(chalk.bold.white(title));
@@ -3562,9 +3581,10 @@ async function renderSession(session, mode, filters, options = {}) {
3562
3581
  const modelStr = stats.models.length > 0 ? chalk.yellow(` ${stats.models.join(', ')}`) : '';
3563
3582
  const branchStr = session.gitBranch ? chalk.gray(` (${session.gitBranch})`) : '';
3564
3583
  const absTime = formatAbsoluteTime(session.timestamp);
3565
- // Auto-inferred title headline (user /rename > Claude ai-title > first-prompt
3566
- // topic) — the fastest way to recognize which task this session is.
3567
- const title = session.label || session.topic;
3584
+ // Auto-inferred title headline (user /rename > Claude ai-title >
3585
+ // daemon-generated title > first-prompt topic) — the fastest way to
3586
+ // recognize which task this session is.
3587
+ const title = sessionHeadline(session);
3568
3588
  if (title) {
3569
3589
  const badges = signalBadges(metaSignals(session));
3570
3590
  console.log(chalk.bold.white(title) + (badges ? ' ' + badges : ''));
@@ -4456,7 +4476,7 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
4456
4476
  spinner.stop();
4457
4477
  console.error(chalk.red(`Multiple sessions match "${query}":`));
4458
4478
  for (const m of queryMatches.slice(0, 10)) {
4459
- console.error(chalk.cyan(` ${m.shortId} ${m.id} ${m.label ?? m.topic ?? ''}`));
4479
+ console.error(chalk.cyan(` ${m.shortId} ${m.id} ${sessionHeadline(m) ?? ''}`));
4460
4480
  }
4461
4481
  console.error(chalk.gray(ambiguityHint(byId, completeId)));
4462
4482
  process.exit(1);
@@ -4613,7 +4633,7 @@ async function renderOneSession(query, mode, scope) {
4613
4633
  spinner.stop();
4614
4634
  console.error(chalk.red(`Multiple sessions match "${query}":`));
4615
4635
  for (const match of queryMatches.slice(0, 10)) {
4616
- console.error(chalk.cyan(` ${match.shortId} ${match.id} ${match.label ?? match.topic ?? ''}`));
4636
+ console.error(chalk.cyan(` ${match.shortId} ${match.id} ${sessionHeadline(match) ?? ''}`));
4617
4637
  }
4618
4638
  console.error(chalk.gray(ambiguityHint(byId, completeId)));
4619
4639
  process.exit(1);
@@ -5067,7 +5087,7 @@ export async function resolveSessionMetadata(selector, scope, deps = { gatherRem
5067
5087
  for (const candidate of outcome.candidates) {
5068
5088
  const session = candidate.hits[0].session;
5069
5089
  const machines = candidate.hits.map(hit => hit.machine).join(', ');
5070
- console.error(chalk.cyan(` ${session.shortId} ${session.id}`) + chalk.gray(` ${machines} ${session.label ?? session.topic ?? ''}`));
5090
+ console.error(chalk.cyan(` ${session.shortId} ${session.id}`) + chalk.gray(` ${machines} ${sessionHeadline(session) ?? ''}`));
5071
5091
  }
5072
5092
  console.error(chalk.gray(looksLikeSessionId(selector) ? 'Pass a longer ID to narrow it down.' : 'Narrow the keywords to one session.'));
5073
5093
  process.exit(1);
@@ -5107,7 +5127,7 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
5107
5127
  console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
5108
5128
  for (const candidate of candidates) {
5109
5129
  const s = candidate.hits[0].session;
5110
- const label = s.label ?? s.topic ?? '';
5130
+ const label = sessionHeadline(s) ?? '';
5111
5131
  const machines = candidate.hits.map(hit => hit.machine).join(', ');
5112
5132
  console.error(chalk.cyan(` ${s.shortId} ${s.id}`) + chalk.gray(` ${machines} ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
5113
5133
  }
@@ -45,6 +45,15 @@ export interface Surface {
45
45
  * to a single `const s = resolveSurface(cmd)` without changing its flag surface.
46
46
  */
47
47
  export declare function resolveSurface(cmd: Command): Surface;
48
+ /**
49
+ * Coerce a `--device` value that may arrive as a scalar or an array to a single
50
+ * host string. A subcommand whose own `--device` collides in name with an
51
+ * ancestor's variadic `-D, --device <target...>` (e.g. `sessions inject`,
52
+ * `sessions resume` under the parent `sessions` command) can receive an array
53
+ * even when the command only ever targets one device — fail loud on more than
54
+ * one rather than guessing the first (PHNX-3688, PHNX-3940).
55
+ */
56
+ export declare function normalizeSingleDeviceOption(value: string | string[] | undefined, commandLabel: string): string | undefined;
48
57
  /**
49
58
  * Exit with a clean message when a picker would be required in a non-interactive shell.
50
59
  */
@@ -59,6 +59,24 @@ export function resolveSurface(cmd) {
59
59
  interactive: tty && !json,
60
60
  };
61
61
  }
62
+ /**
63
+ * Coerce a `--device` value that may arrive as a scalar or an array to a single
64
+ * host string. A subcommand whose own `--device` collides in name with an
65
+ * ancestor's variadic `-D, --device <target...>` (e.g. `sessions inject`,
66
+ * `sessions resume` under the parent `sessions` command) can receive an array
67
+ * even when the command only ever targets one device — fail loud on more than
68
+ * one rather than guessing the first (PHNX-3688, PHNX-3940).
69
+ */
70
+ export function normalizeSingleDeviceOption(value, commandLabel) {
71
+ const list = value == null ? [] : Array.isArray(value) ? value : [value];
72
+ const hosts = list.map((v) => String(v).trim()).filter((v) => v.length > 0);
73
+ if (hosts.length === 0)
74
+ return undefined;
75
+ if (hosts.length > 1) {
76
+ throw new Error(`${commandLabel} targets a single device, but --device named ${hosts.length}: ${hosts.join(', ')}.`);
77
+ }
78
+ return hosts[0];
79
+ }
62
80
  /**
63
81
  * Exit with a clean message when a picker would be required in a non-interactive shell.
64
82
  */
@@ -27,6 +27,7 @@ import { isWatchdogRotateEnabled, listRotateStates, setWatchdogRotateEnabled } f
27
27
  import { loadWatchdogSessions, runWatchdogPass } from '../lib/watchdog/service.js';
28
28
  import { readWatchdogEvents, WATCHDOG_LOG_PATH } from '../lib/watchdog/log.js';
29
29
  import { selectWatchdogHistory } from '../lib/watchdog/history.js';
30
+ import { sessionHeadline } from '../lib/session/title.js';
30
31
  /** Default state dir the runner and these subcommands share. */
31
32
  function stateDir() {
32
33
  return path.join(getRuntimeStateDir(), 'watchdog');
@@ -99,7 +100,9 @@ export function formatWatchdogTickLines(result, willInject, verbose = false) {
99
100
  : o.decision === 'nudge' ? 'WOULD-NUDGE'
100
101
  : 'skip';
101
102
  const id = o.sessionId?.slice(0, 8) ?? 'no-session-id';
102
- const title = o.label || o.name || o.topic;
103
+ // `name` is the `agents run --name` launch handle — a user-given name, so it
104
+ // ranks with the label; everything below it is the shared headline ladder.
105
+ const title = o.label || o.name || sessionHeadline(o);
103
106
  lines.push(` ${tag.padEnd(11)} ${id}${title ? ` · ${title}` : ''}`);
104
107
  const metadata = [
105
108
  o.kind,
@@ -178,6 +178,14 @@ export declare function isVersionLaunchableHere(agent: AgentId, version: string)
178
178
  * launched into it while the account was actually at its weekly cap.
179
179
  */
180
180
  export declare const USAGE_DECISION_MAX_AGE_MS: number;
181
+ /**
182
+ * How old a *synced* snapshot (arrived from the account's poller through the
183
+ * fleet store) may be and still settle a routing decision. Matches the
184
+ * usage-sync tick so a worker that only ever sees 15-minute-old rows does not
185
+ * drop to the account picker. Local captures (`poll` / `statusline`) keep
186
+ * {@link USAGE_DECISION_MAX_AGE_MS}.
187
+ */
188
+ export declare const USAGE_SYNC_TRUST_MS: number;
181
189
  /**
182
190
  * How old a usage snapshot may be before routing REFUSES to run at all
183
191
  * (NO_VERIFIED_USAGE), as opposed to merely declining to *weight* by its number.
@@ -212,6 +220,7 @@ export declare const USAGE_STALE_REFUSAL_MAX_AGE_MS: number;
212
220
  * running it refreshes that log. That self-reinforcing pin is exactly what the
213
221
  * narrowing rule below exists to prevent.
214
222
  */
223
+ export declare function usageVerifiedMaxAgeMs(snapshot: UsageSnapshot | null | undefined): number;
215
224
  export declare function isUsageVerified(candidate: RotateCandidate, nowMs?: number): boolean;
216
225
  /**
217
226
  * Whether this candidate carries a GENUINELY-STALE usage number: a snapshot with
@@ -140,6 +140,14 @@ export async function isVersionLaunchableHere(agent, version) {
140
140
  * launched into it while the account was actually at its weekly cap.
141
141
  */
142
142
  export const USAGE_DECISION_MAX_AGE_MS = 5 * 60 * 1000;
143
+ /**
144
+ * How old a *synced* snapshot (arrived from the account's poller through the
145
+ * fleet store) may be and still settle a routing decision. Matches the
146
+ * usage-sync tick so a worker that only ever sees 15-minute-old rows does not
147
+ * drop to the account picker. Local captures (`poll` / `statusline`) keep
148
+ * {@link USAGE_DECISION_MAX_AGE_MS}.
149
+ */
150
+ export const USAGE_SYNC_TRUST_MS = 15 * 60 * 1000;
143
151
  /**
144
152
  * How old a usage snapshot may be before routing REFUSES to run at all
145
153
  * (NO_VERIFIED_USAGE), as opposed to merely declining to *weight* by its number.
@@ -174,12 +182,18 @@ export const USAGE_STALE_REFUSAL_MAX_AGE_MS = 40 * 60 * 1000;
174
182
  * running it refreshes that log. That self-reinforcing pin is exactly what the
175
183
  * narrowing rule below exists to prevent.
176
184
  */
185
+ export function usageVerifiedMaxAgeMs(snapshot) {
186
+ // D8: a row that arrived via sync from the account's own poller is trusted
187
+ // for the sync cadence. A locally captured row (poll / statusline / unset)
188
+ // keeps the 5-minute bar — the poller is on this box and can refresh it.
189
+ return snapshot?.freshness?.source === 'sync' ? USAGE_SYNC_TRUST_MS : USAGE_DECISION_MAX_AGE_MS;
190
+ }
177
191
  export function isUsageVerified(candidate, nowMs = Date.now()) {
178
192
  const snapshot = candidate.usageSnapshot;
179
193
  const capturedAt = snapshot?.capturedAt;
180
194
  if (!capturedAt || !snapshot?.windows.length)
181
195
  return false;
182
- return nowMs - capturedAt.getTime() <= USAGE_DECISION_MAX_AGE_MS;
196
+ return nowMs - capturedAt.getTime() <= usageVerifiedMaxAgeMs(snapshot);
183
197
  }
184
198
  /**
185
199
  * Whether this candidate carries a GENUINELY-STALE usage number: a snapshot with
@@ -996,6 +1010,8 @@ function describeRotationCandidate(c, nowMs) {
996
1010
  tier,
997
1011
  source: snap?.source ?? null,
998
1012
  sourceLabel: snap?.sourceLabel ?? null,
1013
+ captureSource: snap?.freshness?.source ?? null,
1014
+ pollerDevice: snap?.freshness?.poller ?? null,
999
1015
  capturedAt: snap?.capturedAt ? snap.capturedAt.toISOString() : null,
1000
1016
  ageMs: capturedAtMs === null ? null : nowMs - capturedAtMs,
1001
1017
  windows: (snap?.windows ?? []).map((w) => ({ key: w.key, usedPercent: Math.round(w.usedPercent) })),
@@ -9,6 +9,16 @@
9
9
  */
10
10
  import { type ConfiguredDeviceRole } from '../device-config.js';
11
11
  import { type CachedUsageSnapshot } from './usage.js';
12
+ /** Cadence of the usage-sync git tick — the trust window for a `sync` snapshot. */
13
+ export declare const USAGE_SYNC_INTERVAL_MS: number;
14
+ /** Re-publish an unchanged windowed row at least this often so workers' 15-min trust does not lapse. */
15
+ export declare const USAGE_PUBLISH_HEARTBEAT_MS: number;
16
+ /**
17
+ * Keep the previously published `capturedAt` when meters have not moved and
18
+ * the last publish is still inside the heartbeat. Stops statusline re-renders
19
+ * from dirtying the shared store (and taking the git lock) every few seconds.
20
+ */
21
+ export declare function mergeUsageRowsForPublish(previous: Record<string, CachedUsageSnapshot> | undefined, next: Record<string, CachedUsageSnapshot>, nowMs?: number): Record<string, CachedUsageSnapshot>;
12
22
  /** Legacy hidden ingest/export envelope kept for older fleet CLI compatibility. */
13
23
  export interface UsageSyncPayload {
14
24
  v: 1;
@@ -29,6 +39,18 @@ export interface PublishUsageSnapshotResult {
29
39
  }
30
40
  /** Publish this headed device's stable usage snapshot into its owned store file. */
31
41
  export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): Promise<PublishUsageSnapshotResult>;
42
+ /**
43
+ * Publish a changed snapshot immediately and exchange the shared repo so
44
+ * workers see it without waiting for the 15-minute tick. No-ops when the
45
+ * serialized store is unchanged (statusline re-renders with the same windows).
46
+ */
47
+ export declare function pushUsageSnapshotNow(options?: PublishUsageSnapshotOptions & {
48
+ lockPath?: string;
49
+ timeoutMs?: number;
50
+ }): Promise<{
51
+ published: PublishUsageSnapshotResult;
52
+ transport?: import('../fleet-shared-repo-sync.js').FleetSharedRepoSyncResult;
53
+ }>;
32
54
  export interface ConsumeUsageSnapshotsOptions {
33
55
  userAgentsDir?: string;
34
56
  cachePath?: string;
@@ -12,6 +12,41 @@ import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from
12
12
  import { getUserAgentsDir } from '../state.js';
13
13
  import { machineId, normalizeHost } from '../session/sync/config.js';
14
14
  import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
15
+ /** Cadence of the usage-sync git tick — the trust window for a `sync` snapshot. */
16
+ export const USAGE_SYNC_INTERVAL_MS = 15 * 60_000;
17
+ /** Re-publish an unchanged windowed row at least this often so workers' 15-min trust does not lapse. */
18
+ export const USAGE_PUBLISH_HEARTBEAT_MS = 10 * 60_000;
19
+ function usageMeterSignature(row) {
20
+ return JSON.stringify({
21
+ windows: row.windows,
22
+ plan: row.plan ?? null,
23
+ unavailable: row.unavailable ?? null,
24
+ freshnessSource: row.freshnessSource ?? null,
25
+ pollerDevice: row.pollerDevice ?? null,
26
+ });
27
+ }
28
+ /**
29
+ * Keep the previously published `capturedAt` when meters have not moved and
30
+ * the last publish is still inside the heartbeat. Stops statusline re-renders
31
+ * from dirtying the shared store (and taking the git lock) every few seconds.
32
+ */
33
+ export function mergeUsageRowsForPublish(previous, next, nowMs = Date.now()) {
34
+ if (!previous)
35
+ return next;
36
+ const out = {};
37
+ for (const [key, row] of Object.entries(next)) {
38
+ const prior = previous[key];
39
+ if (prior && usageMeterSignature(prior) === usageMeterSignature(row)) {
40
+ const priorMs = prior.capturedAt ? Date.parse(prior.capturedAt) : NaN;
41
+ if (Number.isFinite(priorMs) && nowMs - priorMs < USAGE_PUBLISH_HEARTBEAT_MS) {
42
+ out[key] = { ...row, capturedAt: prior.capturedAt };
43
+ continue;
44
+ }
45
+ }
46
+ out[key] = row;
47
+ }
48
+ return out;
49
+ }
15
50
  /** Publish this headed device's stable usage snapshot into its owned store file. */
16
51
  export async function publishUsageSnapshotToSharedStore(options = {}) {
17
52
  const result = {
@@ -26,13 +61,19 @@ export async function publishUsageSnapshotToSharedStore(options = {}) {
26
61
  result.skipped = 'this device is not a usage publisher (mark it personal or desktop)';
27
62
  return result;
28
63
  }
29
- const rows = exportClaudeUsageCacheRows(options.cachePath);
30
- if (Object.keys(rows).length === 0) {
64
+ const rawRows = exportClaudeUsageCacheRows(options.cachePath);
65
+ if (Object.keys(rawRows).length === 0) {
31
66
  result.skipped = 'no local usage snapshot to publish';
32
67
  return result;
33
68
  }
34
69
  try {
35
- const write = await updateFleetSharedDeviceStateAsync(options.device ?? machineId(), { usage: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
70
+ const device = options.device ?? machineId();
71
+ const userAgentsDir = options.userAgentsDir ?? getUserAgentsDir();
72
+ const prior = readFleetSharedDeviceStates(userAgentsDir).states
73
+ .find((state) => normalizeHost(state.device) === normalizeHost(device))
74
+ ?.usage?.rows;
75
+ const rows = mergeUsageRowsForPublish(prior, rawRows);
76
+ const write = await updateFleetSharedDeviceStateAsync(device, { usage: { rows } }, userAgentsDir);
36
77
  result.published = true;
37
78
  result.changed = write.changed;
38
79
  result.path = write.path;
@@ -42,6 +83,24 @@ export async function publishUsageSnapshotToSharedStore(options = {}) {
42
83
  }
43
84
  return result;
44
85
  }
86
+ /**
87
+ * Publish a changed snapshot immediately and exchange the shared repo so
88
+ * workers see it without waiting for the 15-minute tick. No-ops when the
89
+ * serialized store is unchanged (statusline re-renders with the same windows).
90
+ */
91
+ export async function pushUsageSnapshotNow(options = {}) {
92
+ const published = await publishUsageSnapshotToSharedStore(options);
93
+ if (!published.changed)
94
+ return { published };
95
+ const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
96
+ const transport = await syncFleetSharedStateRepo({
97
+ userAgentsDir: options.userAgentsDir,
98
+ device: options.device,
99
+ timeoutMs: options.timeoutMs,
100
+ lockPath: options.lockPath,
101
+ });
102
+ return { published, transport };
103
+ }
45
104
  function capturedAtMs(row) {
46
105
  if (!row.capturedAt)
47
106
  return null;
@@ -70,15 +129,20 @@ export function consumeUsageSnapshotsFromSharedStore(options = {}) {
70
129
  for (const [identity, incoming] of Object.entries(state.usage.rows)) {
71
130
  if (!incoming || !Array.isArray(incoming.windows) || incoming.windows.length === 0)
72
131
  continue;
132
+ const stamped = {
133
+ ...incoming,
134
+ freshnessSource: 'sync',
135
+ pollerDevice: incoming.pollerDevice ?? state.device,
136
+ };
73
137
  const current = rows[identity];
74
138
  if (!current) {
75
- rows[identity] = incoming;
139
+ rows[identity] = stamped;
76
140
  continue;
77
141
  }
78
- const incomingMs = capturedAtMs(incoming);
142
+ const incomingMs = capturedAtMs(stamped);
79
143
  const currentMs = capturedAtMs(current);
80
144
  if (incomingMs !== null && (currentMs === null || incomingMs > currentMs))
81
- rows[identity] = incoming;
145
+ rows[identity] = stamped;
82
146
  }
83
147
  }
84
148
  result.sources.sort();
@@ -157,6 +157,12 @@ export declare function claudeAccessTokenNeedsRefresh(expiresAt: number | null |
157
157
  export declare function setClaudeUsageCachePathForTest(cachePath: string | null): string | null;
158
158
  /** Discriminator for usage window types. */
159
159
  export type UsageWindowKey = 'session' | 'week' | 'sonnet_week' | 'month';
160
+ /**
161
+ * How this box obtained a usage row (delta-spec D8). Distinct from
162
+ * {@link UsageSnapshot.source} (`live` | `last_seen`), which is how the
163
+ * number was collected from the harness/API.
164
+ */
165
+ export type UsageCaptureSource = 'poll' | 'statusline' | 'sync';
160
166
  /** A single rate-limit window with utilization percentage and reset time. */
161
167
  export interface UsageWindow {
162
168
  key: UsageWindowKey;
@@ -203,6 +209,16 @@ export interface UsageSnapshot {
203
209
  reason: 'session_limit' | 'out_of_credits';
204
210
  resetsAt?: Date;
205
211
  };
212
+ /**
213
+ * D8 freshness provenance. A `sync` row arrived from the account's poller
214
+ * through the fleet store and is trusted for the sync cadence; `poll` and
215
+ * `statusline` are local captures and keep the 5-minute decision bar.
216
+ */
217
+ freshness?: {
218
+ source: UsageCaptureSource;
219
+ /** Device that polled or ingested the authoritative reading. */
220
+ poller?: string;
221
+ };
206
222
  }
207
223
  /** Usage data plus any error encountered while fetching. */
208
224
  export interface UsageInfo {
@@ -257,6 +273,13 @@ interface UsageOptions {
257
273
  * an unattended loop never transmits the interactive login to Anthropic.
258
274
  */
259
275
  allowInteractiveLogin?: boolean;
276
+ /**
277
+ * Headed usage poller: skip the setup-token and the ACL keychain, and read
278
+ * only `<home>/.claude/.credentials.json` (the native rotating blob). A
279
+ * setup-token 403s on `/api/oauth/usage`; the keychain pops Touch ID. The
280
+ * file blob is the Linux headed native login, and a no-op when absent.
281
+ */
282
+ nativeFileLogin?: boolean;
260
283
  }
261
284
  /** Canonical input for a single usage fetch operation. */
262
285
  export interface UsageFetchInput {
@@ -317,6 +340,10 @@ export interface CachedUsageSnapshot {
317
340
  * usage bucket.
318
341
  */
319
342
  modelRefusals?: Record<string, CachedModelRefusal>;
343
+ /** D8: `poll` | `statusline` | `sync`. Survives export → ingest. */
344
+ freshnessSource?: UsageCaptureSource;
345
+ /** D8: device that polled or ingested the authoritative reading. */
346
+ pollerDevice?: string;
320
347
  }
321
348
  /** The single registry of agent usage sources and their transport. */
322
349
  declare const USAGE_SOURCES: {
@@ -406,6 +433,8 @@ export interface UsageLookupOptions {
406
433
  * `loadClaudeOauth`. Unset for every other lookup.
407
434
  */
408
435
  allowInteractiveLogin?: boolean;
436
+ /** Headed poller: native `.credentials.json` only, never the setup-token. */
437
+ nativeFileLogin?: boolean;
409
438
  }
410
439
  export declare function getUsageInfoByIdentity(inputs: UsageIdentityInput[], opts?: UsageLookupOptions): Promise<{
411
440
  canonicalByUsageKey: Map<string, AccountInfo>;
@@ -712,10 +741,13 @@ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse):
712
741
  * `.credentials.json` only. Used by the daemon usage refresher so a background
713
742
  * tick can never pop Touch ID.
714
743
  */
744
+ /** True when `<home>/.claude/.credentials.json` is a native rotating OAuth blob. */
745
+ export declare function claudeHomeHasNativeOauthFile(home?: string): boolean;
715
746
  export declare function loadClaudeOauth(home?: string, opts?: {
716
747
  accessTokenCache?: boolean;
717
748
  fileOnly?: boolean;
718
749
  allowInteractiveLogin?: boolean;
750
+ nativeFileLogin?: boolean;
719
751
  }): Promise<ClaudeOauthCredentials | null>;
720
752
  /**
721
753
  * Save Claude OAuth credentials to the system keychain/keyring.
@@ -431,6 +431,7 @@ export async function getUsageInfoForIdentity(input, opts) {
431
431
  organizationId: input.info.organizationId,
432
432
  fileOnly: opts?.fileOnly,
433
433
  allowInteractiveLogin: opts?.allowInteractiveLogin,
434
+ nativeFileLogin: opts?.nativeFileLogin,
434
435
  signal: opts?.signal,
435
436
  });
436
437
  }
@@ -469,6 +470,7 @@ export async function getUsageInfoForIdentity(input, opts) {
469
470
  // Explicit refresh: block on the shared device collector.
470
471
  return fetchLiveUsageDeduped(input, usageKey, cached, opts?.fileOnly === true, {
471
472
  allowInteractiveLogin: opts?.allowInteractiveLogin === true,
473
+ nativeFileLogin: opts?.nativeFileLogin === true,
472
474
  signal: opts?.signal,
473
475
  });
474
476
  }
@@ -501,6 +503,7 @@ async function fetchLiveUsageDeduped(input, usageKey, cached, fileOnly, opts) {
501
503
  usageScope: usageKey,
502
504
  fileOnly,
503
505
  allowInteractiveLogin: opts?.allowInteractiveLogin === true,
506
+ nativeFileLogin: opts?.nativeFileLogin === true,
504
507
  signal: opts?.signal,
505
508
  });
506
509
  if (usage.snapshot) {
@@ -935,9 +938,10 @@ async function getClaudeUsageInfo(options) {
935
938
  // requires (the setup-token is user:inference → 403, RUSH-2392). It is unset
936
939
  // for every background caller, so the RUSH-1822 guarantee is untouched there.
937
940
  const oauth = await loadClaudeOauth(options?.home, {
938
- accessTokenCache: true,
939
- fileOnly: options?.fileOnly === true,
941
+ accessTokenCache: options?.nativeFileLogin !== true,
942
+ fileOnly: options?.fileOnly === true || options?.nativeFileLogin === true,
940
943
  allowInteractiveLogin: options?.allowInteractiveLogin === true,
944
+ nativeFileLogin: options?.nativeFileLogin === true,
941
945
  });
942
946
  if (!oauth?.accessToken) {
943
947
  // NOT the shared no-credential message: "sign in" is not a remedy here.
@@ -1612,7 +1616,33 @@ function deleteCachedClaudeOauth(service) {
1612
1616
  * `.credentials.json` only. Used by the daemon usage refresher so a background
1613
1617
  * tick can never pop Touch ID.
1614
1618
  */
1619
+ /** True when `<home>/.claude/.credentials.json` is a native rotating OAuth blob. */
1620
+ export function claudeHomeHasNativeOauthFile(home) {
1621
+ return readClaudeNativeCredentialsFile(home) !== null;
1622
+ }
1623
+ function readClaudeNativeCredentialsFile(home) {
1624
+ const credsPath = path.join(home ?? os.homedir(), '.claude', '.credentials.json');
1625
+ try {
1626
+ if (!fs.existsSync(credsPath))
1627
+ return null;
1628
+ const parsed = parseClaudeOauthPayload(fs.readFileSync(credsPath, 'utf-8'));
1629
+ // A native rotating blob has a refresh token. A setup-token-shaped file
1630
+ // (access only) is not a native login and must not be polled as one.
1631
+ if (!parsed?.accessToken?.trim() || !parsed.refreshToken?.trim())
1632
+ return null;
1633
+ return parsed;
1634
+ }
1635
+ catch {
1636
+ return null;
1637
+ }
1638
+ }
1615
1639
  export async function loadClaudeOauth(home, opts) {
1640
+ // Headed usage poller: the native rotating blob in `.credentials.json` is
1641
+ // the only credential that carries `user:profile` without opening the ACL
1642
+ // keychain (Touch ID) or firing a setup-token that 403s on /oauth/usage.
1643
+ if (opts?.nativeFileLogin === true) {
1644
+ return readClaudeNativeCredentialsFile(home);
1645
+ }
1616
1646
  // Read-only usage/probe callers (accessTokenCache) authenticate ONLY with a
1617
1647
  // file-based setup-token from the `auth` bundle — never Claude Code's
1618
1648
  // interactive login. The usage endpoint accepts any sk-ant-oat01 bearer, and
@@ -1963,6 +1993,8 @@ function serializeClaudeUsageSnapshot(snapshot) {
1963
1993
  resetsAt: window.resetsAt?.toISOString() || null,
1964
1994
  windowMinutes: window.windowMinutes,
1965
1995
  })),
1996
+ freshnessSource: snapshot.freshness?.source,
1997
+ pollerDevice: snapshot.freshness?.poller,
1966
1998
  };
1967
1999
  }
1968
2000
  /**
@@ -2028,6 +2060,9 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
2028
2060
  plan: snapshot.plan ?? null,
2029
2061
  refreshHint: snapshot.refreshHint ?? null,
2030
2062
  unavailable,
2063
+ freshness: snapshot.freshnessSource
2064
+ ? { source: snapshot.freshnessSource, poller: snapshot.pollerDevice }
2065
+ : undefined,
2031
2066
  };
2032
2067
  }
2033
2068
  /**
@@ -25,6 +25,8 @@ import { getCacheDir, readMeta } from './state.js';
25
25
  import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, readClaudeUsageCache, } from './accounting/usage.js';
26
26
  import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
27
27
  import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
28
+ import { selfConfiguredDeviceRole } from './device-config.js';
29
+ import { mayIssueUsageEndpointProbe, trySpendUsageApiCall } from './usage-refresh.js';
28
30
  /** Maximum age of an auth verdict used for automatic routing decisions. */
29
31
  export const AUTH_PROBE_MAX_AGE_MS = 20 * 60_000;
30
32
  /** Agents with a live network probe wired up today. The rest are best-effort. */
@@ -455,6 +457,23 @@ export async function probeAuthHealth(agent, home, opts) {
455
457
  if (derived)
456
458
  return derived;
457
459
  }
460
+ if (!mayIssueUsageEndpointProbe({
461
+ role: selfConfiguredDeviceRole(),
462
+ forceLive: opts?.forceLive,
463
+ })) {
464
+ return {
465
+ verdict: 'unverified',
466
+ checkedAt,
467
+ detail: 'setup-token box does not probe the usage endpoint',
468
+ };
469
+ }
470
+ if (opts?.forceLive !== true && usageScope && !trySpendUsageApiCall(usageScope, agent, checkedAt)) {
471
+ return {
472
+ verdict: 'unverified',
473
+ checkedAt,
474
+ detail: 'usage-endpoint budget already spent this hour',
475
+ };
476
+ }
458
477
  let probe;
459
478
  if (agent === 'claude')
460
479
  probe = await probeClaudeStatus(home, opts?.cliVersion, usageScope, opts?.signal);
@@ -8,6 +8,7 @@ import { atomicWriteFileSync } from './fs-atomic.js';
8
8
  import { mergeClaudeUsageCacheWindows } from './accounting/usage.js';
9
9
  import { loadReminders, pickReminderForSession } from './reminders.js';
10
10
  import { readMeta } from './state.js';
11
+ import { machineId } from './machine-id.js';
11
12
  export const CLAUDE_STATUSLINE_COMMAND = 'agents __claude-statusline';
12
13
  const DELEGATE_FILE = path.join('.agents', 'claude-statusline-delegate');
13
14
  // The private subcommand this feature runs. It is only ever invoked internally,
@@ -77,7 +78,11 @@ export function ingestClaudeStatusLineUsage(payload, identity) {
77
78
  sourceLabel: 'Claude response rate limits',
78
79
  capturedAt: new Date(),
79
80
  windows,
81
+ freshness: { source: 'statusline', poller: machineId() },
80
82
  });
83
+ void import('./accounting/usage-sync.js')
84
+ .then((mod) => mod.pushUsageSnapshotNow())
85
+ .catch(() => { });
81
86
  return true;
82
87
  }
83
88
  /**