@phnx-labs/agents-cli 1.20.89 → 1.20.90

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 (58) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/README.md +6 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +7 -1
  5. package/dist/commands/harness.d.ts +27 -0
  6. package/dist/commands/harness.js +120 -13
  7. package/dist/commands/profiles.d.ts +3 -0
  8. package/dist/commands/profiles.js +1 -1
  9. package/dist/commands/routines.d.ts +19 -0
  10. package/dist/commands/routines.js +28 -6
  11. package/dist/commands/secrets.d.ts +10 -1
  12. package/dist/commands/secrets.js +18 -6
  13. package/dist/commands/sessions-browser.d.ts +4 -0
  14. package/dist/commands/sessions-browser.js +51 -9
  15. package/dist/commands/sessions-favorite.d.ts +20 -0
  16. package/dist/commands/sessions-favorite.js +120 -0
  17. package/dist/commands/sessions.d.ts +103 -20
  18. package/dist/commands/sessions.js +356 -62
  19. package/dist/commands/setup-secrets.d.ts +7 -0
  20. package/dist/commands/setup-secrets.js +12 -9
  21. package/dist/commands/versions.js +12 -4
  22. package/dist/commands/view.d.ts +14 -1
  23. package/dist/commands/view.js +103 -128
  24. package/dist/lib/agents.d.ts +4 -2
  25. package/dist/lib/agents.js +21 -6
  26. package/dist/lib/hosts/dispatch.js +19 -1
  27. package/dist/lib/hq/floor.js +12 -0
  28. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  29. package/dist/lib/picker.d.ts +27 -2
  30. package/dist/lib/picker.js +71 -7
  31. package/dist/lib/profiles.d.ts +48 -0
  32. package/dist/lib/profiles.js +67 -0
  33. package/dist/lib/rotate.d.ts +24 -2
  34. package/dist/lib/rotate.js +63 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/active.d.ts +109 -3
  38. package/dist/lib/session/active.js +269 -13
  39. package/dist/lib/session/db.d.ts +14 -0
  40. package/dist/lib/session/db.js +35 -0
  41. package/dist/lib/session/favorites.d.ts +39 -0
  42. package/dist/lib/session/favorites.js +101 -0
  43. package/dist/lib/session/host-link.d.ts +68 -0
  44. package/dist/lib/session/host-link.js +64 -0
  45. package/dist/lib/session/presence.d.ts +85 -0
  46. package/dist/lib/session/presence.js +150 -0
  47. package/dist/lib/session/remote-list.d.ts +10 -0
  48. package/dist/lib/session/remote-list.js +47 -9
  49. package/dist/lib/tmux/binary.d.ts +7 -0
  50. package/dist/lib/tmux/binary.js +11 -1
  51. package/dist/lib/types.d.ts +4 -3
  52. package/dist/lib/usage-backoff.d.ts +29 -0
  53. package/dist/lib/usage-backoff.js +165 -0
  54. package/dist/lib/usage.d.ts +112 -5
  55. package/dist/lib/usage.js +464 -46
  56. package/dist/lib/watchdog/runner.d.ts +13 -0
  57. package/dist/lib/watchdog/runner.js +16 -1
  58. package/package.json +1 -1
@@ -5,6 +5,7 @@
5
5
  * `agents secrets import` command, keeping bundle storage as the source of truth.
6
6
  */
7
7
  import type { Command } from 'commander';
