@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
@@ -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';
@@ -75,6 +76,7 @@ import { registerSessionsRenderCommand } from './sessions-render.js';
75
76
  import { registerSessionsTraceCommand } from './sessions-trace.js';
76
77
  import { registerSessionsShareCommand } from './sessions-share.js';
77
78
  import { registerSessionsImportCommand } from './sessions-import.js';
79
+ import { registerSessionsBackupSetupCommand } from './sessions-backup-setup.js';
78
80
  import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
79
81
  import { registerSessionsBackfillCommand } from './sessions-backfill.js';
80
82
  import { registerSessionsStatsCommand } from './sessions-stats.js';
@@ -350,6 +352,7 @@ export function buildSessionDescription(s) {
350
352
  return cleanPreview(parts.filter(Boolean).join(' · '));
351
353
  }
352
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.
353
356
  const base = s.preview || s.label || s.topic || '';
354
357
  return cleanPreview([todo, base].filter(Boolean).join(' · '));
355
358
  }
@@ -758,22 +761,39 @@ export function renderActiveRowLines(s, indent, termW) {
758
761
  // Line 2: label/topic + checklist (the identity, no longer buried) then the
759
762
  // jump locator. Skipped entirely when there is nothing to say. desc may carry a
760
763
  // clickable project link, so it also goes through fitCell.
764
+ const contIndent = indent + ' '.repeat(ROW_ID_W);
761
765
  const desc = formatActiveRowDescription(s);
762
766
  const loc = locatorBadge(s);
763
- if (!desc && !loc)
764
- return lines;
765
- const contIndent = indent + ' '.repeat(ROW_ID_W);
766
- const room2 = Math.max(0, termW - stringWidth(contIndent) - 2);
767
- const locCell = fitCell(loc, room2);
768
- const locW = stringWidth(locCell);
769
- const descRoom = Math.max(0, room2 - (locW ? locW + 2 : 0));
770
- const descCell = chalk.white(fitCell(desc || '-', descRoom));
771
- let line2 = contIndent + chalk.dim('└ ') + descCell;
772
- if (locCell)
773
- line2 += ' ' + locCell;
774
- if (stringWidth(line2) > termW)
775
- line2 = truncateToWidth(line2, termW);
776
- 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
+ }
777
797
  return lines;
778
798
  }
779
799
  /** Render a single agent-session row inside an already-printed group header. */
