@phnx-labs/agents-cli 1.22.109 → 1.22.110

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 (64) hide show
  1. package/CHANGELOG.md +44 -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/session/active.d.ts +93 -16
  42. package/dist/lib/session/active.js +80 -14
  43. package/dist/lib/session/db.d.ts +44 -1
  44. package/dist/lib/session/db.js +122 -17
  45. package/dist/lib/session/fork.d.ts +10 -2
  46. package/dist/lib/session/fork.js +11 -2
  47. package/dist/lib/session/live-metadata.js +6 -0
  48. package/dist/lib/session/mirror.d.ts +3 -2
  49. package/dist/lib/session/mirror.js +5 -2
  50. package/dist/lib/session/remote/watch.js +18 -8
  51. package/dist/lib/session/title.d.ts +256 -0
  52. package/dist/lib/session/title.js +312 -0
  53. package/dist/lib/session/tool-index.d.ts +5 -0
  54. package/dist/lib/session/tool-index.js +1 -0
  55. package/dist/lib/session/types.d.ts +11 -0
  56. package/dist/lib/startup/root-command.d.ts +2 -0
  57. package/dist/lib/startup/root-command.js +22 -0
  58. package/dist/lib/traces/sync.js +1 -0
  59. package/dist/lib/usage-refresh.d.ts +58 -7
  60. package/dist/lib/usage-refresh.js +145 -23
  61. package/dist/lib/watchdog/runner.d.ts +3 -0
  62. package/dist/lib/watchdog/runner.js +1 -0
  63. package/dist/session-tracker/dist/install-hook.js +4 -4
  64. package/package.json +1 -1
@@ -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
  /**
@@ -61,6 +61,7 @@
61
61
  import { query, truncate } from '../feed/events.js';
62
62
  import { formatRelativeTime } from '../session/relative-time.js';
63
63
  import { getSessionById, listComputerSessionRecords, pruneToolSessions } from '../session/db.js';
64
+ import { sessionHeadline } from '../session/title.js';
64
65
  import { buildLaunchSessionIndex, resolveLaunchSession, } from '../browser/sessions-list.js';
65
66
  /** Max chars of a `run --task` description persisted to the ledger — see the
66
67
  * module docblock's retention/privacy note. */