8
+ import type { SecretsPolicy } from '../lib/secrets/bundles.js';
8
9
  interface SetupSecretsOptions {
9
10
  backend?: string;
10
11
  policy?: string;
@@ -14,6 +15,12 @@ interface SetupSecretsOptions {
14
15
  iUnderstand?: boolean;
15
16
  force?: boolean;
16
17
  }
18
+ /** The wizard's `--policy` shares the canonical parser with `agents secrets
19
+ * policy`, so it accepts `hold` (and the `daily`/`session` aliases) rather than
20
+ * carrying its own copy that knows only the retired vocabulary. `parsePolicyOpt`
21
+ * defaults an absent value to `always`; the wizard's own default is `hold`, so
22
+ * it is supplied here rather than inherited. */
23
+ export declare function parsePolicy(raw: string | undefined): SecretsPolicy;
17
24
  export declare function runSecretsSetupWizard(opts?: SetupSecretsOptions): Promise<boolean>;
18
25
  /** Register `agents setup secrets` under the parent `setup` command. */
19
26
  export declare function registerSetupSecretsCommand(setupCmd: Command): void;
@@ -11,6 +11,7 @@ import { spawnSync } from 'node:child_process';
11
11
  import { getCliLaunch } from '../lib/cli-entry.js';
12
12
  import { getHistoryDir, updateMeta } from '../lib/state.js';
13
13
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
14
+ import { parsePolicyOpt } from './secrets.js';
14
15
  function defaultBackendForPlatform(platform = process.platform) {
15
16
  return platform === 'darwin' ? 'keychain' : 'file';
16
17
  }
@@ -23,11 +24,13 @@ function parseBackend(raw) {
23
24
  return value;
24
25
  throw new Error(`Invalid --backend '${raw}'. Use keychain, file, or vault.`);
25
26
  }
26
- function parsePolicy(raw) {
27
- const value = (raw ?? 'daily').toLowerCase();
28
- if (value === 'daily' || value === 'always' || value === 'never')
29
- return value;
30
- throw new Error(`Invalid --policy '${raw}'. Use daily, always, or never.`);
27
+ /** The wizard's `--policy` shares the canonical parser with `agents secrets
28
+ * policy`, so it accepts `hold` (and the `daily`/`session` aliases) rather than
29
+ * carrying its own copy that knows only the retired vocabulary. `parsePolicyOpt`
30
+ * defaults an absent value to `always`; the wizard's own default is `hold`, so
31
+ * it is supplied here rather than inherited. */
32
+ export function parsePolicy(raw) {
33
+ return parsePolicyOpt(raw ?? 'hold');
31
34
  }
32
35
  function parseImportSource(raw) {
33
36
  const value = (raw ?? 'none').toLowerCase();
@@ -140,9 +143,9 @@ async function resolveInteractiveChoices(opts) {
140
143
  });
141
144
  const policy = opts.policy ? parsePolicy(opts.policy) : await select({
142
145
  message: 'Default prompt policy',
143
- default: 'daily',
146
+ default: 'hold',
144
147
  choices: [
145
- { name: 'daily — ask once, then hold for the configured window', value: 'daily' },
148
+ { name: 'hold — ask once, then hold for the configured window (7 days by default)', value: 'hold' },
146
149
  { name: 'always — ask on every read', value: 'always' },
147
150
  { name: 'never — no biometry ACL; use only for automation-only bundles', value: 'never' },
148
151
  ],
@@ -171,7 +174,7 @@ function printOnboardingSummary(backend, policy, prefsFile) {
171
174
  console.log(chalk.gray(`policy: ${policy} — used by bundles that do not set their own policy.`));
172
175
  }
173
176
  console.log(chalk.bold('\nTry:'));
174
- console.log(chalk.cyan(` agents secrets create prod ${backendArgs(backend).join(' ')} --policy ${policy === 'never' ? 'daily' : policy}`));
177
+ console.log(chalk.cyan(` agents secrets create prod ${backendArgs(backend).join(' ')} --policy ${policy === 'never' ? 'hold' : policy}`));
175
178
  console.log(chalk.cyan(' agents secrets add prod API_KEY'));
176
179
  console.log(chalk.cyan(' agents run claude "deploy" --secrets prod'));
177
180
  }
@@ -201,7 +204,7 @@ export function registerSetupSecretsCommand(setupCmd) {
201
204
  .command('secrets')
202
205
  .description('Configure `agents secrets` defaults and optionally import existing secrets.')
203
206
  .option('--backend <backend>', 'default backend: keychain, file, or vault')
204
- .option('--policy <policy>', 'default prompt policy: daily, always, or never')
207
+ .option('--policy <policy>', "default prompt policy: hold, always, or never ('daily'/'session' are accepted aliases for 'hold')")
205
208
  .option('--import-from <source>', 'optional import source: none, env, 1password, or icloud')
206
209
  .option('--bundle <name>', 'bundle name for optional imports')
207
210
  .option('--vault <name-or-path>', '1Password vault name, or .env path with --import-from env')
@@ -31,7 +31,7 @@ function fixSessionFilePaths(agent, version, oldVersionDir) {
31
31
  const trashPath = path.join(trashAgentDir, stamps[0]);
32
32
  updateSessionFilePaths(oldVersionDir, trashPath);
33
33
  }
34
- function formatAccountHint(info, usage) {
34
+ function formatAccountHint(info, usage, unverified = false) {
35
35
  const parts = [];
36
36
  if (info.email) {
37
37
  // Same-email accounts can live in different orgs (personal Max vs a Team
@@ -39,7 +39,7 @@ function formatAccountHint(info, usage) {
39
39
  const badge = accountOrgBadge(info);
40
40
  parts.push(badge ? `${info.email} (${badge})` : info.email);
41
41
  }
42
- const usageSummary = formatUsageSummary(info.plan, usage);
42
+ const usageSummary = formatUsageSummary(info.plan, usage, 3, { unverified });
43
43
  if (usageSummary)
44
44
  parts.push(usageSummary);
45
45
  if (parts.length === 0)
@@ -535,7 +535,10 @@ export function registerVersionsCommands(program) {
535
535
  cliVersion: installedVersion,
536
536
  info,
537
537
  });
538
- const accountHint = formatAccountHint(info, usage.snapshot);
538
+ // This hint sits in a "switch your default to this version?"
539
+ // confirm — the one place a stale reading directly steers a
540
+ // choice, so it must not present an unconfirmed bar as fact.
541
+ const accountHint = formatAccountHint(info, usage.snapshot, !!usage.snapshot && !!usage.error);
539
542
  const message = `Switch default from ${agentLabel(agentConfig.id)}@${currentDefault} to ${agentLabel(agentConfig.id)}@${installedVersion}${accountHint}?`;
540
543
  const setAsDefault = await confirm({
541
544
  message,
@@ -702,7 +705,12 @@ export function registerVersionsCommands(program) {
702
705
  const accountInfo = pickerAccountMap.get(v);
703
706
  const email = accountInfo?.email || '';
704
707
  const usageKey = getUsageLookupKey(accountInfo);
705
- const usageSummary = usageKey ? formatUsageSummary(null, usageByKey.get(usageKey)?.snapshot || null) : '';
708
+ const versionUsage = usageKey ? usageByKey.get(usageKey) : undefined;
709
+ const usageSummary = usageKey
710
+ ? formatUsageSummary(null, versionUsage?.snapshot || null, 3, {
711
+ unverified: !!versionUsage?.snapshot && !!versionUsage.error,
712
+ })
713
+ : '';
706
714
  if (maxEmailLen > 0) {
707
715
  label += ' ';
708
716
  label += email ? chalk.cyan(email.padEnd(maxEmailLen)) : ' '.repeat(maxEmailLen);
@@ -30,6 +30,17 @@ export interface ViewSectionFilter {
30
30
  /** Trim a description to a column-friendly snippet. Strips newlines, collapses whitespace. */
31
31
  export declare function summarizeDescription(desc: string | undefined, maxLen?: number): string;
32
32
  export declare function descriptionForPrefix(desc: string | undefined, prefix: string): string;
33
+ /**
34
+ * Render custom harnesses as their own agent-type blocks — peers of the native
35
+ * Claude/Codex blocks, not indented rows under the host CLI that executes them.
36
+ * `agents run <name>` already treats a custom harness like a native agent id, so
37
+ * `agents view` lists it the same way: a bold name header, then one row carrying
38
+ * the model, the account/auth state, and the host it runs on.
39
+ *
40
+ * `installedHosts` is the set of agent ids with a usable install; a harness whose
41
+ * host is missing is flagged rather than silently listed as runnable.
42
+ */
43
+ export declare function renderHarnessBlocks(harnesses: ProfileSummary[], installedHosts: Set<AgentId>, showPaths: boolean): void;
33
44
  /** Machine-readable entry for a single installed version. */
34
45
  export interface ViewJsonVersion {
35
46
  version: string;
@@ -47,6 +58,7 @@ export interface ViewJsonVersion {
47
58
  } | null;
48
59
  windows: Array<{
49
60
  key: 'session' | 'week' | 'sonnet_week' | 'month';
61
+ label?: string;
50
62
  usedPercent: number;
51
63
  resetsAt: string | null;
52
64
  }>;
@@ -64,7 +76,8 @@ export interface ViewJsonVersion {
64
76
  export interface ViewJsonAgent {
65
77
  agent: AgentId;
66
78
  versions: ViewJsonVersion[];
67
- profiles: ProfileSummary[];
79
+ /** Custom harnesses that run on this agent as their host CLI. */
80
+ harnesses: ProfileSummary[];
68
81
  }
69
82
  /** Resource sections that --resources can include in --json output. */
70
83
  export type ResourceSection = 'commands' | 'skills' | 'mcp' | 'memory' | 'hooks' | 'workflows' | 'plugins';
@@ -25,7 +25,8 @@ import { composeRulesFromState } from '../lib/rules/compose.js';
25
25
  import { getConfiguredRunStrategy } from '../lib/rotate.js';
26
26
  import { resolveRunDefaults } from '../lib/run-defaults.js';
27
27
  import { resolveConfiguredModel } from '../lib/models.js';
28
- import { listProfiles, profileSummary } from '../lib/profiles.js';
28
+ import { listProfiles, profileExists, profileSummary, readProfile } from '../lib/profiles.js';
29
+ import { renderHarnessDetail } from './harness.js';
29
30
  import { loadManifest, isStale } from '../lib/staleness/index.js';
30
31
  import { confirm } from '@inquirer/prompts';
31
32
  import { formatPath, isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -33,28 +34,19 @@ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/widt
33
34
  /** Shared account identity formatter, re-exported for the view-specific tests. */
34
35
  export const accountColumnLabel = accountDisplayLabel;
35
36
  /**
36
- * Group profile summaries by their host harness, optionally filtered to a
37
- * single agent. Profile YAMLs that fail validation are silently skipped by
38
- * `listProfiles` so this never throws on a malformed file.
37
+ * Custom harnesses (the `~/.agents/profiles/*.yml` bundles), sorted by name and
38
+ * optionally narrowed to the ones that run on one host agent. YAMLs that fail
39
+ * validation are silently skipped by `listProfiles`, so this never throws on a
40
+ * malformed file.
39
41
  */
40
- function getProfilesByAgent(filterAgentId) {
41
- const byAgent = new Map();
42
- for (const profile of listProfiles()) {
43
- if (filterAgentId && profile.host.agent !== filterAgentId)
44
- continue;
45
- const summary = profileSummary(profile);
46
- const existing = byAgent.get(profile.host.agent);
47
- if (existing)
48
- existing.push(summary);
49
- else
50
- byAgent.set(profile.host.agent, [summary]);
51
- }
52
- return byAgent;
42
+ function getHarnesses(filterAgentId) {
43
+ return listProfiles()
44
+ .filter((profile) => !filterAgentId || profile.host.agent === filterAgentId)
45
+ .map(profileSummary);
53
46
  }
54
- /** Build the usage-column equivalent for a profile row: "profile <model>". */
55
- function profileKindAndModel(model, planWidth) {
56
- const kind = 'profile'.padEnd(Math.max(planWidth, 'profile'.length));
57
- return `${kind} ${model}`;
47
+ /** "via <host> <version>" which native harness actually executes this one. */
48
+ function harnessHostTag(harness) {
49
+ return harness.hostVersion ? `via ${harness.agent} ${harness.hostVersion}` : `via ${harness.agent}`;
58
50
  }
59
51
  /**
60
52
  * Resolve a resource path to something the IDE can open inline. When `p` is a
@@ -147,31 +139,38 @@ export function descriptionForPrefix(desc, prefix) {
147
139
  const budget = Math.max(1, terminalWidth() - stringWidth(visiblePrefix));
148
140
  return summarizeDescription(desc, budget);
149
141
  }
150
- function getProfileSummaries(filterAgentId) {
151
- return listProfiles()
152
- .filter((profile) => !filterAgentId || profile.host.agent === filterAgentId)
153
- .map(profileSummary);
154
- }
155
- function renderProfilesSection(profiles) {
156
- if (profiles.length === 0)
142
+ /**
143
+ * Render custom harnesses as their own agent-type blocks — peers of the native
144
+ * Claude/Codex blocks, not indented rows under the host CLI that executes them.
145
+ * `agents run <name>` already treats a custom harness like a native agent id, so
146
+ * `agents view` lists it the same way: a bold name header, then one row carrying
147
+ * the model, the account/auth state, and the host it runs on.
148
+ *
149
+ * `installedHosts` is the set of agent ids with a usable install; a harness whose
150
+ * host is missing is flagged rather than silently listed as runnable.
151
+ */
152
+ export function renderHarnessBlocks(harnesses, installedHosts, showPaths) {
153
+ if (harnesses.length === 0)
157
154
  return;
158
- const nameWidth = Math.max(4, ...profiles.map((p) => p.name.length));
159
- const hostWidth = Math.max(4, ...profiles.map((p) => p.host.length));
160
- const providerWidth = Math.max(8, ...profiles.map((p) => p.provider.length));
161
- console.log(chalk.bold('Profiles\n'));
162
- console.log(` ${chalk.gray('NAME'.padEnd(nameWidth))} ` +
163
- `${chalk.gray('HOST'.padEnd(hostWidth))} ` +
164
- `${chalk.gray('PROVIDER'.padEnd(providerWidth))} ` +
165
- chalk.gray('MODEL'));
166
- for (const profile of profiles) {
167
- console.log(` ${chalk.cyan(profile.name.padEnd(nameWidth))} ` +
168
- `${profile.host.padEnd(hostWidth)} ` +
169
- `${profile.provider.padEnd(providerWidth)} ` +
170
- chalk.gray(profile.model));
171
- }
172
- console.log(chalk.gray('\n Run: agents run <profile> [prompt]'));
173
- console.log(chalk.gray(' agents profiles view <profile>'));
174
- console.log();
155
+ const modelWidth = Math.max(...harnesses.map((h) => h.model.length));
156
+ const authWidth = Math.max(...harnesses.map((h) => h.auth.length));
157
+ for (const harness of harnesses) {
158
+ // The `via <host>` tag on the row already names a native fork parent, so
159
+ // only a fork of another custom harness adds lineage worth printing.
160
+ const origin = harness.forkedFrom && harness.forkedFrom !== harness.agent
161
+ ? `custom · forked from ${harness.forkedFrom}`
162
+ : 'custom';
163
+ const missingHost = installedHosts.has(harness.agent)
164
+ ? ''
165
+ : chalk.yellow(` (host ${harness.agent} not installed)`);
166
+ console.log(` ${chalk.bold(harness.label)}${chalk.gray(` (${origin})`)}${missingHost}`);
167
+ console.log(` ${chalk.yellow(harness.model.padEnd(modelWidth))} ` +
168
+ `${chalk.cyan(harness.auth.padEnd(authWidth))} ` +
169
+ chalk.gray(harnessHostTag(harness)));
170
+ if (showPaths)
171
+ console.log(chalk.gray(` ${harness.path}`));
172
+ console.log();
173
+ }
175
174
  }
176
175
  /**
177
176
  * Show installed versions for one or all agents.
@@ -267,8 +266,7 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
267
266
  .map(async (agentId) => [agentId, await getUnmanagedCliState(agentId)])));
268
267
  spinner.stop();
269
268
  const showPaths = !!filterAgentId;
270
- const profilesByAgent = getProfilesByAgent(filterAgentId);
271
- const profileSummaries = [...profilesByAgent.values()].flat();
269
+ const harnesses = getHarnesses(filterAgentId);
272
270
  // Auto-heal stale versioned aliases. Pre-v2 aliases (e.g. pre-CLAUDE_CONFIG_DIR
273
271
  // claude shims) silently route login through the default version's symlinked
274
272
  // home, so `agents view` would never reflect the right account. Regenerate on
@@ -367,11 +365,9 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
367
365
  // Separate version-managed from globally-installed agents
368
366
  const versionManaged = [];
369
367
  const globallyInstalled = [];
370
- const profileOnly = [];
371
368
  for (const agentId of agentsToShow) {
372
369
  const versions = listInstalledVersions(agentId);
373
370
  const cliState = cliStates[agentId];
374
- const hasProfiles = (profilesByAgent.get(agentId)?.length ?? 0) > 0;
375
371
  if (versions.length > 0) {
376
372
  versionManaged.push(agentId);
377
373
  }
@@ -380,10 +376,10 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
380
376
  if (!hasNonIsolatedVersion(agentId))
381
377
  globallyInstalled.push(agentId);
382
378
  }
383
- else if (versions.length === 0 && hasProfiles) {
384
- profileOnly.push(agentId);
385
- }
386
379
  }
380
+ // A custom harness runs through its host CLI, so it is only launchable when
381
+ // that host has an install of some kind.
382
+ const installedHosts = new Set([...versionManaged, ...globallyInstalled]);
387
383
  // For self-updating global-binary agents (droid) the on-disk version-dir name
388
384
  // is a stale label — the real version is whatever `<cli> --version` reports.
389
385
  // Resolve it once so every row/width pass shows the live version, while the
@@ -443,12 +439,6 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
443
439
  maxModelWidth = Math.max(maxModelWidth, model.length);
444
440
  }
445
441
  }
446
- // Profile rows share these columns with version rows so they line up.
447
- for (const profile of profilesByAgent.get(agentId) ?? []) {
448
- maxVerLabel = Math.max(maxVerLabel, profile.name.length);
449
- maxEmail = Math.max(maxEmail, profile.auth.length);
450
- maxPlanWidth = Math.max(maxPlanWidth, 'profile'.length);
451
- }
452
442
  }
453
443
  // Second pass: compute max visible usage + status widths (now that maxPlanWidth is settled)
454
444
  for (const agentId of versionManaged) {
@@ -459,15 +449,12 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
459
449
  const usageKey = getUsageLookupKey(info);
460
450
  const usageInfo = usageKey ? usageByKey.get(usageKey) : undefined;
461
451
  const usageUnavailable = agentReportsUsage(agentId) && !!info?.signedIn && !usageInfo?.snapshot;
462
- const usageStr = formatUsageSummary(info?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, { unavailable: usageUnavailable });
452
+ const usageUnverified = !!usageInfo?.snapshot && !!usageInfo.error;
453
+ const usageStr = formatUsageSummary(info?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, { unavailable: usageUnavailable, unverified: usageUnverified });
463
454
  maxUsageWidth = Math.max(maxUsageWidth, visibleWidth(usageStr));
464
455
  const statusStr = formatUsageStatusBadge(info?.usageStatus);
465
456
  maxStatusWidth = Math.max(maxStatusWidth, visibleWidth(statusStr));
466
457
  }
467
- for (const profile of profilesByAgent.get(agentId) ?? []) {
468
- const usageEquivalent = profileKindAndModel(profile.model, maxPlanWidth);
469
- maxUsageWidth = Math.max(maxUsageWidth, visibleWidth(usageEquivalent));
470
- }
471
458
  }
472
459
  for (const agentId of versionManaged) {
473
460
  const agent = AGENTS[agentId];
@@ -518,7 +505,8 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
518
505
  const hasEmail = !!vInfo?.email;
519
506
  const signedIn = !!vInfo?.signedIn;
520
507
  const usageUnavailable = agentReportsUsage(agentId) && signedIn && !usageInfo?.snapshot;
521
- const usageStr = formatUsageSummary(vInfo?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, { unavailable: usageUnavailable });
508
+ const usageUnverified = !!usageInfo?.snapshot && !!usageInfo.error;
509
+ const usageStr = formatUsageSummary(vInfo?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, { unavailable: usageUnavailable, unverified: usageUnverified });
522
510
  const hasUsage = usageStr.length > 0;
523
511
  // Only show lastActive for versions with an actual logged-in account.
524
512
  // Otherwise it reflects install time (misleading "just now" for fresh installs).
@@ -577,20 +565,6 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
577
565
  console.log(chalk.gray(` ${versionDir}`));
578
566
  }
579
567
  }
580
- // Profile rows share the same columns as versions: name | auth | "profile"+model.
581
- // No status badge, no last-active — profiles don't accumulate usage state.
582
- for (const profile of profilesByAgent.get(agentId) ?? []) {
583
- const nameCol = chalk.cyan(profile.name.padEnd(maxVerLabel));
584
- // Pad the model column so profile rows line up with version rows.
585
- const modelPad = maxModelWidth > 0 ? `${' '.repeat(maxModelWidth)} ` : '';
586
- const authCol = chalk.gray(profile.auth.padEnd(maxEmail));
587
- const usageEquivalent = profileKindAndModel(profile.model, maxPlanWidth);
588
- const usagePad = ' '.repeat(Math.max(0, maxUsageWidth - visibleWidth(usageEquivalent)));
589
- console.log(` ${nameCol} ${modelPad}${authCol} ${chalk.gray(usageEquivalent + usagePad)}`);
590
- if (showPaths) {
591
- console.log(chalk.gray(` ${profile.path}`));
592
- }
593
- }
594
568
  // Check for project override
595
569
  const projectVersion = getProjectVersionFromCwd(agentId);
596
570
  if (projectVersion && projectVersion !== globalDefault) {
@@ -599,6 +573,10 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
599
573
  console.log();
600
574
  }
601
575
  }
576
+ // Custom harnesses sit in the same list as the native ones — they are run the
577
+ // same way (`agents run <name>`), so they read as their own agent type rather
578
+ // than as an indented row under whichever host CLI executes them.
579
+ renderHarnessBlocks(harnesses, installedHosts, showPaths);
602
580
  // Show globally installed (not managed) agents
603
581
  if (globallyInstalled.length > 0) {
604
582
  console.log(chalk.bold('Not Managed by Agents CLI\n'));
@@ -632,7 +610,9 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
632
610
  const parts = [` ${verLabel}${padding}`];
633
611
  const gUsageKey = getUsageLookupKey(gInfo);
634
612
  const gUsage = gUsageKey ? usageByKey.get(gUsageKey) : undefined;
635
- const gUsageStr = formatUsageSummary(gInfo?.plan || null, gUsage?.snapshot || null);
613
+ const gUsageStr = formatUsageSummary(gInfo?.plan || null, gUsage?.snapshot || null, 3, {
614
+ unverified: !!gUsage?.snapshot && !!gUsage.error,
615
+ });
636
616
  const gActiveStr = gInfo ? formatLastActive(gInfo.lastActive) : '';
637
617
  if (gInfo?.email || gUsageStr || gActiveStr || gInfo?.signedIn) {
638
618
  const gDisplay = accountColumnLabel(gInfo);
@@ -651,25 +631,6 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
651
631
  if (showPaths && cliState?.path) {
652
632
  console.log(chalk.gray(` ${cliState.path}`));
653
633
  }
654
- // Profile rows under a globally-installed harness. Use a simpler
655
- // alignment here since this section doesn't share column state with
656
- // the version-managed block.
657
- // An isolated-only agent now appears in BOTH blocks; its profiles already
658
- // rendered under the version-managed one, so don't print them twice.
659
- const profilesHere = versionManaged.includes(agentId) ? [] : (profilesByAgent.get(agentId) ?? []);
660
- if (profilesHere.length > 0) {
661
- const nameWidth = Math.max(globalMaxVerLabel, ...profilesHere.map((p) => p.name.length));
662
- const authWidth = Math.max(...profilesHere.map((p) => p.auth.length));
663
- for (const profile of profilesHere) {
664
- console.log(` ${chalk.cyan(profile.name.padEnd(nameWidth))} ` +
665
- `${chalk.gray(profile.auth.padEnd(authWidth))} ` +
666
- `${chalk.gray('profile')} ` +
667
- chalk.gray(profile.model));
668
- if (showPaths) {
669
- console.log(chalk.gray(` ${profile.path}`));
670
- }
671
- }
672
- }
673
634
  if (agent.npmPackage && cliState?.version) {
674
635
  console.log(chalk.gray(` Manage: agents add ${agentId}@${cliState.version} -y`));
675
636
  }
@@ -681,47 +642,44 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
681
642
  console.log();
682
643
  }
683
644
  }
684
- // Agents with no install but with profiles defined — render under the same
685
- // harness header so users find them where they look.
686
- if (profileOnly.length > 0) {
687
- if (versionManaged.length === 0 && globallyInstalled.length === 0) {
688
- console.log(chalk.bold('Profile-only Agents\n'));
689
- }
690
- for (const agentId of profileOnly) {
691
- const profilesHere = profilesByAgent.get(agentId) ?? [];
692
- console.log(` ${chalk.bold(agentLabel(agentId))}${chalk.yellow(' (profile only)')}`);
693
- const nameWidth = Math.max(...profilesHere.map((p) => p.name.length));
694
- const authWidth = Math.max(...profilesHere.map((p) => p.auth.length));
695
- for (const profile of profilesHere) {
696
- console.log(` ${chalk.cyan(profile.name.padEnd(nameWidth))} ` +
697
- `${chalk.gray(profile.auth.padEnd(authWidth))} ` +
698
- `${chalk.gray('profile')} ` +
699
- chalk.gray(profile.model));
700
- if (showPaths) {
701
- console.log(chalk.gray(` ${profile.path}`));
702
- }
703
- }
704
- console.log();
705
- }
706
- }
707
645
  // If filtering to a specific agent and not found
708
646
  if (filterAgentId &&
709
647
  versionManaged.length === 0 &&
710
648
  globallyInstalled.length === 0 &&
711
- profileOnly.length === 0) {
649
+ harnesses.length === 0) {
712
650
  console.log(` ${chalk.bold(agentLabel(filterAgentId))}: ${chalk.gray('not installed')}`);
713
651
  console.log();
714
652
  }
715
653
  // No agents installed at all
716
654
  if (versionManaged.length === 0 &&
717
655
  globallyInstalled.length === 0 &&
718
- profileOnly.length === 0 &&
719
- profileSummaries.length === 0 &&
656
+ harnesses.length === 0 &&
720
657
  !filterAgentId) {
721
658
  console.log(chalk.gray(' No agent CLIs installed.'));
722
659
  console.log(chalk.gray(' Run: agents add claude@latest'));
723
660
  console.log();
724
661
  }
662
+ // `--refresh` used to print a table that looked fully refreshed no matter how
663
+ // many accounts it had failed to reach, so a box whose every Claude credential
664
+ // had expired rendered identically to a healthy one — the bars beside each row
665
+ // came from a cache that the run had not managed to update. Name the accounts
666
+ // it could not confirm, and why.
667
+ if (viewOpts?.forceRefresh) {
668
+ const unrefreshed = [];
669
+ for (const [key, usage] of usageByKey) {
670
+ if (!usage.error)
671
+ continue;
672
+ const label = canonicalByUsageKey.get(key)?.email ?? key;
673
+ unrefreshed.push(` ${label.padEnd(24)} ${chalk.gray(usage.error)}`);
674
+ }
675
+ if (unrefreshed.length > 0) {
676
+ const noun = unrefreshed.length === 1 ? 'account' : 'accounts';
677
+ console.log(chalk.yellow(` Could not refresh ${unrefreshed.length} ${noun} — bars above are the last cached reading:`));
678
+ for (const line of unrefreshed)
679
+ console.log(line);
680
+ console.log();
681
+ }
682
+ }
725
683
  // Host CLIs are host-global, not per-agent — show them once in the overview.
726
684
  if (!filterAgentId) {
727
685
  renderHostClisSection(process.cwd());
@@ -1246,6 +1204,7 @@ async function collectAgentsJson(filterAgentId, resourceSections) {
1246
1204
  windows: snapshot
1247
1205
  ? snapshot.windows.map((w) => ({
1248
1206
  key: w.key,
1207
+ label: w.label,
1249
1208
  usedPercent: w.usedPercent,
1250
1209
  resetsAt: w.resetsAt ? w.resetsAt.toISOString() : null,
1251
1210
  }))
@@ -1263,7 +1222,7 @@ async function collectAgentsJson(filterAgentId, resourceSections) {
1263
1222
  else
1264
1223
  byAgent.set(agentId, [entry]);
1265
1224
  }
1266
- const profilesByAgent = getProfilesByAgent(filterAgentId);
1225
+ const harnesses = getHarnesses(filterAgentId);
1267
1226
  const out = [];
1268
1227
  for (const agentId of agentsToShow) {
1269
1228
  const versions = byAgent.get(agentId) ?? [];
@@ -1272,7 +1231,7 @@ async function collectAgentsJson(filterAgentId, resourceSections) {
1272
1231
  return a.isDefault ? -1 : 1;
1273
1232
  return compareVersions(b.version, a.version);
1274
1233
  });
1275
- out.push({ agent: agentId, versions, profiles: profilesByAgent.get(agentId) ?? [] });
1234
+ out.push({ agent: agentId, versions, harnesses: harnesses.filter((h) => h.agent === agentId) });
1276
1235
  }
1277
1236
  return out;
1278
1237
  }
@@ -1536,6 +1495,17 @@ export async function viewAction(agentArg, options) {
1536
1495
  const agentName = parts[0];
1537
1496
  const agentId = resolveAgentName(agentName);
1538
1497
  if (!agentId) {
1498
+ // A custom harness is an agent type here, not an unknown name: `agents run
1499
+ // <name>` launches it, so `agents view <name>` describes it.
1500
+ if (profileExists(agentName)) {
1501
+ const harness = profileSummary(readProfile(agentName));
1502
+ if (json) {
1503
+ console.log(JSON.stringify(harness, null, 2));
1504
+ return;
1505
+ }
1506
+ renderHarnessDetail(agentName);
1507
+ return;
1508
+ }
1539
1509
  if (json) {
1540
1510
  console.log(JSON.stringify({ error: formatAgentError(agentName) }));
1541
1511
  process.exit(1);
@@ -1575,7 +1545,7 @@ export async function viewAction(agentArg, options) {
1575
1545
  // --json ignores the @version suffix, but --resources/--detailed (or a
1576
1546
  // section flag) now attach each version's resource inventory + sync-state.
1577
1547
  const data = await collectAgentsJson(agentId, resourceSections);
1578
- console.log(JSON.stringify(data[0] ?? { agent: agentId, versions: [], profiles: [] }, null, 2));
1548
+ console.log(JSON.stringify(data[0] ?? { agent: agentId, versions: [], harnesses: [] }, null, 2));
1579
1549
  return;
1580
1550
  }
1581
1551
  if (requestedVersion) {
@@ -1622,6 +1592,9 @@ Examples:
1622
1592
  # Show versions for one agent
1623
1593
  agents view claude
1624
1594
 
1595
+ # Describe one custom harness (host, model, provider, auth, path)
1596
+ agents view deepseek-flash
1597
+
1625
1598
  # Detailed view: resources, commands, skills, MCP servers for a specific version
1626
1599
  agents view claude@2.1.112
1627
1600
  agents view claude@default
@@ -1655,8 +1628,10 @@ When to use:
1655
1628
  - Cleaning up stale versions left behind after upgrading (--prune)
1656
1629
 
1657
1630
  Output:
1658
- - Without arguments: table of all agents with versions, emails, usage stats
1631
+ - Without arguments: table of all agents with versions, emails, usage stats,
1632
+ then one block per custom harness (see 'agents harness')
1659
1633
  - With agent name: versions for that agent, showing which is the default
1634
+ - With a custom harness name: that harness's host, model, provider, and auth
1660
1635
  - With agent@version: detailed breakdown of resources synced to that version
1661
1636
  - With --json: structured JSON with version, isDefault, signedIn, email, plan,
1662
1637
  usageStatus, per-window usedPercent, lastActive, and path
@@ -242,8 +242,10 @@ export declare function decryptDroidAuthFile(filePath: string, keyPath: string):
242
242
  * -> email / org_id / sub.
243
243
  * - kimi: credentials/kimi-code.json -> access-token JWT -> user_id / sub.
244
244
  * - antigravity: antigravity-oauth-token -> token.refresh_token -> JWT sub
245
- * when the token is a JWT, else the raw refresh-token value (opaque Google
246
- * consumer tokens are stable per login).
245
+ * when the token is a JWT, else a SHA-256 hash of the raw refresh-token
246
+ * value (opaque Google consumer tokens are stable per login — hashed so
247
+ * the identity key, which is persisted as a usage-cache key, never carries
248
+ * a live credential).
247
249
  * Two directories for the SAME account compare equal; two DIFFERENT accounts
248
250
  * compare distinct. Used by carryForwardAuthFiles to refuse overwriting one
249
251
  * account's login with a credential that belongs to a DIFFERENT account
@@ -1190,8 +1190,10 @@ export function decryptDroidAuthFile(filePath, keyPath) {
1190
1190
  * -> email / org_id / sub.
1191
1191
  * - kimi: credentials/kimi-code.json -> access-token JWT -> user_id / sub.
1192
1192
  * - antigravity: antigravity-oauth-token -> token.refresh_token -> JWT sub
1193
- * when the token is a JWT, else the raw refresh-token value (opaque Google
1194
- * consumer tokens are stable per login).
1193
+ * when the token is a JWT, else a SHA-256 hash of the raw refresh-token
1194
+ * value (opaque Google consumer tokens are stable per login — hashed so
1195
+ * the identity key, which is persisted as a usage-cache key, never carries
1196
+ * a live credential).
1195
1197
  * Two directories for the SAME account compare equal; two DIFFERENT accounts
1196
1198
  * compare distinct. Used by carryForwardAuthFiles to refuse overwriting one
1197
1199
  * account's login with a credential that belongs to a DIFFERENT account
@@ -1226,7 +1228,11 @@ export function readAuthAccountIdentity(agent, configDir) {
1226
1228
  return null;
1227
1229
  const claims = decodeJwtPayload(refreshToken);
1228
1230
  const sub = normalizeIdentityPart(claims?.sub ?? claims?.user_id);
1229
- return buildIdentityKey(agent, [['sub', sub ?? refreshToken]]);
1231
+ // An opaque (non-JWT) Google refresh token IS the credential — hash it
1232
+ // so the identity key stays stable per login without embedding a live
1233
+ // secret (the key is persisted as a usage-cache filename key).
1234
+ const fallback = crypto.createHash('sha256').update(refreshToken).digest('hex').slice(0, 16);
1235
+ return buildIdentityKey(agent, [['sub', sub ?? fallback]]);
1230
1236
  }
1231
1237
  default:
1232
1238
  return null;
@@ -1648,11 +1654,20 @@ export async function getAccountInfo(agentId, home) {
1648
1654
  if (tokenPath) {
1649
1655
  const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
1650
1656
  if (typeof data?.token?.refresh_token === 'string' && data.token.refresh_token) {
1651
- return { ...empty, signedIn: true, lastActive };
1657
+ // A stable account/usage key (derived from the refresh token — see
1658
+ // readAuthAccountIdentity) lets `agents view` dedupe and cache the
1659
+ // per-model quota bars for this login.
1660
+ const identity = readAuthAccountIdentity('antigravity', path.dirname(tokenPath));
1661
+ return { ...empty, signedIn: true, lastActive, accountKey: identity, usageKey: identity };
1652
1662
  }
1653
1663
  }
1654
- if (await antigravityKeychainSignedIn())
1655
- return { ...empty, signedIn: true, lastActive };
1664
+ if (await antigravityKeychainSignedIn()) {
1665
+ // Keyring-only login (the macOS case): the OS keyring holds exactly
1666
+ // ONE antigravity credential, so a stable singleton key identifies it
1667
+ // for usage-cache dedup without reading the secret value here.
1668
+ const identity = buildIdentityKey('antigravity', [['sub', 'keychain']]);
1669
+ return { ...empty, signedIn: true, lastActive, accountKey: identity, usageKey: identity };
1670
+ }
1656
1671
  return { ...empty, lastActive };
1657
1672
  }
1658
1673
  case 'kimi': {