@@ -2199,7 +2219,7 @@ export function printToolSearch(envelope) {
2199
2219
  ? truncate(sanitizeForTerminal(session.machine).replace(/\s+/g, ' '), 80)
2200
2220
  : '';
2201
2221
  const machine = machineName ? ` @ ${machineName}` : '';
2202
- const rawHeading = session.label || session.topic || session.project || session.shortId;
2222
+ const rawHeading = sessionHeadline(session) || session.project || session.shortId;
2203
2223
  const heading = truncate(sanitizeForTerminal(rawHeading).replace(/\s+/g, ' '), Math.max(30, terminalWidth() - 20));
2204
2224
  console.log(`${chalk.cyan(session.shortId)}${chalk.gray(machine)} ${heading}`);
2205
2225
  for (const call of session.calls) {
@@ -3373,7 +3393,7 @@ function printTeamsView(pool, liveIndex, hiddenUnmanaged = 0) {
3373
3393
  // degradation the --active teams rows use (active.ts resolveOrchestratorLabels).
3374
3394
  const labelById = new Map();
3375
3395
  for (const s of pool) {
3376
- const label = s.label || s.topic;
3396
+ const label = sessionHeadline(s);
3377
3397
  if (label)
3378
3398
  labelById.set(s.id, cleanPreview(label));
3379
3399
  }
@@ -3503,7 +3523,7 @@ function renderArchivedSession(session, mode, options = {}) {
3503
3523
  const shown = sessionDisplayAgent(session);
3504
3524
  const agentColor = colorAgent(shown);
3505
3525
  const absTime = formatAbsoluteTime(session.timestamp);
3506
- const title = session.label || session.topic;
3526
+ const title = sessionHeadline(session);
3507
3527
  console.log('');
3508
3528
  if (title)
3509
3529
  console.log(chalk.bold.white(title));
@@ -3561,9 +3581,10 @@ async function renderSession(session, mode, filters, options = {}) {
3561
3581
  const modelStr = stats.models.length > 0 ? chalk.yellow(` ${stats.models.join(', ')}`) : '';
3562
3582
  const branchStr = session.gitBranch ? chalk.gray(` (${session.gitBranch})`) : '';
3563
3583
  const absTime = formatAbsoluteTime(session.timestamp);
3564
- // Auto-inferred title headline (user /rename > Claude ai-title > first-prompt
3565
- // topic) — the fastest way to recognize which task this session is.
3566
- 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);
3567
3588
  if (title) {
3568
3589
  const badges = signalBadges(metaSignals(session));
3569
3590
  console.log(chalk.bold.white(title) + (badges ? ' ' + badges : ''));
@@ -4455,7 +4476,7 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
4455
4476
  spinner.stop();
4456
4477
  console.error(chalk.red(`Multiple sessions match "${query}":`));
4457
4478
  for (const m of queryMatches.slice(0, 10)) {
4458
- console.error(chalk.cyan(` ${m.shortId} ${m.id} ${m.label ?? m.topic ?? ''}`));
4479
+ console.error(chalk.cyan(` ${m.shortId} ${m.id} ${sessionHeadline(m) ?? ''}`));
4459
4480
  }
4460
4481
  console.error(chalk.gray(ambiguityHint(byId, completeId)));
4461
4482
  process.exit(1);
@@ -4612,7 +4633,7 @@ async function renderOneSession(query, mode, scope) {
4612
4633
  spinner.stop();
4613
4634
  console.error(chalk.red(`Multiple sessions match "${query}":`));
4614
4635
  for (const match of queryMatches.slice(0, 10)) {
4615
- console.error(chalk.cyan(` ${match.shortId} ${match.id} ${match.label ?? match.topic ?? ''}`));
4636
+ console.error(chalk.cyan(` ${match.shortId} ${match.id} ${sessionHeadline(match) ?? ''}`));
4616
4637
  }
4617
4638
  console.error(chalk.gray(ambiguityHint(byId, completeId)));
4618
4639
  process.exit(1);
@@ -5066,7 +5087,7 @@ export async function resolveSessionMetadata(selector, scope, deps = { gatherRem
5066
5087
  for (const candidate of outcome.candidates) {
5067
5088
  const session = candidate.hits[0].session;
5068
5089
  const machines = candidate.hits.map(hit => hit.machine).join(', ');
5069
- 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) ?? ''}`));
5070
5091
  }
5071
5092
  console.error(chalk.gray(looksLikeSessionId(selector) ? 'Pass a longer ID to narrow it down.' : 'Narrow the keywords to one session.'));
5072
5093
  process.exit(1);
@@ -5106,7 +5127,7 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
5106
5127
  console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
5107
5128
  for (const candidate of candidates) {
5108
5129
  const s = candidate.hits[0].session;
5109
- const label = s.label ?? s.topic ?? '';
5130
+ const label = sessionHeadline(s) ?? '';
5110
5131
  const machines = candidate.hits.map(hit => hit.machine).join(', ');
5111
5132
  console.error(chalk.cyan(` ${s.shortId} ${s.id}`) + chalk.gray(` ${machines} ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
5112
5133
  }
@@ -5387,6 +5408,7 @@ export function registerSessionsCommands(program) {
5387
5408
  registerSessionsTraceCommand(sessionsCmd);
5388
5409
  registerSessionsShareCommand(sessionsCmd);
5389
5410
  registerSessionsImportCommand(sessionsCmd);
5411
+ registerSessionsBackupSetupCommand(sessionsCmd);
5390
5412
  registerSessionsMigrateCommand(sessionsCmd);
5391
5413
  registerSessionsMigrationsCommand(sessionsCmd);
5392
5414
  registerSessionsBackfillCommand(sessionsCmd);
@@ -10,13 +10,13 @@ import { formatBytes } from '../lib/format.js';
10
10
  import { Argument, Option } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteToken, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
13
- import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, hashWorkerScript, putWorkerSecret, updateWorker, WORKER_PHOENIX_ID_BASE_SECRET, setWorkerSecret, } from '../lib/share/provision.js';
13
+ import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, hashWorkerScript, putWorkerSecret, updateWorker, WORKER_PHOENIX_ID_BASE_SECRET, WORKER_COLLAB_BASE_SECRET, WORKER_COLLAB_SERVICE_TOKEN_SECRET, setWorkerSecret, } from '../lib/share/provision.js';
14
14
  import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, scanShareContent, formatSensitiveContentError, unlistedNotPrivateWarning, SHARE_VISIBILITY_LEVELS, PUBLISH_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
15
15
  import { deleteShare, resolveDeleteTarget } from '../lib/share/delete.js';
16
16
  import { renderWorkerBundle } from '../lib/share/worker-template.js';
17
17
  import { analyticsEnabled } from '../lib/share/analytics.js';
18
18
  import { extractShareHttpError, formatShareHttpErrorDetail } from '../lib/share/http-error.js';
19
- import { phoenixIdBaseForDeploy, resolveShareBackend, shouldUseManaged, } from '../lib/share/backend.js';
19
+ import { collabConfigForDeploy, phoenixIdBaseForDeploy, resolveShareBackend, shouldUseManaged, } from '../lib/share/backend.js';
20
20
  import { publishVisibility } from '../lib/storage/visibility.js';
21
21
  import { resolveGitHubUsername } from '../lib/git.js';
22
22
  import { refreshSessionAvatar } from '../lib/identity/index.js';
@@ -1271,6 +1271,15 @@ export async function runShareProvision(opts) {
1271
1271
  await putWorkerSecret(apiToken, accountId, workerName, WORKER_PHOENIX_ID_BASE_SECRET, phoenixIdBase, provisionOpts);
1272
1272
  spin.text = `Worker '${workerName}' Phoenix ID base set`;
1273
1273
  }
1274
+ // Managed collaboration backend (PHNX-3835). Dormant unless BOTH
1275
+ // PRIX_ARTIFACT_COLLAB_BASE and ARTIFACT_COLLAB_SERVICE_TOKEN are set in the
1276
+ // deploy env; the service token rides the Secrets API, never rendered HTML.
1277
+ const collab = collabConfigForDeploy({ managed: opts.managed }, { baseUrl, domain });
1278
+ if (collab.collabBase && collab.collabServiceToken) {
1279
+ await putWorkerSecret(apiToken, accountId, workerName, WORKER_COLLAB_BASE_SECRET, collab.collabBase, provisionOpts);
1280
+ await putWorkerSecret(apiToken, accountId, workerName, WORKER_COLLAB_SERVICE_TOKEN_SECRET, collab.collabServiceToken, provisionOpts);
1281
+ spin.text = `Worker '${workerName}' collaboration backend set`;
1282
+ }
1274
1283
  spin.succeed('Provisioned');
1275
1284
  const cfg = {
1276
1285
  baseUrl,
@@ -1336,10 +1345,14 @@ export async function runShareUpdate(opts = {}) {
1336
1345
  }
1337
1346
  const writeToken = readWriteToken();
1338
1347
  const phoenixIdBase = phoenixIdBaseForDeploy({ managed: opts.managed }, cfg);
1348
+ const collab = collabConfigForDeploy({ managed: opts.managed }, cfg);
1339
1349
  const provisionOpts = {
1340
1350
  ...(opts.request ? { request: opts.request } : {}),
1341
1351
  force: opts.force,
1342
1352
  ...(phoenixIdBase !== undefined ? { phoenixIdBase } : {}),
1353
+ ...(collab.collabBase !== undefined
1354
+ ? { collabBase: collab.collabBase, collabServiceToken: collab.collabServiceToken }
1355
+ : {}),
1343
1356
  };
1344
1357
  const result = await updateWorker(apiToken, accountId, cfg.workerName, cfg.bucketName, worker, writeToken, cfg.templateHash, provisionOpts);
1345
1358
  if (!result.skipped) {
@@ -190,7 +190,7 @@ export function registerSyncCommand(program) {
190
190
  .option('-y, --yes', 'Skip the interactive preview and auto-sync all detected resources', false)
191
191
  .option('--force', 'Re-sync even if no changes are detected since the last sync', false)
192
192
  .option('--quiet', 'Suppress all output (exit code indicates success)', false)
193
- .option('--dry-run', 'Show what would be synced without making any changes', false)
193
+ .option('--dry-run', 'Show what would be synced without making any changes — requires an agent scope (e.g. agents sync claude --dry-run); the umbrella verb refuses it', false)
194
194
  .option('--allow-exec-surfaces', 'Allow syncing plugin exec surfaces (scripts, binaries) — off by default for safety', false)
195
195
  .option('--json', 'Emit machine-readable JSON (also accepted so fleet fan-out via --device all can parse each peer)', false)
196
196
  // Umbrella verb (no agent given): make this machine current.
@@ -480,6 +480,35 @@ function evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog) {
480
480
  * a one-line summary. Stage failures are non-fatal and surfaced as warnings.
481
481
  */
482
482
  async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
483
+ // `--dry-run` on the umbrella verb is NOT supported and must fail LOUD before
484
+ // touching anything (PHNX-3923). The umbrella composes stages that only exist
485
+ // as mutating operations — repo `git pull`, a full `refresh()` reconcile into
486
+ // every installed version home, central browser-profile eviction, device sync,
487
+ // and `repairAfterSync` — none of which carry a non-mutating preview mode. The
488
+ // old code ignored `opts.dryRun` entirely, ran `runUmbrellaSync` + evict +
489
+ // repair, and so MUTATED every native home despite `--dry-run`. Rather than
490
+ // ship a partial preview that silently skips the stages it cannot model (a
491
+ // lying "would sync" that contradicts the flag's promise), refuse here and
492
+ // point at the scoped path, which DOES honor `--dry-run` non-destructively.
493
+ if (opts.dryRun) {
494
+ const installed = MANAGED_AGENT_IDS.filter((id) => listInstalledVersions(id).length > 0);
495
+ const example = installed[0] ?? 'claude';
496
+ const error = '`agents sync --dry-run` has no umbrella preview: the machine-wide sync pulls repos, ' +
497
+ 'reconciles every installed version, syncs devices, and repairs homes — stages that ' +
498
+ 'cannot be previewed without making changes.';
499
+ const hint = `Preview one agent instead (this is non-destructive): agents sync ${example} --dry-run [--repo <repo>]`;
500
+ if (json) {
501
+ emitJson({ ok: false, mode: 'umbrella', dryRun: true, error, hint, installedAgents: installed });
502
+ }
503
+ else {
504
+ errLog(chalk.red(error));
505
+ errLog(chalk.gray(hint));
506
+ if (installed.length > 0)
507
+ errLog(chalk.gray(`Installed agents: ${installed.join(', ')}`));
508
+ }
509
+ process.exitCode = 1;
510
+ return;
511
+ }
483
512
  // Interactive bare `agents sync` (a TTY, no --yes, no scope flag) drops into
484
513
  // the two-checklist picker: which repos to sync from, which agents to sync
485
514
  // into. Any explicit flag, --yes, or --json keeps the non-interactive path.
@@ -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.