@phnx-labs/agents-cli 1.22.107 → 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 (95) hide show
  1. package/CHANGELOG.md +96 -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/daemon.js +45 -40
  7. package/dist/commands/doctor.js +7 -0
  8. package/dist/commands/exec.js +4 -2
  9. package/dist/commands/fork.js +6 -4
  10. package/dist/commands/logs.js +2 -1
  11. package/dist/commands/monitors.js +15 -2
  12. package/dist/commands/routines.js +46 -6
  13. package/dist/commands/sessions-backfill.d.ts +30 -0
  14. package/dist/commands/sessions-backfill.js +72 -0
  15. package/dist/commands/sessions-backup-setup.d.ts +12 -0
  16. package/dist/commands/sessions-backup-setup.js +65 -0
  17. package/dist/commands/sessions-inject.d.ts +3 -3
  18. package/dist/commands/sessions-inject.js +5 -11
  19. package/dist/commands/sessions-picker.js +10 -7
  20. package/dist/commands/sessions-resume.d.ts +17 -2
  21. package/dist/commands/sessions-resume.js +88 -12
  22. package/dist/commands/sessions.js +46 -24
  23. package/dist/commands/share.js +15 -2
  24. package/dist/commands/sync.js +30 -1
  25. package/dist/commands/utils.d.ts +9 -0
  26. package/dist/commands/utils.js +18 -0
  27. package/dist/commands/watchdog.js +4 -1
  28. package/dist/lib/accounting/rotate.d.ts +9 -0
  29. package/dist/lib/accounting/rotate.js +17 -1
  30. package/dist/lib/accounting/usage-sync.d.ts +22 -0
  31. package/dist/lib/accounting/usage-sync.js +70 -6
  32. package/dist/lib/accounting/usage.d.ts +32 -0
  33. package/dist/lib/accounting/usage.js +37 -2
  34. package/dist/lib/accounts/slots.js +7 -0
  35. package/dist/lib/agent-spec/agents.d.ts +60 -8
  36. package/dist/lib/agent-spec/agents.js +118 -45
  37. package/dist/lib/auth-health.js +19 -0
  38. package/dist/lib/claude-statusline.js +5 -0
  39. package/dist/lib/computer/sessions-list.js +2 -1
  40. package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
  41. package/dist/lib/daemon/auth-sync-service.js +2 -2
  42. package/dist/lib/daemon/daemon.d.ts +34 -1
  43. package/dist/lib/daemon/daemon.js +70 -9
  44. package/dist/lib/daemon/leaked-daemons.d.ts +60 -0
  45. package/dist/lib/daemon/leaked-daemons.js +180 -0
  46. package/dist/lib/daemon/session-title-service.d.ts +41 -0
  47. package/dist/lib/daemon/session-title-service.js +76 -0
  48. package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
  49. package/dist/lib/daemon/usage-sync-service.js +10 -2
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +5 -0
  52. package/dist/lib/daemon-ticks.js +6 -0
  53. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  54. package/dist/lib/devices/doctor-findings.js +32 -1
  55. package/dist/lib/fleet-shared-state.d.ts +2 -0
  56. package/dist/lib/hooks/install.js +78 -64
  57. package/dist/lib/mailbox-target.js +2 -1
  58. package/dist/lib/models.js +76 -5
  59. package/dist/lib/session/active.d.ts +93 -16
  60. package/dist/lib/session/active.js +80 -14
  61. package/dist/lib/session/cloud.js +3 -1
  62. package/dist/lib/session/db.d.ts +44 -1
  63. package/dist/lib/session/db.js +122 -17
  64. package/dist/lib/session/fork.d.ts +10 -2
  65. package/dist/lib/session/fork.js +11 -2
  66. package/dist/lib/session/live-metadata.js +6 -0
  67. package/dist/lib/session/mirror.d.ts +3 -2
  68. package/dist/lib/session/mirror.js +5 -2
  69. package/dist/lib/session/parse.d.ts +1 -0
  70. package/dist/lib/session/parse.js +136 -2
  71. package/dist/lib/session/recovery.d.ts +9 -1
  72. package/dist/lib/session/recovery.js +14 -4
  73. package/dist/lib/session/remote/watch.js +18 -8
  74. package/dist/lib/session/title.d.ts +256 -0
  75. package/dist/lib/session/title.js +312 -0
  76. package/dist/lib/session/tool-calls.d.ts +1 -1
  77. package/dist/lib/session/tool-calls.js +40 -5
  78. package/dist/lib/session/tool-index.d.ts +5 -0
  79. package/dist/lib/session/tool-index.js +1 -0
  80. package/dist/lib/session/types.d.ts +11 -0
  81. package/dist/lib/share/backend.d.ts +23 -0
  82. package/dist/lib/share/backend.js +24 -0
  83. package/dist/lib/share/provision.d.ts +12 -0
  84. package/dist/lib/share/provision.js +30 -0
  85. package/dist/lib/share/worker-template.js +273 -0
  86. package/dist/lib/startup/root-command.d.ts +2 -0
  87. package/dist/lib/startup/root-command.js +22 -0
  88. package/dist/lib/terminal/engine.js +13 -1
  89. package/dist/lib/traces/sync.js +1 -0
  90. package/dist/lib/usage-refresh.d.ts +58 -7
  91. package/dist/lib/usage-refresh.js +145 -23
  92. package/dist/lib/watchdog/runner.d.ts +3 -0
  93. package/dist/lib/watchdog/runner.js +1 -0
  94. package/dist/session-tracker/dist/install-hook.js +4 -4
  95. package/package.json +1 -1
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Daemon-owned usage refresher — runs on every host.
2
+ * Daemon-owned usage refresher — one poller per account, on a headed box.
3
3
  *
