@phnx-labs/agents-cli 1.22.53 → 1.22.54

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 (134) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +41 -8
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +219 -173
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/secrets.js +33 -14
  25. package/dist/commands/sessions.d.ts +20 -12
  26. package/dist/commands/sessions.js +64 -20
  27. package/dist/commands/setup-accounts.d.ts +8 -0
  28. package/dist/commands/setup-accounts.js +47 -0
  29. package/dist/commands/setup.d.ts +1 -1
  30. package/dist/commands/setup.js +11 -2
  31. package/dist/commands/share.d.ts +52 -3
  32. package/dist/commands/share.js +262 -18
  33. package/dist/commands/ssh.d.ts +7 -0
  34. package/dist/commands/ssh.js +18 -2
  35. package/dist/commands/status.js +14 -0
  36. package/dist/commands/view.d.ts +3 -1
  37. package/dist/commands/view.js +5 -4
  38. package/dist/lib/account-registry.js +15 -3
  39. package/dist/lib/accounting/rotate.d.ts +20 -6
  40. package/dist/lib/accounting/rotate.js +38 -7
  41. package/dist/lib/accounting/usage.d.ts +37 -1
  42. package/dist/lib/accounting/usage.js +71 -6
  43. package/dist/lib/agent-spec/agents.d.ts +5 -2
  44. package/dist/lib/agent-spec/agents.js +25 -7
  45. package/dist/lib/analytics/mix-commands.js +12 -6
  46. package/dist/lib/auth-mint.d.ts +150 -0
  47. package/dist/lib/auth-mint.js +434 -0
  48. package/dist/lib/browser/remote-control.d.ts +9 -7
  49. package/dist/lib/browser/remote-control.js +9 -7
  50. package/dist/lib/claude-account-token.d.ts +10 -0
  51. package/dist/lib/claude-account-token.js +14 -4
  52. package/dist/lib/config-drift.d.ts +37 -0
  53. package/dist/lib/config-drift.js +72 -0
  54. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  55. package/dist/lib/daemon/auth-sync-service.js +34 -0
  56. package/dist/lib/daemon/daemon.js +30 -4
  57. package/dist/lib/daemon-services.d.ts +1 -1
  58. package/dist/lib/daemon-services.js +5 -0
  59. package/dist/lib/device-config.d.ts +3 -3
  60. package/dist/lib/device-config.js +5 -5
  61. package/dist/lib/devices/connect.d.ts +26 -0
  62. package/dist/lib/devices/connect.js +48 -1
  63. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  64. package/dist/lib/devices/doctor-findings.js +19 -1
  65. package/dist/lib/exec.d.ts +28 -0
  66. package/dist/lib/exec.js +73 -7
  67. package/dist/lib/feed/feed.d.ts +1 -1
  68. package/dist/lib/feed/feed.js +23 -1
  69. package/dist/lib/feed-broadcast.js +1 -1
  70. package/dist/lib/fleet/apply.d.ts +11 -0
  71. package/dist/lib/fleet/apply.js +23 -3
  72. package/dist/lib/fleet/auth-sync.js +5 -3
  73. package/dist/lib/help.d.ts +9 -0
  74. package/dist/lib/help.js +29 -1
  75. package/dist/lib/hosts/passthrough.d.ts +1 -10
  76. package/dist/lib/hosts/passthrough.js +1 -13
  77. package/dist/lib/installations/versions.js +9 -1
  78. package/dist/lib/linux-userns.d.ts +58 -0
  79. package/dist/lib/linux-userns.js +116 -0
  80. package/dist/lib/memory.d.ts +26 -0
  81. package/dist/lib/memory.js +80 -1
  82. package/dist/lib/monitors/config.d.ts +11 -0
  83. package/dist/lib/monitors/config.js +8 -0
  84. package/dist/lib/monitors/engine.js +8 -1
  85. package/dist/lib/monitors/state.d.ts +37 -1
  86. package/dist/lib/monitors/state.js +79 -4
  87. package/dist/lib/permissions-registry.d.ts +2 -0
  88. package/dist/lib/permissions-registry.js +116 -14
  89. package/dist/lib/permissions.d.ts +5 -3
  90. package/dist/lib/permissions.js +25 -27
  91. package/dist/lib/profiles.d.ts +8 -7
  92. package/dist/lib/profiles.js +12 -0
  93. package/dist/lib/project-key.d.ts +9 -0
  94. package/dist/lib/project-key.js +11 -0
  95. package/dist/lib/secrets/bundles.d.ts +35 -0
  96. package/dist/lib/secrets/bundles.js +78 -1
  97. package/dist/lib/secrets/push.d.ts +3 -8
  98. package/dist/lib/secrets/push.js +18 -14
  99. package/dist/lib/secrets/remote.d.ts +9 -18
  100. package/dist/lib/secrets/remote.js +11 -26
  101. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  102. package/dist/lib/secrets/reserved-sync.js +129 -0
  103. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  104. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  105. package/dist/lib/self-heal/registry.js +4 -0
  106. package/dist/lib/self-heal/types.d.ts +1 -1
  107. package/dist/lib/session/active.js +1 -4
  108. package/dist/lib/session/db.d.ts +39 -4
  109. package/dist/lib/session/db.js +130 -29
  110. package/dist/lib/session/discover.d.ts +32 -4
  111. package/dist/lib/session/discover.js +119 -25
  112. package/dist/lib/session/insights.d.ts +14 -0
  113. package/dist/lib/session/insights.js +25 -2
  114. package/dist/lib/session/linear.js +1 -1
  115. package/dist/lib/session/shell-programs.d.ts +17 -0
  116. package/dist/lib/session/shell-programs.js +21 -0
  117. package/dist/lib/session/state.js +2 -1
  118. package/dist/lib/session/stream-render.js +2 -1
  119. package/dist/lib/session/tool-calls.js +2 -5
  120. package/dist/lib/session/trajectory-html.js +2 -1
  121. package/dist/lib/session/trajectory.js +3 -12
  122. package/dist/lib/session/types.d.ts +8 -0
  123. package/dist/lib/share/publish.d.ts +53 -5
  124. package/dist/lib/share/publish.js +99 -17
  125. package/dist/lib/share/worker-template.js +582 -57
  126. package/dist/lib/startup/root-command.js +2 -1
  127. package/dist/lib/state.d.ts +16 -0
  128. package/dist/lib/state.js +178 -46
  129. package/dist/lib/sync-status.d.ts +4 -0
  130. package/dist/lib/sync-status.js +3 -0
  131. package/dist/lib/traces/classify.js +24 -19
  132. package/dist/lib/usage-refresh.js +2 -1
  133. package/dist/lib/view-types.d.ts +2 -0
  134. package/package.json +2 -1
