@phnx-labs/agents-cli 1.20.67 → 1.20.69

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 (83) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +14 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/check.js +96 -2
  5. package/dist/commands/computer.d.ts +26 -0
  6. package/dist/commands/computer.js +173 -149
  7. package/dist/commands/doctor.js +37 -9
  8. package/dist/commands/exec.d.ts +18 -0
  9. package/dist/commands/exec.js +126 -6
  10. package/dist/commands/fork.d.ts +9 -0
  11. package/dist/commands/fork.js +67 -0
  12. package/dist/commands/plugins.js +11 -2
  13. package/dist/commands/repo.js +47 -2
  14. package/dist/commands/run-account-picker.d.ts +14 -0
  15. package/dist/commands/run-account-picker.js +131 -0
  16. package/dist/commands/setup-browser.d.ts +18 -0
  17. package/dist/commands/setup-browser.js +142 -0
  18. package/dist/commands/setup-computer.d.ts +19 -0
  19. package/dist/commands/setup-computer.js +133 -0
  20. package/dist/commands/setup-share.d.ts +17 -0
  21. package/dist/commands/setup-share.js +85 -0
  22. package/dist/commands/setup.d.ts +1 -1
  23. package/dist/commands/setup.js +58 -1
  24. package/dist/commands/share.d.ts +15 -0
  25. package/dist/commands/share.js +10 -4
  26. package/dist/commands/ssh.js +267 -2
  27. package/dist/commands/teams.d.ts +11 -0
  28. package/dist/commands/teams.js +39 -1
  29. package/dist/commands/view.d.ts +4 -14
  30. package/dist/commands/view.js +39 -36
  31. package/dist/index.js +2 -1
  32. package/dist/lib/agents.d.ts +21 -5
  33. package/dist/lib/agents.js +59 -24
  34. package/dist/lib/auth-health.d.ts +103 -0
  35. package/dist/lib/auth-health.js +232 -0
  36. package/dist/lib/browser/chrome.d.ts +9 -0
  37. package/dist/lib/browser/chrome.js +22 -0
  38. package/dist/lib/browser/profiles.js +4 -25
  39. package/dist/lib/cli-entry.d.ts +23 -0
  40. package/dist/lib/cli-entry.js +116 -0
  41. package/dist/lib/computer/download.d.ts +51 -0
  42. package/dist/lib/computer/download.js +145 -0
  43. package/dist/lib/computer-rpc.js +9 -3
  44. package/dist/lib/daemon.d.ts +2 -2
  45. package/dist/lib/daemon.js +8 -89
  46. package/dist/lib/devices/connect.d.ts +15 -0
  47. package/dist/lib/devices/connect.js +17 -5
  48. package/dist/lib/devices/fleet.d.ts +23 -0
  49. package/dist/lib/devices/fleet.js +38 -0
  50. package/dist/lib/devices/health-report.d.ts +46 -0
  51. package/dist/lib/devices/health-report.js +159 -0
  52. package/dist/lib/devices/health.d.ts +14 -4
  53. package/dist/lib/devices/health.js +49 -8
  54. package/dist/lib/devices/terminfo.d.ts +44 -0
  55. package/dist/lib/devices/terminfo.js +167 -0
  56. package/dist/lib/git.d.ts +10 -0
  57. package/dist/lib/git.js +23 -0
  58. package/dist/lib/hosts/option.js +1 -1
  59. package/dist/lib/hosts/ready.js +5 -3
  60. package/dist/lib/hosts/remote-cmd.d.ts +12 -0
  61. package/dist/lib/hosts/remote-cmd.js +17 -1
  62. package/dist/lib/mcp.d.ts +21 -0
  63. package/dist/lib/mcp.js +278 -13
  64. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  65. package/dist/lib/resources/mcp.js +20 -342
  66. package/dist/lib/rotate.d.ts +12 -8
  67. package/dist/lib/rotate.js +22 -23
  68. package/dist/lib/routines.d.ts +13 -0
  69. package/dist/lib/routines.js +21 -0
  70. package/dist/lib/runner.js +18 -40
  71. package/dist/lib/secrets/agent.js +11 -15
  72. package/dist/lib/session/fork.d.ts +32 -0
  73. package/dist/lib/session/fork.js +101 -0
  74. package/dist/lib/share/capture.js +21 -3
  75. package/dist/lib/signin-badge.d.ts +41 -0
  76. package/dist/lib/signin-badge.js +64 -0
  77. package/dist/lib/ssh-exec.d.ts +5 -0
  78. package/dist/lib/ssh-exec.js +55 -1
  79. package/dist/lib/startup/command-registry.d.ts +1 -0
  80. package/dist/lib/startup/command-registry.js +2 -0
  81. package/dist/lib/usage.d.ts +40 -3
  82. package/dist/lib/usage.js +147 -16
  83. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