@@ -315,7 +316,7 @@ export function renderComputerSessionRows(rows) {
315
316
  const where = r.remoteHost ? `${r.machine} -> ${r.remoteHost}` : r.machine;
316
317
  const label = r.task ? truncate(r.task, 60) : (r.bundle ?? `pid ${r.pid}`);
317
318
  const link = r.linkStatus === 'linked' && r.linkedSession
318
- ? `${r.linkedSession.agent} — ${r.linkedSession.label || r.linkedSession.topic || r.linkedSession.shortId}`
319
+ ? `${r.linkedSession.agent} — ${sessionHeadline(r.linkedSession) || r.linkedSession.shortId}`
319
320
  : r.linkStatus === 'unresolved'
320
321
  ? 'unresolved (session not indexed here)'
321
322
  : 'unlinked';
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import { BasePeriodicService, type DaemonContext } from './service.js';
27
27
  import type { DaemonServiceId } from '../daemon-services.js';
28
+ export declare const AUTH_SYNC_TICK_MS: number;
29
+ export declare const AUTH_SYNC_KICKOFF_MS = 60000;
28
30
  export declare class AuthSyncService extends BasePeriodicService {
29
31
  readonly id: DaemonServiceId;
30
32
  readonly intervalMs: number;
@@ -25,9 +25,9 @@
25
25
  */
26
26
  import { BasePeriodicService } from './service.js';
27
27
  import { USAGE_SYNC_TICK_MS } from './usage-sync-service.js';
28
- const AUTH_SYNC_TICK_MS = 15 * 60_000;
28
+ export const AUTH_SYNC_TICK_MS = 15 * 60_000;
29
29
  const AUTH_SYNC_DEADLINE_MS = 2 * 60_000;
30
- const AUTH_SYNC_KICKOFF_MS = 60_000;
30
+ export const AUTH_SYNC_KICKOFF_MS = 60_000;
31
31
  export class AuthSyncService extends BasePeriodicService {
32
32
  id = 'auth-sync';
33
33
  intervalMs = AUTH_SYNC_TICK_MS;
@@ -32,6 +32,7 @@ import { recordSubsystemOk, recordSubsystemError, recordSubsystemErrorReason, re
32
32
  import { ServiceSupervisor } from './supervisor.js';
33
33
  import { SessionIndexService } from './session-index-service.js';
34
34
  import { SessionSummarizerService } from './session-summarizer-service.js';
35
+ import { SessionTitleService } from './session-title-service.js';
35
36
  import { MonitorEngineService } from './monitor-engine-service.js';
36
37
  import { AccountUsageService, AccountAuthService } from './account-state-daemon-service.js';
37
38
  import { CatchupService } from './catchup-service.js';
@@ -1070,6 +1071,12 @@ export async function runDaemon() {
1070
1071
  supervisor.register(new AttentionNotifyService());
1071
1072
  else
1072
1073
  log('INFO', 'Attention-notify service disabled');
1074
+ // Session titles (PHNX-3797) — generates each session row's headline once,
1075
+ // with a cheap model, off the request path.
1076
+ if (isEnabled('session-title'))
1077
+ supervisor.register(new SessionTitleService());
1078
+ else
1079
+ log('INFO', 'Session-title service disabled');
1073
1080
  // Watchdog, device-probe, and self-heal are all periodic services managed
1074
1081
  // by the ServiceSupervisor (RUSH-3193 P3). Each is gated the same way as
1075
1082
  // the socket services above; state-dir-check is registered separately,
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Session-title service (PHNX-3797) — the ONE generator of session headlines.
3
+ *
4
+ * Every session row's headline used to be the agent's latest transcript line.
5
+ * This service replaces it with a user-anchored NAME: it sweeps the local
6
+ * session index for recent sessions whose title is still the raw first user
7
+ * message, asks a {@link SessionTitleProvider} (the cloud cheap-model call by
8
+ * default) for a short action+object headline, and persists it
9
+ * (`lib/session/title.ts`). Because the value lands in the index, every consumer
10
+ * — the CLI list, the picker, `sessions watch --json`, the fleet mirror, AGI EXT
11
+ * — reads one title that was generated exactly once.
12
+ *
13
+ * Cost is bounded by construction, not by hope: at most
14
+ * {@link SESSION_TITLE_MAX_PER_TICK} generations per tick, only for sessions
15
+ * inside the display window, only for rows whose stored source key no longer
16
+ * matches their user text (so a titled session is a pure DB read forever after),
17
+ * and never for the titler's own spawned sessions. When the harness is missing
18
+ * or signed out every attempt fails, so the service backs off exponentially
19
+ * instead of respawning two processes a minute forever — the rows simply keep
20
+ * showing the user's own words, which is the honest fallback.
21
+ */
22
+ import type { DaemonServiceId } from '../daemon-services.js';
23
+ import { type SessionTitleRunner } from '../session/title.js';
24
+ import { BasePeriodicService, type DaemonContext } from './service.js';
25
+ export declare class SessionTitleService extends BasePeriodicService {
26
+ private readonly run?;
27
+ readonly id: DaemonServiceId;
28
+ readonly intervalMs: number;
29
+ readonly deadlineMs = 110000;
30
+ /** Let the box settle (shims, PATH, index warm) before spawning a harness. */
31
+ readonly startupDelayMs = 60000;
32
+ /** Ticks still to skip before the next attempt; 0 = attempt this tick. */
33
+ private skipTicks;
34
+ /** Skip count applied after the next all-failed sweep. */
35
+ private backoff;
36
+ /** `run` is the model-call seam (defaults to the real subprocess); tests inject one. */
37
+ constructor(run?: SessionTitleRunner | undefined);
38
+ protected onStart(_ctx: DaemonContext): Promise<void>;
39
+ protected onStop(): Promise<void>;
40
+ protected onTick(ctx: DaemonContext, signal: AbortSignal): Promise<void>;
41
+ }