@@ -23,9 +23,9 @@ import * as yaml from 'yaml';
23
23
  import { atomicWriteFileSync } from './fs-atomic.js';
24
24
  import { getUserAgentsDir, readMeta, updateMeta } from './state.js';
25
25
  import { deleteKeychainToken, getKeychainToken, hasKeychainToken } from './secrets/index.js';
26
- import { bundleExists, deleteBundle, listBundles, readBundle, renameBundle, writeBundleWithItems } from './secrets/bundles.js';
26
+ import { bundleExists, deleteBundle, listBundles, readAndResolveBundleEnv, readBundle, renameBundle, writeBundleWithItems } from './secrets/bundles.js';
27
27
  import { getAccountProvider } from './account-provider-registry.js';
28
- import { accountSecretItem, buildAccountBundle, parseAccountBundle } from './account-schema.js';
28
+ import { accountSecretItem, buildAccountBundle, parseAccountBundle, secretVarFor } from './account-schema.js';
29
29
  const NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
30
30
  const NATIVE_LABEL = /^[a-zA-Z0-9][a-zA-Z0-9@._+-]*$/;
31
31
  const AUTH_KINDS = ['api-key', 'setup-token', 'bearer-token'];
@@ -455,6 +455,18 @@ export function resolveCredentialAccount(name, host, expectedProvider, base = ge
455
455
  const envVar = account.auth === 'setup-token' ? 'CLAUDE_CODE_OAUTH_TOKEN' : adapter.envFor(host, account.auth);
456
456
  if (!hasKeychainToken(account.secretRef))
457
457
  throw new Error(`Credential for account '${account.name}' is missing on this device. Add it with 'agents accounts set-key ${account.name}'.`);
458
+ const secretVar = secretVarFor(account.auth);
459
+ // Account bundles are policy `never`, so their value items carry no biometry
460
+ // ACL. Resolve through the bundle path that verifies that policy and attests
461
+ // `silentNoAcl` to the headless keychain guard. Calling getKeychainToken()
462
+ // directly makes a headless --account launch reject the prompt-free item as
463
+ // if it required Touch ID before the helper ever reads it (PHNX-2939).
464
+ const secret = readAndResolveBundleEnv(account.name, {
465
+ keys: [secretVar],
466
+ keyMode: 'storage',
467
+ agentOnly: true,
468
+ caller: 'accounts resolve',
469
+ }).env[secretVar];
458
470
  const connectionEnv = { ...adapter.connectionEnvFor(host) };
459
471
  if (account.baseUrl) {
460
472
  const baseUrlEnv = adapter.baseUrlEnvFor(host);
@@ -467,7 +479,7 @@ export function resolveCredentialAccount(name, host, expectedProvider, base = ge
467
479
  name: account.name,
468
480
  provider: account.provider,
469
481
  auth: account.auth,
470
- env: { ...connectionEnv, [envVar]: getKeychainToken(account.secretRef) },
482
+ env: { ...connectionEnv, [envVar]: secret },
471
483
  };
472
484
  }
473
485
  /**
@@ -336,16 +336,30 @@ export declare function resolveAccountVersion(agent: AgentId, account: string):
336
336
  export declare function selectBalancedVersion(agent: AgentId): Promise<RotateResult | null>;
337
337
  /** Select the configured version if available, otherwise another available version. */
338
338
  export declare function selectAvailableVersion(agent: AgentId, preferredVersion?: string | null): Promise<RotateResult | null>;
339
+ /**
340
+ * Resolve the version `agents run` should use when the caller did not pin
341
+ * one with `@version`. The caller supplies the effective strategy.
342
+ *
343
+ * `pinned` still prefers the workspace/global default, but it MUST NOT launch
344
+ * a logged-out (or revoked) default when the same device holds a signed-in
345
+ * version that can run — that was a silent 1-second death on fleet workers
346
+ * whose default home had no credential (PHNX-2685). A rate-limited pin is
347
+ * still honoured: `--strategy pinned` remains the escape hatch to force the
348
+ * default through a throttle. When the default is auth-blocked and nothing
349
+ * else is healthy, `exhausted` is set so the caller fails loud instead of
350
+ * spawning into a credential-less home.
351
+ */
339
352
  export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string, collect?: (agent: AgentId) => Promise<RotateCandidate[]>): Promise<{
340
353
  version: string | null;
341
354
  rotation: RotateResult | null;
342
355
  /**
343
- * Set when a non-pinned strategy found ZERO healthy candidates among the
344
- * installed versions: the full excluded set, so callers fail loud with
345
- * per-account reasons instead of launching the exhausted pinned default
346
- * (RUSH-2132). Undefined for pinned, for successful picks, and when no
347
- * version is installed at all (the pre-existing not-installed path — there
348
- * is no account to be "unhealthy").
356
+ * Set when a strategy found ZERO healthy candidates among the installed
357
+ * versions: the full excluded set, so callers fail loud with per-account
358
+ * reasons instead of launching the exhausted pinned default (RUSH-2132).
359
+ * Also set for `pinned` when the default is logged out / revoked and no
360
+ * signed-in alternative exists (PHNX-2685). Undefined for successful picks
361
+ * and when no version is installed at all (the pre-existing not-installed
362
+ * path — there is no account to be "unhealthy").
349
363
  */
350
364
  exhausted?: RotateCandidate[];
351
365
  }>;