4
4
  * The routing hot path (`agents run` → collectRunCandidates) reads usage
5
5
  * CACHE-ONLY (`getUsageInfoForIdentity`, RUSH-2061) and never blocks
6
- * on a provider fetch. Something still has to keep that cache fresh — this
7
- * module is that something, running inside the daemon on every host
8
- * (RUSH-3193 #15: no primary/subscriber split — each host refreshes only
9
- * the accounts it holds credentials for).
6
+ * on a provider fetch. A headed box (`isHeadedDeviceRole`) polls only the
7
+ * accounts it holds native logins for; a setup-token-only box never polls.
8
+ * A stray `.credentials.json` for an account another headed box already
9
+ * publishes defers to that poller. Auth-health draws from the same
10
+ * per-account call budget so the two stay under the ~100/hr ceiling.
10
11
  *
11
12
  * Design:
12
13
  *
@@ -48,11 +49,15 @@
48
49
  */
49
50
  import * as fs from 'fs';
50
51
  import * as path from 'path';
51
- import { getCacheDir } from './state.js';
52
+ import { getCacheDir, getUserAgentsDir } from './state.js';
52
53
  import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
53
- import { deriveUsageHeadroom, buildCanonicalUsageContext, agentUsesNetworkUsage, USAGE_SOURCE_AGENT_IDS, } from './accounting/usage.js';
54
- import { getAccountInfo } from './agents.js';
54
+ import { deriveUsageHeadroom, buildCanonicalUsageContext, agentUsesNetworkUsage, USAGE_SOURCE_AGENT_IDS, claudeHomeHasNativeOauthFile, } from './accounting/usage.js';
55
+ import { getAccountInfo, credentialPresence } from './agents.js';
55
56
  import { listInstalledVersions, getVersionHomePath } from './installations/versions.js';