50
50
  // module on each invocation (which loaded the whole ~50-module tree before the
51
51
  // first byte of output), the registry maps a command name to a thunk that
52
52
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
53
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadShare, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
53
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadShare, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
54
54
  import { applyGlobalHelpConventions } from './lib/help.js';
55
55
  import { renderWhatsNew } from './lib/whats-new.js';
56
56
  import { emit, redactArgs } from './lib/events.js';
@@ -690,6 +690,7 @@ async function registerAllEagerCommands() {
690
690
  await reg(loadRoutines);
691
691
  await reg(loadMonitors);
692
692
  await reg(loadRun);
693
+ await reg(loadFork);
693
694
  await reg(loadDefaults);
694
695
  await reg(loadModels);
695
696
  await reg(loadPrune);
@@ -139,13 +139,25 @@ export interface AccountInfo {
139
139
  */
140
140
  export declare function formatClaudeOrgLabel(orgType: string | null | undefined): string | null;
141
141
  /**
142
- * Short badge identifying which org an account belongs to: "Turing Labs · Team"
143
- * for multi-seat orgs, just the tier label ("Max") for personal plans — a
144
- * personal org's name is auto-generated boilerplate ("<email>'s Organization"),
145
- * not identity. Returns null when the account carries no organizationType
146
- * (signed out, non-Claude agents, configs predating the field).
142
+ * Short badge identifying which ORG an account belongs to "Turing Labs" for a
143
+ * multi-seat Team/Enterprise org, whose name is real identity that disambiguates
144
+ * a Team seat from a same-email personal plan. Returns null for personal plans
145
+ * (Max/Pro/Free): the tier label now lives in the aligned plan column, so a badge
146
+ * would only duplicate it, and a personal org's name is auto-generated boilerplate
147
+ * ("<email>'s Organization"), not identity. Also null when the account carries no
148
+ * organizationType (signed out, non-Claude agents, configs predating the field).
147
149
  */
148
150
  export declare function accountOrgBadge(info?: Pick<AccountInfo, 'organizationType' | 'organizationName'> | null): string | null;
151
+ /** Agents whose local credential formats expose enough state for account selection. */
152
+ export declare const ACCOUNT_INSPECTION_AGENT_IDS: readonly ["claude", "codex", "gemini", "grok", "antigravity", "kimi", "droid", "opencode"];
153
+ /** Whether agents-cli can determine this agent's per-version sign-in state. */
154
+ export declare function supportsAccountInspection(agentId: AgentId): boolean;
155
+ /**
156
+ * Human-readable account identity shared by every account-aware surface.
157
+ * Prefer email, append a multi-seat Claude organization name when present,
158
+ * then fall back to a non-secret account id or a generic signed-in label.
159
+ */
160
+ export declare function accountDisplayLabel(info?: Pick<AccountInfo, 'email' | 'accountId' | 'signedIn' | 'organizationType' | 'organizationName'> | null): string;
149
161
  /** Return the email address associated with the agent's auth config, or null. */
150
162
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
151
163
  /** Decrypted contents of Droid's auth.v2.file (subset we consume). */
@@ -303,6 +315,10 @@ export declare function getUserMcpConfigPath(agentId: AgentId): string;
303
315
  * Get MCP config path for a specific HOME directory (used for version-managed agents).
304
316
  */
305
317
  export declare function getMcpConfigPathForHome(agentId: AgentId, home: string): string;
318
+ /**
319
+ * Get project-scoped MCP config path for an agent.
320
+ */
321
+ export declare function getProjectMcpConfigPath(agentId: AgentId, cwd?: string): string;
306
322
  /**
307
323
  * Parse MCP config based on agent type.
308
324
  */
@@ -978,20 +978,51 @@ export function formatClaudeOrgLabel(orgType) {
978
978
  .join(' ');
979
979
  }
980
980
  /**
981
- * Short badge identifying which org an account belongs to: "Turing Labs · Team"
982
- * for multi-seat orgs, just the tier label ("Max") for personal plans — a
983
- * personal org's name is auto-generated boilerplate ("<email>'s Organization"),
984
- * not identity. Returns null when the account carries no organizationType
985
- * (signed out, non-Claude agents, configs predating the field).
981
+ * Short badge identifying which ORG an account belongs to "Turing Labs" for a
982
+ * multi-seat Team/Enterprise org, whose name is real identity that disambiguates
983
+ * a Team seat from a same-email personal plan. Returns null for personal plans
984
+ * (Max/Pro/Free): the tier label now lives in the aligned plan column, so a badge
985
+ * would only duplicate it, and a personal org's name is auto-generated boilerplate
986
+ * ("<email>'s Organization"), not identity. Also null when the account carries no
987
+ * organizationType (signed out, non-Claude agents, configs predating the field).
986
988
  */
987
989
  export function accountOrgBadge(info) {
988
- const label = formatClaudeOrgLabel(info?.organizationType);
989
- if (!label)
990
- return null;
991
990
  const isMultiSeat = info?.organizationType === 'claude_team' || info?.organizationType === 'claude_enterprise';
992
991
  if (isMultiSeat && info?.organizationName)
993
- return `${info.organizationName} · ${label}`;
994
- return label;
992
+ return info.organizationName;
993
+ return null;
994
+ }
995
+ /** Agents whose local credential formats expose enough state for account selection. */
996
+ export const ACCOUNT_INSPECTION_AGENT_IDS = [
997
+ 'claude',
998
+ 'codex',
999
+ 'gemini',
1000
+ 'grok',
1001
+ 'antigravity',
1002
+ 'kimi',
1003
+ 'droid',
1004
+ 'opencode',
1005
+ ];
1006
+ const ACCOUNT_INSPECTION_AGENTS = new Set(ACCOUNT_INSPECTION_AGENT_IDS);
1007
+ /** Whether agents-cli can determine this agent's per-version sign-in state. */
1008
+ export function supportsAccountInspection(agentId) {
1009
+ return ACCOUNT_INSPECTION_AGENTS.has(agentId);
1010
+ }
1011
+ /**
1012
+ * Human-readable account identity shared by every account-aware surface.
1013
+ * Prefer email, append a multi-seat Claude organization name when present,
1014
+ * then fall back to a non-secret account id or a generic signed-in label.
1015
+ */
1016
+ export function accountDisplayLabel(info) {
1017
+ if (!info)
1018
+ return '';
1019
+ if (info.email) {
1020
+ const badge = accountOrgBadge(info);
1021
+ return badge ? `${info.email} (${badge})` : info.email;
1022
+ }
1023
+ if (info.signedIn)
1024
+ return info.accountId ? `id:${info.accountId}` : 'signed in';
1025
+ return '';
995
1026
  }
996
1027
  /** Return the email address associated with the agent's auth config, or null. */
997
1028
  export async function getAccountEmail(agentId, home) {
@@ -1321,19 +1352,23 @@ export async function getAccountInfo(agentId, home) {
1321
1352
  ['org', organizationId],
1322
1353
  ]);
1323
1354
  const usageKey = buildIdentityKey(agentId, [['org', organizationId]]);
1324
- // Plan is derived from .claude.json's billingType only. Reading
1325
- // subscriptionType from the keychain item ("Claude Code-credentials-<hash>")
1326
- // forces a macOS Keychain ACL prompt on every `agents run` (one prompt per
1327
- // installed version under balanced rotation) because Claude Code writes its
1328
- // credentials with its own process in the ACL our helper isn't trusted by
1329
- // that item. Callers that genuinely need subscriptionType (e.g. detailed
1330
- // `agents view`) should call loadClaudeOauth() directly.
1331
- let plan = null;
1332
- if (oa?.billingType === 'stripe_subscription') {
1333
- plan = 'Pro';
1334
- }
1335
- else if (oa?.billingType) {
1336
- plan = oa.billingType;
1355
+ // Plan tier is derived from .claude.json's organizationType, which carries
1356
+ // the TRUE tier (claude_max "Max", claude_pro → "Pro", claude_team →
1357
+ // "Team") and is already in-hand from the config we just read no Keychain
1358
+ // prompt. billingType only distinguishes "has a Stripe subscription" and so
1359
+ // mislabels every Max account as "Pro"; keep it as a fallback for older
1360
+ // configs predating organizationType. (Reading subscriptionType from the
1361
+ // keychain item would force a macOS Keychain ACL prompt on every `agents
1362
+ // run`, so we deliberately avoid it — organizationType gives us the tier
1363
+ // without that cost.)
1364
+ let plan = formatClaudeOrgLabel(oa?.organizationType);
1365
+ if (!plan) {
1366
+ if (oa?.billingType === 'stripe_subscription') {
1367
+ plan = 'Pro';
1368
+ }
1369
+ else if (oa?.billingType) {
1370
+ plan = oa.billingType;
1371
+ }
1337
1372
  }
1338
1373
  // usageStatus is NOT derived from cachedExtraUsageDisabledReason. That
1339
1374
  // field reports why pay-as-you-go overage is off (out_of_credits = no
@@ -2194,7 +2229,7 @@ export function getMcpConfigPathForHome(agentId, home) {
2194
2229
  /**
2195
2230
  * Get project-scoped MCP config path for an agent.
2196
2231
  */
2197
- function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
2232
+ export function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
2198
2233
  switch (agentId) {
2199
2234
  case 'claude':
2200
2235
  // Claude uses .mcp.json at project root for project-scoped MCPs
@@ -0,0 +1,103 @@
1
+ import { type AccountInfo } from './agents.js';
2
+ import type { AgentId } from './types.js';
3
+ import { type ProviderProbe } from './usage.js';
4
+ /**
5
+ * - `live` — completed an authenticated request (200).
6
+ * - `revoked` — the server rejected the token (401/403).
7
+ * - `expired` — locally-detected expiry; not network-verified (no refresh on the read path).
8
+ * - `rate_limited`— token works but is throttled right now (429).
9
+ * - `unverified` — credential present, not locally expired, but this agent has no in-repo probe endpoint (codex/grok).
10
+ * - `unconfigured`— no usable credential on disk.
11
+ * - `error` — network/other failure; verdict indeterminate (keep the last known one).
12
+ */
13
+ export type AuthVerdict = 'live' | 'revoked' | 'expired' | 'rate_limited' | 'unverified' | 'unconfigured' | 'error';
14
+ export interface AuthHealth {
15
+ verdict: AuthVerdict;
16
+ /** epoch ms of the probe. */
17
+ checkedAt: number;
18
+ /** optional short human detail (e.g. "HTTP 401", a network error). */
19
+ detail?: string;
20
+ /** account label for display (email / id), when known. Never part of the key. */
21
+ account?: string;
22
+ }
23
+ /** Agents with a live network probe wired up today. The rest are best-effort. */
24
+ export declare const LIVE_PROBE_AGENTS: ReadonlySet<AgentId>;
25
+ /** Map an HTTP status from a live probe to a verdict. */
26
+ export declare function classifyHttpStatus(status: number): AuthVerdict;
27
+ /** Turn a raw provider probe (from usage.ts) into a verdict. */
28
+ export declare function verdictFromProbe(probe: ProviderProbe): AuthVerdict;
29
+ /** A short human detail line for a probe result (rendered under --verbose). */
30
+ export declare function probeDetail(probe: ProviderProbe): string | undefined;
31
+ /** Uncolored glyph for a verdict (color is applied by the caller). */
32
+ export declare function verdictGlyph(verdict: AuthVerdict): string;
33
+ /** One-word label for matrices/verbose output. */
34
+ export declare function verdictLabel(verdict: AuthVerdict): string;
35
+ /** Roll a set of verdicts (one host×agent's installs) into counts for a matrix cell. */
36
+ export interface VerdictSummary {
37
+ live: number;
38
+ /** revoked — the server rejected the token (401/403). Genuinely needs re-login. */
39
+ bad: number;
40
+ /**
41
+ * expired / rate_limited / unverified / error — degraded or unknown, but NOT
42
+ * "re-login now". `expired` is soft for kimi/droid (their CLIs refresh the
43
+ * token on next launch; we don't refresh on the read path), so it must not be
44
+ * lumped with revoked or we'd cry wolf on a self-healing token.
45
+ */
46
+ warn: number;
47
+ total: number;
48
+ }
49
+ export declare function summarizeVerdicts(verdicts: AuthVerdict[]): VerdictSummary;
50
+ /** Verdicts that mean "this token was rejected by the server — re-login required". */
51
+ export declare function isDeadVerdict(verdict: AuthVerdict): boolean;
52
+ /** Human "3m ago" style age for a checkedAt timestamp. */
53
+ export declare function formatCheckedAge(checkedAt: number, now?: number): string;
54
+ /**
55
+ * Human account label for display (email, else id). NOT used in the cache key —
56
+ * two installs on one host can hold the same account with independently valid
57
+ * tokens, so the key is keyed by version (below), not account.
58
+ */
59
+ export declare function authAccountLabel(info: Pick<AccountInfo, 'email' | 'accountId' | 'userId'> | null | undefined): string | undefined;
60
+ /** Cache key: one entry per install — (host, agent, version). Unique per token. */
61
+ export declare function authCacheKey(host: string, agent: AgentId | string, version: string): string;
62
+ /** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
63
+ export declare function readAuthHealthCache(): Record<string, AuthHealth>;
64
+ /** Read one entry, or null. */
65
+ export declare function readAuthHealth(host: string, agent: AgentId | string, version: string): AuthHealth | null;
66
+ /**
67
+ * Merge entries into the cache. An incoming `error` verdict (a network blip,
68
+ * not a server rejection) is indeterminate, so it must NOT clobber a prior
69
+ * known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
70
+ * exactly the "cry wolf" the verdict model avoids for `expired`. This is the
71
+ * behaviour promised by the `error` doc on AuthVerdict ("keep the last known
72
+ * one"). Pure, so it's unit-tested directly. */
73
+ export declare function mergeAuthHealthEntries(current: Record<string, AuthHealth>, incoming: Record<string, AuthHealth>): Record<string, AuthHealth>;
74
+ /** Merge one or more entries into the cache (best-effort write). */
75
+ export declare function writeAuthHealthEntries(entries: Record<string, AuthHealth>): void;
76
+ /**
77
+ * Complete a live auth probe for one (agent, home). For claude/kimi/droid this
78
+ * hits the provider; for everyone else it reports a best-effort local verdict
79
+ * (`unverified` when a credential is present, `unconfigured` otherwise) — never
80
+ * masquerading as `live`.
81
+ */
82
+ export declare function probeAuthHealth(agent: AgentId, home: string | undefined, opts?: {
83
+ cliVersion?: string | null;
84
+ info?: AccountInfo | null;
85
+ }): Promise<AuthHealth>;
86
+ /** One probed install on a host. */
87
+ export interface AuthProbeRow {
88
+ agent: AgentId;
89
+ version: string;
90
+ account?: string;
91
+ health: AuthHealth;
92
+ }
93
+ /**
94
+ * Enumerate every installed (agent, version) on THIS host, probe each in
95
+ * parallel, and return the rows (installs with no credential at all are
96
+ * dropped). Shared by `agents fleet ping --local` and the daemon refresh.
97
+ */
98
+ export declare function probeLocalFleetAuth(opts?: {
99
+ cliVersion?: string | null;
100
+ agents?: readonly AgentId[];
101
+ }): Promise<AuthProbeRow[]>;
102
+ /** Persist a host's probed rows into the cache (keyed by host+agent+version). */
103
+ export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[]): void;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Live auth-health: does an agent account's stored credential actually complete
3
+ * an authenticated request right now?
4
+ *
5
+ * The rest of the CLI reports "signed in" from a local heuristic — a credential
6
+ * file is present and its email decodes — which cannot distinguish a good token
7
+ * from a revoked-but-unexpired one. This module completes a real request per
8
+ * (agent, account) and records the verdict in a small cache that `agents view`,
9
+ * `agents fleet status`, and the run rotation all read. The daemon and
10
+ * `agents fleet ping` are the writers; everyone else reads.
11
+ *
12
+ * The network probes themselves live in lib/usage.ts (where the per-provider
13
+ * token loaders + endpoints already are); this module classifies their result,
14
+ * covers the best-effort (non-networked) providers, and owns the cache.
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { ALL_AGENT_IDS, getAccountInfo } from './agents.js';
19
+ import { getCacheDir } from './state.js';
20
+ import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, } from './usage.js';
21
+ import { getVersionHomePath, listInstalledVersions } from './versions.js';
22
+ /** Agents with a live network probe wired up today. The rest are best-effort. */
23
+ export const LIVE_PROBE_AGENTS = new Set(['claude', 'kimi', 'droid']);
24
+ // ---------------------------------------------------------------------------
25
+ // Pure classifiers / render (unit-tested; no network, no fs)
26
+ // ---------------------------------------------------------------------------
27
+ /** Map an HTTP status from a live probe to a verdict. */
28
+ export function classifyHttpStatus(status) {
29
+ if (status >= 200 && status < 300)
30
+ return 'live';
31
+ if (status === 401 || status === 403)
32
+ return 'revoked';
33
+ if (status === 429)
34
+ return 'rate_limited';
35
+ return 'error';
36
+ }
37
+ /** Turn a raw provider probe (from usage.ts) into a verdict. */
38
+ export function verdictFromProbe(probe) {
39
+ if (probe.token === 'missing')
40
+ return 'unconfigured';
41
+ if (probe.token === 'expired')
42
+ return 'expired';
43
+ if (probe.status == null)
44
+ return 'error';
45
+ return classifyHttpStatus(probe.status);
46
+ }
47
+ /** A short human detail line for a probe result (rendered under --verbose). */
48
+ export function probeDetail(probe) {
49
+ if (probe.status != null && (probe.status < 200 || probe.status >= 300))
50
+ return `HTTP ${probe.status}`;
51
+ if (probe.error)
52
+ return probe.error;
53
+ return undefined;
54
+ }
55
+ const VERDICT_GLYPHS = {
56
+ live: '●', // ●
57
+ revoked: '○', // ○
58
+ expired: '○', // ○
59
+ rate_limited: '◐', // ◐
60
+ unverified: '◐', // ◐
61
+ unconfigured: '·', // ·
62
+ error: '·', // ·
63
+ };
64
+ /** Uncolored glyph for a verdict (color is applied by the caller). */
65
+ export function verdictGlyph(verdict) {
66
+ return VERDICT_GLYPHS[verdict] ?? '·';
67
+ }
68
+ /** One-word label for matrices/verbose output. */
69
+ export function verdictLabel(verdict) {
70
+ switch (verdict) {
71
+ case 'live': return 'live';
72
+ case 'revoked': return 'revoked';
73
+ case 'expired': return 'expired';
74
+ case 'rate_limited': return 'limited';
75
+ case 'unverified': return 'unverified';
76
+ case 'unconfigured': return '—';
77
+ case 'error': return '?';
78
+ }
79
+ }
80
+ export function summarizeVerdicts(verdicts) {
81
+ let live = 0;
82
+ let bad = 0;
83
+ let warn = 0;
84
+ for (const v of verdicts) {
85
+ if (v === 'live')
86
+ live++;
87
+ else if (v === 'revoked')
88
+ bad++;
89
+ else
90
+ warn++;
91
+ }
92
+ return { live, bad, warn, total: verdicts.length };
93
+ }
94
+ /** Verdicts that mean "this token was rejected by the server — re-login required". */
95
+ export function isDeadVerdict(verdict) {
96
+ return verdict === 'revoked';
97
+ }
98
+ /** Human "3m ago" style age for a checkedAt timestamp. */
99
+ export function formatCheckedAge(checkedAt, now = Date.now()) {
100
+ const secs = Math.max(0, Math.round((now - checkedAt) / 1000));
101
+ if (secs < 60)
102
+ return `${secs}s ago`;
103
+ const mins = Math.round(secs / 60);
104
+ if (mins < 60)
105
+ return `${mins}m ago`;
106
+ const hours = Math.round(mins / 60);
107
+ if (hours < 24)
108
+ return `${hours}h ago`;
109
+ return `${Math.round(hours / 24)}d ago`;
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Cache identity + IO (single source of truth read by view/fleet/rotation)
113
+ // ---------------------------------------------------------------------------
114
+ /**
115
+ * Human account label for display (email, else id). NOT used in the cache key —
116
+ * two installs on one host can hold the same account with independently valid
117
+ * tokens, so the key is keyed by version (below), not account.
118
+ */
119
+ export function authAccountLabel(info) {
120
+ return info?.email || info?.accountId || info?.userId || undefined;
121
+ }
122
+ /** Cache key: one entry per install — (host, agent, version). Unique per token. */
123
+ export function authCacheKey(host, agent, version) {
124
+ return `${host}:${agent}:${version}`;
125
+ }
126
+ function cacheFilePath() {
127
+ return path.join(getCacheDir(), '.auth-health.json');
128
+ }
129
+ /** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
130
+ export function readAuthHealthCache() {
131
+ try {
132
+ const parsed = JSON.parse(fs.readFileSync(cacheFilePath(), 'utf-8'));
133
+ if (parsed && parsed.entries && typeof parsed.entries === 'object')
134
+ return parsed.entries;
135
+ }
136
+ catch {
137
+ // missing or corrupt — treat as empty
138
+ }
139
+ return {};
140
+ }
141
+ /** Read one entry, or null. */
142
+ export function readAuthHealth(host, agent, version) {
143
+ return readAuthHealthCache()[authCacheKey(host, agent, version)] ?? null;
144
+ }
145
+ /**
146
+ * Merge entries into the cache. An incoming `error` verdict (a network blip,
147
+ * not a server rejection) is indeterminate, so it must NOT clobber a prior
148
+ * known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
149
+ * exactly the "cry wolf" the verdict model avoids for `expired`. This is the
150
+ * behaviour promised by the `error` doc on AuthVerdict ("keep the last known
151
+ * one"). Pure, so it's unit-tested directly. */
152
+ export function mergeAuthHealthEntries(current, incoming) {
153
+ const merged = { ...current };
154
+ for (const [key, health] of Object.entries(incoming)) {
155
+ if (health.verdict === 'error' && merged[key])
156
+ continue; // keep last known
157
+ merged[key] = health;
158
+ }
159
+ return merged;
160
+ }
161
+ /** Merge one or more entries into the cache (best-effort write). */
162
+ export function writeAuthHealthEntries(entries) {
163
+ try {
164
+ const dir = getCacheDir();
165
+ if (!fs.existsSync(dir))
166
+ fs.mkdirSync(dir, { recursive: true });
167
+ const merged = {
168
+ version: 1,
169
+ entries: mergeAuthHealthEntries(readAuthHealthCache(), entries),
170
+ };
171
+ fs.writeFileSync(cacheFilePath(), JSON.stringify(merged, null, 2));
172
+ }
173
+ catch {
174
+ // best-effort; a failed write just means the next reader falls back to heuristics
175
+ }
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // The probe (writer side)
179
+ // ---------------------------------------------------------------------------
180
+ /**
181
+ * Complete a live auth probe for one (agent, home). For claude/kimi/droid this
182
+ * hits the provider; for everyone else it reports a best-effort local verdict
183
+ * (`unverified` when a credential is present, `unconfigured` otherwise) — never
184
+ * masquerading as `live`.
185
+ */
186
+ export async function probeAuthHealth(agent, home, opts) {
187
+ const checkedAt = Date.now();
188
+ if (LIVE_PROBE_AGENTS.has(agent)) {
189
+ let probe;
190
+ if (agent === 'claude')
191
+ probe = await probeClaudeStatus(home, opts?.cliVersion);
192
+ else if (agent === 'kimi')
193
+ probe = await probeKimiStatus(home);
194
+ else
195
+ probe = await probeDroidStatus(home);
196
+ return { verdict: verdictFromProbe(probe), checkedAt, detail: probeDetail(probe) };
197
+ }
198
+ const info = opts?.info !== undefined ? opts.info : await getAccountInfo(agent, home).catch(() => null);
199
+ return { verdict: info?.signedIn ? 'unverified' : 'unconfigured', checkedAt };
200
+ }
201
+ /**
202
+ * Enumerate every installed (agent, version) on THIS host, probe each in
203
+ * parallel, and return the rows (installs with no credential at all are
204
+ * dropped). Shared by `agents fleet ping --local` and the daemon refresh.
205
+ */
206
+ export async function probeLocalFleetAuth(opts) {
207
+ const agentIds = opts?.agents ?? ALL_AGENT_IDS;
208
+ const tasks = [];
209
+ for (const agent of agentIds) {
210
+ for (const version of listInstalledVersions(agent)) {
211
+ const home = getVersionHomePath(agent, version);
212
+ tasks.push((async () => {
213
+ const info = await getAccountInfo(agent, home).catch(() => null);
214
+ const health = await probeAuthHealth(agent, home, { cliVersion: opts?.cliVersion, info });
215
+ health.account = authAccountLabel(info);
216
+ if (health.verdict === 'unconfigured')
217
+ return null;
218
+ return { agent, version, account: health.account, health };
219
+ })());
220
+ }
221
+ }
222
+ const settled = await Promise.all(tasks);
223
+ return settled.filter((r) => r !== null);
224
+ }
225
+ /** Persist a host's probed rows into the cache (keyed by host+agent+version). */
226
+ export function writeFleetAuthRows(host, rows) {
227
+ const entries = {};
228
+ for (const row of rows) {
229
+ entries[authCacheKey(host, row.agent, row.version)] = row.health;
230
+ }
231
+ writeAuthHealthEntries(entries);
232
+ }
@@ -24,6 +24,15 @@ export declare function findFirstInstalledBrowser(platform?: string): {
24
24
  browserType: BrowserType;
25
25
  binary: string;
26
26
  } | null;
27
+ /**
28
+ * List every installed Chromium-family browser on this machine, in the platform
29
+ * priority order. Used by `agents setup browser` to offer a pick when more than
30
+ * one is present. Returns [] if none are installed.
31
+ */
32
+ export declare function listInstalledBrowsers(platform?: string): {
33
+ browserType: BrowserType;
34
+ binary: string;
35
+ }[];
27
36
  export interface LaunchResult {
28
37
  pid: number;
29
38
  port: number;
@@ -203,6 +203,28 @@ export function findFirstInstalledBrowser(platform = os.platform()) {
203
203
  }
204
204
  return null;
205
205
  }
206
+ /**
207
+ * List every installed Chromium-family browser on this machine, in the platform
208
+ * priority order. Used by `agents setup browser` to offer a pick when more than
209
+ * one is present. Returns [] if none are installed.
210
+ */
211
+ export function listInstalledBrowsers(platform = os.platform()) {
212
+ const priority = DEFAULT_BROWSER_PRIORITY[platform];
213
+ const platformPaths = BROWSER_PATHS[platform];
214
+ if (!priority || !platformPaths)
215
+ return [];
216
+ const found = [];
217
+ for (const browserType of priority) {
218
+ const candidates = platformPaths[browserType] || [];
219
+ for (const p of candidates) {
220
+ if (fs.existsSync(p)) {
221
+ found.push({ browserType, binary: resolveBrowserBinary(p) });
222
+ break; // one install path per browser type is enough
223
+ }
224
+ }
225
+ }
226
+ return found;
227
+ }
206
228
  export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary,
207
229
  // `electron: true` distinguishes Notion / VS Code-style apps from
208
230
  // regular Chrome — purely informational, stored in meta.json so the
@@ -228,27 +228,6 @@ function hasSshEndpoint(endpoints) {
228
228
  }
229
229
  });
230
230
  }
231
- /**
232
- * True when any endpoint is an `ssh://…?os=windows` target — i.e. the browser
233
- * lives on a remote Windows host. Such a profile's binary (`msedge.exe`) will
234
- * never exist on this Mac, so create-time local-binary validation must be
235
- * skipped; the binary is resolved on the remote at connect time instead.
236
- */
237
- function hasRemoteWindowsEndpoint(endpoints) {
238
- const targets = Array.isArray(endpoints)
239
- ? endpoints
240
- : Object.values(endpoints).map((preset) => preset.target);
241
- return targets.some((target) => {
242
- try {
243
- const url = new URL(target);
244
- return (url.protocol === 'ssh:' &&
245
- (url.searchParams.get('os') || '').toLowerCase() === 'windows');
246
- }
247
- catch {
248
- return false;
249
- }
250
- });
251
- }
252
231
  export async function createProfile(profile) {
253
232
  const meta = readMeta();
254
233
  if (meta.browser?.[profile.name]) {
@@ -276,10 +255,10 @@ export async function createProfile(profile) {
276
255
  // deferring the failure to the first task. `findBrowserPath` short-circuits
277
256
  // for browser=custom without a binary by throwing — same outcome.
278
257
  //
279
- // Skip for remote-Windows profiles: the browser is `msedge.exe` on the
280
- // remote box, never on this Mac, so a local lookup would always (wrongly)
281
- // fail. The remote launcher resolves it at connect time via App Paths.
282
- if (!hasRemoteWindowsEndpoint(profile.endpoints)) {
258
+ // Skip for SSH profiles: the browser binary lives on the remote host, so a
259
+ // local lookup would validate the wrong machine. The remote launcher resolves
260
+ // it at connect time.
261
+ if (!hasSshEndpoint(profile.endpoints)) {
283
262
  findBrowserPath(profile.browser, profile.binary);
284
263
  }
285
264
  meta.browser = meta.browser ?? {};
@@ -0,0 +1,23 @@
1
+ export declare const BUN_VIRTUAL_ROOT: RegExp;
2
+ export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
3
+ /**
4
+ * A CLI entry must be launched through the Node runtime when it is a Node
5
+ * script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
6
+ * shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
7
+ * (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
8
+ * extension check alone misses them and they get run directly. A real compiled
9
+ * binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the direct branch
10
+ * and owns its own runtime resolution.
11
+ */
12
+ export declare function isNodeScriptEntry(agentsBin: string): boolean;
13
+ /**
14
+ * Build the `{ command, args }` to re-invoke this CLI with `sub` as its argv,
15
+ * resolving the JS-vs-standalone shape above. This is the single primitive behind
16
+ * both the daemon launch and the secrets-broker spawn — never hand-roll
17
+ * `[process.execPath, process.argv[1], …]`, which appends the bun virtual entry
18
+ * as a bogus subcommand on standalone builds.
19
+ */
20
+ export declare function getCliLaunch(sub: string[], agentsBin?: string): {
21
+ command: string;
22
+ args: string[];
23
+ };