@@ -690,12 +690,6 @@ export async function selectBalancedVersion(agent) {
690
690
  export async function selectAvailableVersion(agent, preferredVersion) {
691
691
  return pickAvailableCandidate(await collectRunCandidates(agent), preferredVersion);
692
692
  }
693
- /**
694
- * Resolve the version `agents run` should use when the caller did not pin
695
- * one with `@version`. The caller supplies the effective strategy; if that
696
- * strategy cannot find a usable candidate, fall back to the pinned
697
- * workspace/global version.
698
- */
699
693
  /**
700
694
  * Record a rotation pick so parallel callers see it as recently-used.
701
695
  * Writes a stamp file per agent — lightweight, no locking needed since
@@ -722,12 +716,49 @@ function readRotationStamp(agent) {
722
716
  catch { /* missing or corrupt — treat as no stamp */ }
723
717
  return null;
724
718
  }
719
+ /**
720
+ * Resolve the version `agents run` should use when the caller did not pin
721
+ * one with `@version`. The caller supplies the effective strategy.
722
+ *
723
+ * `pinned` still prefers the workspace/global default, but it MUST NOT launch
724
+ * a logged-out (or revoked) default when the same device holds a signed-in
725
+ * version that can run — that was a silent 1-second death on fleet workers
726
+ * whose default home had no credential (PHNX-2685). A rate-limited pin is
727
+ * still honoured: `--strategy pinned` remains the escape hatch to force the
728
+ * default through a throttle. When the default is auth-blocked and nothing
729
+ * else is healthy, `exhausted` is set so the caller fails loud instead of
730
+ * spawning into a credential-less home.
731
+ */
725
732
  export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
726
733
  const fallback = resolveVersion(agent, cwd);
734
+ const candidates = await collect(agent);
727
735
  if (strategy === 'pinned') {
736
+ const pinnedCandidate = fallback
737
+ ? candidates.find((c) => c.version === fallback)
738
+ : undefined;
739
+ // Auth-blocked pin: the home cannot authenticate, so launching it is a
740
+ // guaranteed miss. Prefer a signed-in sibling on this device.
741
+ if (pinnedCandidate && isSignInRecoverable(readinessFromCandidate(pinnedCandidate))) {
742
+ const rotation = pickAvailableCandidate(candidates, fallback);
743
+ if (rotation) {
744
+ emit('rotation.resolved', {
745
+ module: 'rotate',
746
+ agent,
747
+ version: rotation.picked.version,
748
+ strategy,
749
+ healthy: rotation.healthy.length,
750
+ excluded: rotation.excluded.length,
751
+ });
752
+ return { version: rotation.picked.version, rotation };
753
+ }
754
+ return {
755
+ version: fallback,
756
+ rotation: null,
757
+ exhausted: candidates.length > 0 ? candidates : undefined,
758
+ };
759
+ }
728
760
  return { version: fallback, rotation: null };
729
761
  }
730
- const candidates = await collect(agent);
731
762
  const rotation = strategy === 'available'
732
763
  ? pickAvailableCandidate(candidates, fallback)
733
764
  : pickBalancedCandidate(candidates);
@@ -22,6 +22,12 @@ import type { AgentId } from '../types.js';
22
22
  */
23
23
  export declare function usageNoCredentialError(agent: string): string;
24
24
  export declare function usageExpiredCredentialError(agent: string): string;
25
+ /**
26
+ * Kimi-specific expired-credential wording. A normal Kimi launch refreshes its
27
+ * own OAuth access token, so the recovery action for an expired Kimi credential
28
+ * is to run Kimi once — not to re-auth through agents-cli (RUSH-3198).
29
+ */
30
+ export declare function usageExpiredKimiCredentialError(): string;
25
31
  export declare function usageRejectedError(agent: string, status: number): string;
26
32
  /**
27
33
  * Canonical phrase for the Anthropic setup-token scope gap (RUSH-2392).
@@ -39,6 +45,26 @@ export declare const USAGE_HEADLESS_SCOPE_MARKER = "usage unavailable (headless)
39
45
  export declare function usageHeadlessScopeError(agent?: string): string;
40
46
  /** True when an error string is the setup-token scope gap (RUSH-2392). */
41
47
  export declare function isUsageHeadlessScopeError(error: string | null | undefined): boolean;