57
+ import { isHeadedDeviceRole, selfConfiguredDeviceRole } from './device-config.js';
58
+ import { machineId, normalizeHost } from './session/sync/config.js';
59
+ import { readFleetSharedDeviceStates } from './fleet-shared-state.js';
60
+ import { USAGE_SYNC_INTERVAL_MS } from './accounting/usage-sync.js';
56
61
  /**
57
62
  * Default schedule between successful (or attempted) live usage fetches for one
58
63
  * account. Floor and ceiling of the delay helper are pinned to this so the
@@ -319,6 +324,112 @@ function failedHeadroomEntry(prev, now) {
319
324
  }
320
325
  return next;
321
326
  }
327
+ /**
328
+ /** Daemon auth-health may hit `/oauth/usage` only on a headed box, unless forceLive. */
329
+ export function mayIssueUsageEndpointProbe(opts) {
330
+ if (opts.forceLive === true)
331
+ return true;
332
+ return isHeadedDeviceRole(opts.role);
333
+ }
334
+ /**
335
+ * Sticky one-poller election: lex-least among this box (if it holds a native
336
+ * login) and peer devices that published a `freshnessSource=poll` row. Statusline
337
+ * ingest does not claim. Two headed boxes therefore converge on one poller
338
+ * instead of each deferring to the other.
339
+ */
340
+ export function electUsagePoller(opts) {
341
+ const names = new Set();
342
+ if (opts.selfHoldsNativeLogin)
343
+ names.add(normalizeHost(opts.selfDevice));
344
+ for (const peer of opts.peerPollers ?? [])
345
+ names.add(normalizeHost(peer));
346
+ if (names.size === 0)
347
+ return null;
348
+ return [...names].sort()[0];
349
+ }
350
+ export function shouldPollUsageAccount(candidate, opts) {
351
+ if (!isHeadedDeviceRole(opts.role))
352
+ return false;
353
+ if (!candidate.holdsNativeLogin)
354
+ return false;
355
+ const peerPollers = opts.peerPollers
356
+ ?? (opts.claimedBy?.[candidate.usageKey] ? [opts.claimedBy[candidate.usageKey]] : []);
357
+ const elected = electUsagePoller({
358
+ selfDevice: opts.selfDevice,
359
+ selfHoldsNativeLogin: true,
360
+ peerPollers,
361
+ });
362
+ return elected === normalizeHost(opts.selfDevice);
363
+ }
364
+ /** Native rotating login on this home — Claude's `.credentials.json` blob, else a credential file. */
365
+ export function homeHoldsNativeLogin(agentId, home) {
366
+ if (agentId === 'claude')
367
+ return claudeHomeHasNativeOauthFile(home);
368
+ return credentialPresence(agentId, home).perVersion;
369
+ }
370
+ /** usageKey → devices that published a `poll` row (statusline/sync do not claim). */
371
+ export function pollerClaimsFromSharedStore(selfDevice, userAgentsDir = getUserAgentsDir()) {
372
+ const claims = {};
373
+ const read = readFleetSharedDeviceStates(userAgentsDir);
374
+ const self = normalizeHost(selfDevice);
375
+ for (const state of read.states) {
376
+ if (!state.usage)
377
+ continue;
378
+ for (const [key, row] of Object.entries(state.usage.rows)) {
379
+ if (row.freshnessSource !== 'poll')
380
+ continue;
381
+ const capturedMs = row.capturedAt ? Date.parse(row.capturedAt) : NaN;
382
+ if (!Number.isFinite(capturedMs) || Date.now() - capturedMs > USAGE_SYNC_INTERVAL_MS)
383
+ continue;
384
+ const poller = normalizeHost(row.pollerDevice ?? state.device);
385
+ if (!poller || poller === self)
386
+ continue;
387
+ const list = claims[key] ?? [];
388
+ if (!list.includes(poller))
389
+ list.push(poller);
390
+ claims[key] = list;
391
+ }
392
+ }
393
+ return claims;
394
+ }
395
+ /**
396
+ * Spend one live usage-endpoint call from the shared per-account / per-provider
397
+ * budget. Auth-health and the usage poller both go through here so they cannot
398
+ * together exceed {@link PROVIDER_HOURLY_BUDGET} (~30/hr, well under ~100/hr).
399
+ * Returns false when the call must not fire.
400
+ */
401
+ export function trySpendUsageApiCall(usageKey, agentId, now) {
402
+ if (!agentUsesNetworkUsage(agentId))
403
+ return true;
404
+ const cache = readHeadroomCache();
405
+ const entry = cache[usageKey];
406
+ const recent = pruneCallTimestamps(entry?.callTimestamps ?? [], now);
407
+ if (recent.length >= HOURLY_CALL_CAP)
408
+ return false;
409
+ let providerSpent = recent.length;
410
+ for (const [key, other] of Object.entries(cache)) {
411
+ if (key === usageKey)
412
+ continue;
413
+ if (!key.startsWith(`${agentId}:`))
414
+ continue;
415
+ providerSpent += pruneCallTimestamps(other.callTimestamps ?? [], now).length;
416
+ }
417
+ if (providerSpent >= PROVIDER_HOURLY_BUDGET)
418
+ return false;
419
+ writeHeadroomEntries({
420
+ [usageKey]: {
421
+ status: entry?.status ?? null,
422
+ minutesToLimit: entry?.minutesToLimit ?? null,
423
+ sessionUsedPercent: entry?.sessionUsedPercent ?? null,
424
+ capturedAt: entry?.capturedAt ?? null,
425
+ nextRefreshAt: entry?.nextRefreshAt ?? now,
426
+ callTimestamps: [...recent, now],
427
+ computedAt: now,
428
+ consecutiveFailures: entry?.consecutiveFailures ?? 0,
429
+ },
430
+ });
431
+ return true;
432
+ }
322
433
  /**
323
434
  * Order a pass STALEST-FIRST so a scarce per-provider budget
324
435
  * ({@link PROVIDER_HOURLY_BUDGET}) is always spent on the accounts most in need
@@ -362,14 +473,17 @@ export function providerRecentCalls(accounts, cache, now) {
362
473
  return counts;
363
474
  }
364
475
  /**
365
- * Enumerate the usage accounts whose credentials live on THIS host — one
366
- * per unique usage key, deduped to the most-recently-active version (the same
367
- * canonicalization `getUsageInfoByIdentity` uses). Each carries a closure that
368
- * live-fetches its usage. This is the daemon's `listAccounts`; because it only
369
- * ever lists local, signed-in accounts, each host is the sole writer for its own
370
- * accounts' caches. `runUsageRefreshTick` calls this on every host.
476
+ * Enumerate the usage accounts THIS headed box should poll — native logins it
477
+ * holds, minus accounts another headed poller already claims. A worker or
478
+ * setup-token-only box returns [].
371
479
  */