48
+ /**
49
+ * Canonical phrase for a Claude account the usage reader holds no usable
50
+ * credential for. Distinct from {@link USAGE_HEADLESS_SCOPE_MARKER}, which
51
+ * means a setup-token WAS read and the endpoint refused its scope.
52
+ */
53
+ export declare const USAGE_NO_USAGE_CREDENTIAL_MARKER = "usage unavailable (no usage credential)";
54
+ /**
55
+ * Claude's own no-credential message. The shared
56
+ * {@link usageNoCredentialError} offers "sign in" as the remedy, which holds
57
+ * for Kimi/Droid/Cursor — their CLIs rotate a readable token on the next launch
58
+ * — and is false for Claude: the usage read deliberately never touches the
59
+ * interactive login (RUSH-1822), so an account that IS signed in reads as
60
+ * unreadable here and signing in again changes nothing. Naming only the second
61
+ * remedy would send the operator to `claude setup-token`, whose token then hits
62
+ * the `user:profile` scope gap (RUSH-2392) — the loop reported in #2987 — so
63
+ * this message states both constraints and that the account still runs.
64
+ */
65
+ export declare function usageNoClaudeUsageCredentialError(): string;
66
+ /** True when an error string is the Claude no-usage-credential state (#2987). */
67
+ export declare function isUsageNoUsageCredentialError(error: string | null | undefined): boolean;
42
68
  /**
43
69
  * Detect Anthropic's usage-endpoint scope denial: HTTP 403 whose body names
44
70
  * `user:profile` (or "scope requirement"). A bare 403 without that body stays
@@ -72,6 +98,16 @@ export declare function usageUnreachableError(agent: string, cause?: unknown): s
72
98
  export declare const USAGE_NO_RECENT_USAGE_MARKER = "no usage recorded yet";
73
99
  export declare const USAGE_BENIGN_STATE: unique symbol;
74
100
  export type UsageBenignState = 'no-recent-usage';
101
+ /**
102
+ * Sentinel `UsageInfo.error` for a read-only lookup whose cache held nothing
103
+ * (`getUsageInfoForIdentity`). No request was made and nothing failed — the
104
+ * daemon simply has not collected this account yet. It was an unclassified
105
+ * literal, so `classifyUsageErrorKind` fell through to `'rejected'` and
106
+ * `agents view` printed the generic "usage unavailable" for a cold cache,
107
+ * which reads as a failure the operator should chase (#2987). The string value
108
+ * is unchanged; callers that already compare against `'stale'` keep working.
109
+ */
110
+ export declare const USAGE_NOT_COLLECTED_MARKER = "stale";
75
111
  /**
76
112
  * Shared error-classification + 429 backoff for a networked usage fetch whose
77
113
  * only signal is an HTTP status (or none at all, on a network failure) —
@@ -91,7 +127,7 @@ export declare function classifyUsageFetchFailure(agent: string, agentId: 'antig
91
127
  * distinct causes (RUSH-3040). Matched against the canonical strings this file
92
128
  * constructs — never re-derive these prefixes at a call site.
93
129
  */
94
- export type UsageErrorKind = 'no-credential' | 'expired-credential' | 'rate-limited' | 'rejected' | 'headless-scope' | 'unreachable';
130
+ export type UsageErrorKind = 'no-credential' | 'no-usage-credential' | 'expired-credential' | 'rate-limited' | 'rejected' | 'headless-scope' | 'unreachable' | 'not-collected';
95
131
  /** Classify a `UsageInfo.error` string into its {@link UsageErrorKind}, or null when there is no error. */
96
132
  export declare function classifyUsageErrorKind(error: string | null | undefined): UsageErrorKind | null;