372
- export async function buildLocalUsageAccounts() {
480
+ export async function buildLocalUsageAccounts(opts = {}) {
481
+ const role = opts.role ?? selfConfiguredDeviceRole();
482
+ if (!isHeadedDeviceRole(role))
483
+ return [];
484
+ const selfDevice = opts.device ?? machineId();
485
+ const claimedBy = opts.claimedBy ?? pollerClaimsFromSharedStore(selfDevice, opts.userAgentsDir);
486
+ const nativeAt = opts.holdsNativeLogin ?? homeHoldsNativeLogin;
373
487
  const accounts = [];
374
488
  for (const agentId of USAGE_SOURCE_AGENT_IDS) {
375
489
  const versions = listInstalledVersions(agentId);
@@ -384,14 +498,15 @@ export async function buildLocalUsageAccounts() {
384
498
  const canonical = canonicalByUsageKey.get(usageKey);
385
499
  if (!canonical?.signedIn)
386
500
  continue; // only refresh accounts actually usable here
501
+ const home = fetchInput.home ?? getVersionHomePath(agentId, fetchInput.cliVersion ?? '');
502
+ if (!shouldPollUsageAccount({ usageKey, holdsNativeLogin: nativeAt(agentId, home) }, { role, selfDevice, peerPollers: claimedBy[usageKey] }))
503
+ continue;
387
504
  accounts.push({
388
505
  usageKey,
389
506
  agentId,
390
- // fileOnly: never open the ACL-bound keychain item from the daemon —
391
- // that path is the Touch ID storm. Usage reads the file-based setup-token
392
- // only, never the interactive login (see loadClaudeOauth); no setup-token
393
- // reads as "usage unavailable (no usage credential)" — "usage pending"
394
- // is now the cold-cache state only (#2987).
507
+ // Native file login: skip setup-token (403s on /oauth/usage) and the
508
+ // ACL keychain (Touch ID). Linux headed boxes store the rotating blob
509
+ // in `.credentials.json`; a missing file is a no-op fetch.
395
510
  fetch: async (signal) => {
396
511
  const { getUsageInfoForIdentity } = await import('./accounting/usage.js');
397
512
  return getUsageInfoForIdentity({
@@ -399,7 +514,7 @@ export async function buildLocalUsageAccounts() {
399
514
  home: fetchInput.home,
400
515
  cliVersion: fetchInput.cliVersion,
401
516
  info: canonical,
402
- }, { forceRefresh: true, fileOnly: true, signal });
517
+ }, { forceRefresh: true, fileOnly: true, nativeFileLogin: true, signal });
403
518
  },
404
519
  });
405
520
  }
@@ -441,6 +556,7 @@ export async function runUsageRefresh(deps) {
441
556
  spacingTokens.set(agent, providerSpacingTokens(last, now));
442
557
  const spacingUsed = new Map();
443
558
  const updates = {};
559
+ const refreshedKeys = [];
444
560
  for (const [index, account] of accounts.entries()) {
445
561
  const entry = cache[account.usageKey] ?? null;
446
562
  const network = agentUsesNetworkUsage(account.agentId);
@@ -492,9 +608,13 @@ export async function runUsageRefresh(deps) {
492
608
  // `source` is provenance, not freshness. A forced collection that just
493
609
  // reread a local harness event returns `last_seen`; that is still a
494
610
  // successful collection and belongs in the shared read cache.
495
- deps.writeUsageCache(account.usageKey, usage.snapshot);
496
- updates[account.usageKey] = nextHeadroomEntry(entry, usage.snapshot, now);
611
+ const stamped = deps.pollerDevice
612
+ ? { ...usage.snapshot, freshness: { source: 'poll', poller: deps.pollerDevice } }
613
+ : usage.snapshot;
614
+ deps.writeUsageCache(account.usageKey, stamped);
615
+ updates[account.usageKey] = nextHeadroomEntry(entry, stamped, now);
497
616
  result.refreshed += 1;
617
+ refreshedKeys.push(account.usageKey);
498
618
  }
499
619
  else {
500
620
  // No live snapshot (expired token / fetch miss): don't rewrite the usage
@@ -511,5 +631,7 @@ export async function runUsageRefresh(deps) {
511
631
  }
512
632
  if (Object.keys(updates).length > 0)
513
633
  writeHeadroomEntries(updates);
634
+ if (refreshedKeys.length > 0)
635
+ await deps.onSnapshotsChanged?.(refreshedKeys);
514
636
  return result;
515
637
  }
@@ -142,6 +142,9 @@ export interface SessionOutcome {
142
142
  project?: string | null;
143
143
  label?: string;
144
144
  name?: string;
145
+ /** The daemon-generated headline (PHNX-3797); carried so the report renders the
146
+ * same name every other surface shows, not the raw first prompt. */
147
+ generatedTitle?: string;
145
148
  topic?: string;
146
149
  preview?: string;
147
150
  activity?: ActiveSession['activity'];
@@ -444,6 +444,7 @@ export async function runWatchdogTick(opts = {}) {
444
444
  project: session.project,
445
445
  label: session.label,
446
446
  name: session.name,
447
+ generatedTitle: session.generatedTitle,
447
448
  topic: session.topic,
448
449
  preview: session.preview,
449
450
  activity: session.activity,
@@ -17,11 +17,11 @@ function hookCommand(agent, opts) {
17
17
  const hook = opts.hookPathOverride ?? HOOK_PATH;
18
18
  return `${hook} ${agent}`;
19
19
  }
20
- /** True when a stored command string is a prior registration of this package's
21
- * `hook.sh` (src or dist). Used for idempotency when stripping old entries. */
20
+ /** Recognize current registrations and the retired builtin sidecar writer. */
22
21
  function isOwnHookCommand(command) {
23
22
  const first = command.trim().split(/\s+/)[0];
24
- return first.endsWith('hook.sh') && first.includes('session-tracker');
23
+ return (first.endsWith('hook.sh') && first.includes('session-tracker'))
24
+ || first.endsWith('/.agents/.cache/shims/builtin-hooks/session-tracker.sh');
25
25
  }
26
26
  async function readJson(p) {
27
27
  try {
@@ -131,7 +131,7 @@ async function installDroid(opts) {
131
131
  for (const entry of cfg.hooks.SessionStart) {
132
132
  if (!entry || !Array.isArray(entry.hooks))
133
133
  continue;
134
- entry.hooks = entry.hooks.filter((hook) => !(hook?.command && String(hook.command).includes('packages/session-tracker/src/hook.sh')));
134
+ entry.hooks = entry.hooks.filter((hook) => !(hook?.command && isOwnHookCommand(String(hook.command))));
135
135
  }
136
136
  let group = cfg.hooks.SessionStart.find((entry) => entry?.matcher === '');
137
137
  if (!group) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.107",
3
+ "version": "1.22.110",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",