97
133
  /**
@@ -59,6 +59,14 @@ export function usageNoCredentialError(agent) {
59
59
  export function usageExpiredCredentialError(agent) {
60
60
  return `${agent} credential expired — re-auth this account (a usage read never refreshes it).`;
61
61
  }
62
+ /**
63
+ * Kimi-specific expired-credential wording. A normal Kimi launch refreshes its
64
+ * own OAuth access token, so the recovery action for an expired Kimi credential
65
+ * is to run Kimi once — not to re-auth through agents-cli (RUSH-3198).
66
+ */
67
+ export function usageExpiredKimiCredentialError() {
68
+ return `Kimi credential expired — run Kimi once to refresh it (a usage read never refreshes it).`;
69
+ }
62
70
  export function usageRejectedError(agent, status) {
63
71
  return status === 429
64
72
  ? `${agent} is rate-limiting the usage endpoint for this machine (HTTP 429).`
@@ -84,6 +92,31 @@ export function usageHeadlessScopeError(agent = 'Claude') {
84
92
  export function isUsageHeadlessScopeError(error) {
85
93
  return typeof error === 'string' && error.includes(USAGE_HEADLESS_SCOPE_MARKER);
86
94
  }
95
+ /**
96
+ * Canonical phrase for a Claude account the usage reader holds no usable
97
+ * credential for. Distinct from {@link USAGE_HEADLESS_SCOPE_MARKER}, which
98
+ * means a setup-token WAS read and the endpoint refused its scope.
99
+ */
100
+ export const USAGE_NO_USAGE_CREDENTIAL_MARKER = 'usage unavailable (no usage credential)';
101
+ /**
102
+ * Claude's own no-credential message. The shared
103
+ * {@link usageNoCredentialError} offers "sign in" as the remedy, which holds
104
+ * for Kimi/Droid/Cursor — their CLIs rotate a readable token on the next launch
105
+ * — and is false for Claude: the usage read deliberately never touches the
106
+ * interactive login (RUSH-1822), so an account that IS signed in reads as
107
+ * unreadable here and signing in again changes nothing. Naming only the second
108
+ * remedy would send the operator to `claude setup-token`, whose token then hits
109
+ * the `user:profile` scope gap (RUSH-2392) — the loop reported in #2987 — so
110
+ * this message states both constraints and that the account still runs.
111
+ */
112
+ export function usageNoClaudeUsageCredentialError() {
113
+ return (`Claude ${USAGE_NO_USAGE_CREDENTIAL_MARKER} — a usage read never uses your login ` +
114
+ '(RUSH-1822); a setup-token cannot read usage (RUSH-2392). The account still runs.');
115
+ }
116
+ /** True when an error string is the Claude no-usage-credential state (#2987). */
117
+ export function isUsageNoUsageCredentialError(error) {
118
+ return typeof error === 'string' && error.includes(USAGE_NO_USAGE_CREDENTIAL_MARKER);
119
+ }
87
120
  /**
88
121
  * Detect Anthropic's usage-endpoint scope denial: HTTP 403 whose body names
89
122
  * `user:profile` (or "scope requirement"). A bare 403 without that body stays
@@ -130,6 +163,16 @@ export function usageUnreachableError(agent, cause) {
130
163
  */
131
164
  export const USAGE_NO_RECENT_USAGE_MARKER = 'no usage recorded yet';
132
165
  export const USAGE_BENIGN_STATE = Symbol('usageBenignState');
166
+ /**
167
+ * Sentinel `UsageInfo.error` for a read-only lookup whose cache held nothing
168
+ * (`getUsageInfoForIdentity`). No request was made and nothing failed — the
169
+ * daemon simply has not collected this account yet. It was an unclassified
170
+ * literal, so `classifyUsageErrorKind` fell through to `'rejected'` and
171
+ * `agents view` printed the generic "usage unavailable" for a cold cache,
172
+ * which reads as a failure the operator should chase (#2987). The string value
173
+ * is unchanged; callers that already compare against `'stale'` keep working.
174
+ */
175
+ export const USAGE_NOT_COLLECTED_MARKER = 'stale';
133
176
  /**
134
177
  * Shared error-classification + 429 backoff for a networked usage fetch whose
135
178
  * only signal is an HTTP status (or none at all, on a network failure) —
@@ -155,8 +198,12 @@ export function classifyUsageFetchFailure(agent, agentId, status, retryAfterHead
155
198
  export function classifyUsageErrorKind(error) {
156
199
  if (!error)
157
200
  return null;
201
+ if (error === USAGE_NOT_COLLECTED_MARKER)
202
+ return 'not-collected';
158
203
  if (isUsageHeadlessScopeError(error))
159
204
  return 'headless-scope';
205
+ if (isUsageNoUsageCredentialError(error))
206
+ return 'no-usage-credential';
160
207
  if (error.startsWith('No readable '))
161
208
  return 'no-credential';
162
209
  if (error.includes('credential expired'))
@@ -342,7 +389,7 @@ export async function getUsageInfoForIdentity(input, opts) {
342
389
  // cache file holds every account without collision.
343
390
  if (!usageKey) {
344
391
  if (readOnly)
345
- return { snapshot: null, error: 'stale' };
392
+ return { snapshot: null, error: USAGE_NOT_COLLECTED_MARKER };
346
393
  return getUsageInfo(input.agentId, {
347
394
  home: input.home,
348
395
  cliVersion: input.cliVersion,
@@ -361,11 +408,12 @@ export async function getUsageInfoForIdentity(input, opts) {
361
408
  // it. A stale-or-absent snapshot is handled downstream by the router's own
362
409
  // freshness guard (`isUsageVerified` in rotate.ts), which routes around a
363
410
  // number it can't confirm rather than trusting an old one — so returning a
364
- // stale snapshot here is safe, and an absent one reports `'stale'`.
411
+ // stale snapshot here is safe, and an absent one reports
412
+ // {@link USAGE_NOT_COLLECTED_MARKER}.
365
413
  if (readOnly) {
366
414
  if (cached)
367
415
  return { snapshot: cached, error: null };
368
- return { snapshot: null, error: 'stale' };
416
+ return { snapshot: null, error: USAGE_NOT_COLLECTED_MARKER };
369
417
  }
370
418
  // Explicit refresh: block on the shared device collector.
371
419
  return fetchLiveUsageDeduped(input, usageKey, cached, opts?.fileOnly === true);
@@ -460,15 +508,28 @@ function formatUsageErrorKindLabel(kind, detail) {
460
508
  switch (kind) {
461
509
  case 'no-credential':
462
510
  return 'sign in / provision token';
511
+ // Both of these are permanent for the account as configured, and both used
512
+ // to render as the generic bucket — which reads as a transient failure and
513
+ // sends operators back to `claude setup-token` for a remedy that cannot
514
+ // work (#2987). Name the state instead.
515
+ case 'no-usage-credential':
516
+ return USAGE_NO_USAGE_CREDENTIAL_MARKER;
517
+ case 'headless-scope':
518
+ return USAGE_HEADLESS_SCOPE_MARKER;
463
519
  case 'expired-credential':
520
+ // Kimi refreshes its own credential on a normal launch; the recovery hint
521
+ // is embedded in the error string so the label matches the exact action.
522
+ if (detail?.includes('run Kimi once'))
523
+ return 'run Kimi once';
464
524
  return 're-auth for usage';
525
+ case 'not-collected':
526
+ return 'usage pending';
465
527
  case 'rate-limited': {
466
528
  const retryHint = detail?.match(/not retrying for (.+)\.$/)?.[1] ?? null;
467
529
  return retryHint ? `rate-limited (retry ~${retryHint})` : 'rate-limited';
468
530
  }
469
531
  case 'rejected':
470
532
  case 'unreachable':
471
- case 'headless-scope':
472
533
  case null:
473
534
  case undefined:
474
535
  default:
@@ -766,7 +827,11 @@ async function getClaudeUsageInfo(options) {
766
827
  fileOnly: options?.fileOnly === true,
767
828
  });
768
829
  if (!oauth?.accessToken) {
769
- return { snapshot: null, error: usageNoCredentialError('Claude') };
830
+ // NOT the shared no-credential message: "sign in" is not a remedy here.
831
+ // The account this reads for is usually signed in already — the reader is
832
+ // forbidden from touching that login (RUSH-1822) — so the shared wording
833
+ // asked the operator to redo the one thing they had already done (#2987).
834
+ return { snapshot: null, error: usageNoClaudeUsageCredentialError() };
770
835
  }
771
836
  const requestedOrgId = normalizeString(options?.organizationId);
772
837
  const liveOrgId = normalizeString(oauth.organizationUuid);
@@ -887,7 +952,7 @@ async function getKimiUsageInfo(options) {
887
952
  }
888
953
  const expiresAt = typeof cred?.expires_at === 'number' ? cred.expires_at : null;
889
954
  if (expiresAt !== null && Date.now() / 1000 >= expiresAt) {
890
- return { snapshot: null, error: usageExpiredCredentialError('Kimi') };
955
+ return { snapshot: null, error: usageExpiredKimiCredentialError() };
891
956
  }
892
957
  // Honour a live Retry-After rather than re-arming the penalty (see
893
958
  // usage-backoff.ts). No request at all while the window is open.
@@ -376,8 +376,11 @@ export declare function resolveOpenCodeAccountId(base: string): string | undefin
376
376
  * this file is not authoritative, and probing the Keychain would raise an
377
377
  * authorization sheet per installed version on every `agents run` — the reason
378
378
  * rotation stopped calling `isClaudeAuthValid` at all. Off macOS the file IS the
379
- * only store, so a token-less file is proof of signed-out. `platform` is a
380
- * parameter so both branches are testable on any host.
379
+ * only store, so a missing or token-less file is proof of signed-out — unless a
380
+ * Linux setup-token (`.claude/.oauth_token`) is present, which the shim exports
381
+ * as `CLAUDE_CODE_OAUTH_TOKEN` and can authenticate the run without
382
+ * `.credentials.json`. `platform` is a parameter so both branches are testable
383
+ * on any host.
381
384
  *
382
385
  * Sync, no Keychain, no network — safe on the `agents run` hot path.
383
386
  */
@@ -1897,14 +1897,29 @@ function museAuthEmail(value, depth = 0) {
1897
1897
  * this file is not authoritative, and probing the Keychain would raise an
1898
1898
  * authorization sheet per installed version on every `agents run` — the reason
1899
1899
  * rotation stopped calling `isClaudeAuthValid` at all. Off macOS the file IS the
1900
- * only store, so a token-less file is proof of signed-out. `platform` is a
1901
- * parameter so both branches are testable on any host.
1900
+ * only store, so a missing or token-less file is proof of signed-out — unless a
1901
+ * Linux setup-token (`.claude/.oauth_token`) is present, which the shim exports
1902
+ * as `CLAUDE_CODE_OAUTH_TOKEN` and can authenticate the run without
1903
+ * `.credentials.json`. `platform` is a parameter so both branches are testable
1904
+ * on any host.
1902
1905
  *
1903
1906
  * Sync, no Keychain, no network — safe on the `agents run` hot path.
1904
1907
  */
1905
1908
  export function isClaudeCredentialFileBlank(base, platform = process.platform) {
1906
1909
  if (platform === 'darwin')
1907
1910
  return false;
1911
+ // A per-version setup-token is a real credential on Linux even when
1912
+ // `.credentials.json` was never written (the shim's `$CLAUDE_CONFIG_DIR/.oauth_token`
1913
+ // fallback). Treat it as signed-in so rotation does not skip a worker that
1914
+ // authenticates from an attached setup-token.
1915
+ try {
1916
+ const token = fs.readFileSync(path.join(base, '.claude', '.oauth_token'), 'utf-8').trim();
1917
+ if (token.length > 0)
1918
+ return false;
1919
+ }
1920
+ catch {
1921
+ /* absent — fall through to the credentials.json floor */
1922
+ }
1908
1923
  try {
1909
1924
  const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
1910
1925
  const oauth = JSON.parse(raw).claudeAiOauth;
@@ -1913,11 +1928,14 @@ export function isClaudeCredentialFileBlank(base, platform = process.platform) {
1913
1928
  const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
1914
1929
  return !nonEmpty(oauth.accessToken) && !nonEmpty(oauth.refreshToken);
1915
1930
  }
1916
- catch {
1917
- // No file (a Keychain-backed home, or never logged in here) or an
1918
- // unreadable/corrupt one: not positive evidence of a blank credential, so
1919
- // leave the existing signal alone rather than declaring a working install
1920
- // signed out.
1931
+ catch (err) {
1932
+ // Off macOS the file IS the store. Missing it means this home cannot
1933
+ // authenticate (a newly installed default with leftover `.claude.json`
1934
+ // oauthAccount is the PHNX-2685 false-healthy case). A corrupt file is
1935
+ // not positive evidence of a blank credential — leave the existing
1936
+ // signal alone rather than declaring a working install signed out.
1937
+ if (err.code === 'ENOENT')
1938
+ return true;
1921
1939
  return false;
1922
1940
  }
1923
1941
  }
@@ -73,7 +73,10 @@ export function registerMixCommands(parent) {
73
73
  .option('--days <n>', 'Days of history to include', '7')
74
74
  .option('--json', 'Emit JSON instead of tables')
75
75
  .action(function summary() {
76
- const o = this.opts();
76
+ // optsWithGlobals(): --json collides by name with the `insights` parent, so
77
+ // commander binds it to the parent and this.opts() never sees it. Merging
78
+ // ancestor opts is what the per-recipe leaves below already do.
79
+ const o = this.optsWithGlobals();
77
80
  renderMixDashboard(parseMixDays(o.days), Boolean(o.json), banner);
78
81
  });
79
82
  setHelpSections(mix, {
@@ -104,7 +107,8 @@ export function registerMixCommands(parent) {
104
107
  parent.command('recipes')
105
108
  .description('List baked mix-recipe ids')
106
109
  .option('--json', 'Emit JSON')
107
- .action((o) => {
110
+ .action(function recipes() {
111
+ const o = this.optsWithGlobals();
108
112
  const list = listRecipes();
109
113
  if (o.json) {
110
114
  console.log(JSON.stringify(list, null, 2));
@@ -122,7 +126,8 @@ export function registerMixCommands(parent) {
122
126
  .option('--days <n>', 'Days of history', '7')
123
127
  .option('--limit <n>', 'Max rows', '40')
124
128
  .option('--json', 'Emit JSON')
125
- .action((o) => {
129
+ .action(function query() {
130
+ const o = this.optsWithGlobals();
126
131
  const win = analyticsWindow(parseMixDays(o.days));
127
132
  const kind = o.kind && USAGE_KINDS.includes(o.kind)
128
133
  ? o.kind
@@ -165,8 +170,9 @@ export function registerMixCommands(parent) {
165
170
  .option('--days <n>', 'Days of history', '7')
166
171
  .option('--json', 'Emit JSON')
167
172
  .action(function recipeAction() {
168
- const parentOpts = this.parent?.opts?.();
169
- const o = { ...parentOpts, ...this.opts() };
173
+ // optsWithGlobals() merges the `insights` parent opts, so the name-colliding
174
+ // --json/--since reach this leaf (see the sibling commands above).
175
+ const o = this.optsWithGlobals();
170
176
  const win = analyticsWindow(parseMixDays(o.days));
171
177
  const section = runRecipe(id, win);
172
178
  if (o.json) {
@@ -187,7 +193,7 @@ export function registerMixCommands(parent) {
187
193
  .option('--days <n>', 'Days of history to include', '7')
188
194
  .option('--json', 'Emit JSON instead of tables')
189
195
  .action(function summary() {
190
- const o = this.opts();
196
+ const o = this.optsWithGlobals();
191
197
  renderMixDashboard(parseMixDays(o.days), Boolean(o.json), banner);
192
198
  });
193
199
  setHelpSections(trends, {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * First-class setup-token mint + seed (PHNX-2364).
3
+ *
4
+ * Closes the mint-auth manual recipe: drive `claude setup-token` through the
5
+ * same injectable PTY driver `agents fleet login` uses, capture a well-formed
6
+ * `sk-ant-oat01-…` token (the #1767 ANSI-banner guard), and seed BOTH:
7
+ *
8
+ * 1. a named provider account (`agents accounts add` shape, policy never)
9
+ * 2. the reserved FILE-BASED `auth` bundle keyed per-account email, which
10
+ * usage/probe reads (`resolveClaudeSetupToken`)
11
+ *
12
+ * Native rotating OAuth is never copied. Only this non-rotating class is
13
+ * stored and optionally synced. Interactive mint is Claude-only; every other
14
+ * harness fails loud with the command that actually provisions it.
15
+ */
16
+ import type { AgentId } from './types.js';
17
+ import { type CredentialAccount } from './account-registry.js';
18
+ import { type DriveOptions, type PtyDriver } from './fleet/remote-login.js';
19
+ /** Well-formed Claude setup-token as it appears inside a TTY blob. */
20
+ export declare const CLAUDE_SETUP_TOKEN_CAPTURE_RE: RegExp;
21
+ export interface MintFlow {
22
+ harness: AgentId;
23
+ provider: string;
24
+ auth: 'setup-token';
25
+ /** Interactive mint argv after HOME=… <bin>. Null when stdin-seed only. */
26
+ mintArgs: string[] | null;
27
+ verificationUrlRegex: RegExp;
28
+ tokenCapture: RegExp;
29
+ }
30
+ /**
31
+ * Harnesses that expose an interactive setup-token mint. Native device-code
32
+ * login (codex/droid/kimi/grok) stays on `agents fleet login`; API keys stay
33
+ * on `agents accounts add`. Adding a harness here without a real mint command
34
+ * is a lying table — do not.
35
+ */
36
+ export declare const MINT_FLOWS: Record<string, MintFlow>;
37
+ export declare function listMintableHarnesses(): AgentId[];
38
+ export declare function getMintFlow(harnessRaw: string): MintFlow;
39
+ export declare function unmintableMessage(harness: string): string;
40
+ /** Strip CSI / Fe ANSI so a #1767 TTY blob can be scanned for a real token. */
41
+ export declare function stripAnsi(text: string): string;
42
+ /**
43
+ * Pull a single well-formed Claude setup-token out of a (possibly ANSI-wrapped)
44
+ * screen. Returns null when none is present. Two distinct tokens fail loud —
45
+ * guessing which one to seed is how a banner fragment becomes an auth header.
46
+ */
47
+ export declare function extractClaudeSetupToken(screen: string): string | null;
48
+ /** First https URL on the screen, trailing punctuation stripped. */
49
+ export declare function extractMintUrl(screen: string, flow: MintFlow): string | undefined;
50
+ export declare function isEmail(value: string): boolean;
51
+ /** Account-name slug of an email (`ada@example.com` → `ada-at-example.com`). */
52
+ export declare function accountNameFromEmail(email: string): string;
53
+ export declare function assertValidSetupToken(token: string): string;
54
+ export interface ResolveMintIdentityInput {
55
+ account?: string;
56
+ email?: string;
57
+ home?: string;
58
+ }
59
+ export interface ResolvedMintIdentity {
60
+ accountName: string;
61
+ email: string;
62
+ }
63
+ /**
64
+ * Resolve the named account + the email that keys the reserved `auth` bundle.
65
+ * `--account` that looks like an email is the email; a name needs `--email` or
66
+ * a locally signed-in `.claude.json`. Missing email fails loud — we must not
67
+ * fall back to a bare shared key (that is the multi-account mix-up).
68
+ */
69
+ export declare function resolveMintIdentity(input: ResolveMintIdentityInput): ResolvedMintIdentity;
70
+ /**
71
+ * Write (or rotate) the reserved FILE-BASED `auth` bundle's per-account key.
72
+ * Usage/probe ignores a keychain- or vault-backed bundle of this name, so a
73
+ * wrong backend fails loud instead of looking like a successful mint.
74
+ */
75
+ export declare function seedReservedAuthToken(email: string, token: string): {
76
+ key: string;
77
+ };
78
+ /**
79
+ * Create or rotate the named provider account that `agents run --account` and
80
+ * `agents accounts sync` consume. Existing account of a different kind fails
81
+ * loud rather than silently overwriting an API key with a setup-token.
82
+ */
83
+ export declare function seedNamedAccount(name: string, token: string, flow: MintFlow): CredentialAccount;
84
+ export interface MintDriveHooks {
85
+ driver?: PtyDriver;
86
+ openUrl?: (url: string) => Promise<void>;
87
+ /** Asked once the authorize URL is on screen, when `--code` was not given. */
88
+ readCode?: () => Promise<string | undefined>;
89
+ drive?: DriveOptions;
90
+ }
91
+ export interface DriveMintResult {
92
+ token: string;
93
+ url?: string;
94
+ sessionId: string;
95
+ }
96
+ export interface DriveSetupTokenMintOpts extends MintDriveHooks {
97
+ code?: string;
98
+ /** Suppress stdout progress so `--json` callers get a parseable blob. */
99
+ json?: boolean;
100
+ }
101
+ /**
102
+ * Drive `claude setup-token` in a PTY: scrape the authorize URL, open it,
103
+ * optionally paste `--code`, then capture the token with the #1767 guard.
104
+ * Tears the session down on the way out (success, timeout, or throw).
105
+ */
106
+ export declare function driveSetupTokenMint(command: string, flow: MintFlow, opts?: DriveSetupTokenMintOpts): Promise<DriveMintResult>;
107
+ export declare function buildMintCommand(flow: MintFlow, bin: string, home: string): string;
108
+ export declare function resolveMintInstallation(harness: AgentId): {
109
+ version: string;
110
+ bin: string;
111
+ home: string;
112
+ };
113
+ export interface MintAndSeedInput {
114
+ harness: string;
115
+ account?: string;
116
+ email?: string;
117
+ token?: string;
118
+ code?: string;
119
+ open?: boolean;
120
+ fleet?: boolean;
121
+ devices?: string[];
122
+ /** Suppress progress prints so `--json` stdout stays machine-parseable. */
123
+ json?: boolean;
124
+ hooks?: MintDriveHooks;
125
+ }
126
+ export interface FleetSyncRow {
127
+ device: string;
128
+ ok: boolean;
129
+ message: string;
130
+ }
131
+ export interface MintAndSeedResult {
132
+ harness: AgentId;
133
+ account: string;
134
+ email: string;
135
+ authBundleKey: string;
136
+ rotated: boolean;
137
+ fleet: FleetSyncRow[];
138
+ }
139
+ /**
140
+ * End-to-end mint: resolve identity, obtain a token (stdin or PTY drive),
141
+ * seed the named account + reserved auth bundle, optionally sync the fleet.
142
+ * Never returns or logs the token.
143
+ */
144
+ export declare function mintAndSeed(input: MintAndSeedInput): Promise<MintAndSeedResult>;
145
+ export declare function resolveSyncTargets(fleet: boolean, devices: string[]): Promise<string[]>;
146
+ /** True when a Claude setup-token is already seeded on this box (setup status). */
147
+ export declare function hasMintedSetupToken(): {
148
+ ready: boolean;
149
+ detail: string;
150
+ };