@phnx-labs/agents-cli 1.20.68 → 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 (48) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +11 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer.d.ts +26 -0
  5. package/dist/commands/computer.js +173 -149
  6. package/dist/commands/exec.d.ts +18 -0
  7. package/dist/commands/exec.js +88 -6
  8. package/dist/commands/fork.d.ts +9 -0
  9. package/dist/commands/fork.js +67 -0
  10. package/dist/commands/run-account-picker.d.ts +14 -0
  11. package/dist/commands/run-account-picker.js +131 -0
  12. package/dist/commands/setup-browser.d.ts +18 -0
  13. package/dist/commands/setup-browser.js +142 -0
  14. package/dist/commands/setup-computer.d.ts +19 -0
  15. package/dist/commands/setup-computer.js +133 -0
  16. package/dist/commands/setup-share.d.ts +17 -0
  17. package/dist/commands/setup-share.js +85 -0
  18. package/dist/commands/setup.d.ts +1 -1
  19. package/dist/commands/setup.js +58 -1
  20. package/dist/commands/share.d.ts +15 -0
  21. package/dist/commands/share.js +10 -4
  22. package/dist/commands/ssh.js +164 -0
  23. package/dist/commands/view.d.ts +3 -14
  24. package/dist/commands/view.js +27 -28
  25. package/dist/index.js +2 -1
  26. package/dist/lib/agents.d.ts +10 -0
  27. package/dist/lib/agents.js +32 -0
  28. package/dist/lib/auth-health.d.ts +103 -0
  29. package/dist/lib/auth-health.js +232 -0
  30. package/dist/lib/browser/chrome.d.ts +9 -0
  31. package/dist/lib/browser/chrome.js +22 -0
  32. package/dist/lib/computer/download.d.ts +51 -0
  33. package/dist/lib/computer/download.js +145 -0
  34. package/dist/lib/computer-rpc.js +9 -3
  35. package/dist/lib/devices/connect.d.ts +15 -0
  36. package/dist/lib/devices/connect.js +17 -5
  37. package/dist/lib/devices/terminfo.d.ts +44 -0
  38. package/dist/lib/devices/terminfo.js +167 -0
  39. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  40. package/dist/lib/rotate.d.ts +12 -8
  41. package/dist/lib/rotate.js +22 -23
  42. package/dist/lib/session/fork.d.ts +32 -0
  43. package/dist/lib/session/fork.js +101 -0
  44. package/dist/lib/startup/command-registry.d.ts +1 -0
  45. package/dist/lib/startup/command-registry.js +2 -0
  46. package/dist/lib/usage.d.ts +23 -0
  47. package/dist/lib/usage.js +84 -0
  48. package/package.json +1 -1
@@ -4,8 +4,10 @@ import { visibleWidth, termLink } from '../lib/format.js';
4
4
  import ora from 'ora';
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
- import { AGENTS, ALL_AGENT_IDS, accountOrgBadge, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
7
+ import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
8
8
  import { loginHint } from '../lib/signin-badge.js';
9
+ import { machineId } from '../lib/machine-id.js';
10
+ import { authCacheKey, formatCheckedAge, readAuthHealthCache } from '../lib/auth-health.js';
9
11
  import { agentReportsUsage, deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
10
12
  import { readManifest } from '../lib/manifest.js';
11
13
  import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, } from '../lib/versions.js';
@@ -26,33 +28,8 @@ import { loadManifest, isStale } from '../lib/staleness/index.js';
26
28
  import { confirm } from '@inquirer/prompts';
27
29
  import { formatPath, isInteractiveTerminal, isPromptCancelled } from './utils.js';
28
30
  import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
29
- // Shown in the email column for agents that are signed in but expose no email
30
- // address locally (Antigravity stores an opaque OAuth grant with no identity).
31
- const SIGNED_IN_LABEL = 'signed in';
32
- /**
33
- * Text for the account (email) column. Prefers the real email; otherwise, for a
34
- * signed-in agent whose credential carries no email but does carry an opaque
35
- * account id (Kimi's user_id), show `id:<user_id>` so distinct accounts read
36
- * distinctly instead of a generic "signed in". Falls back to "signed in" when we
37
- * have neither (Antigravity), and empty when signed out.
38
- *
39
- * When the account carries a Claude organizationType, the org badge is appended
40
- * — "email (Turing Labs · Team)" — so two installs signed into the same email
41
- * under different orgs (personal Max vs a Team seat) read distinctly. The
42
- * suffix is plain text inside the label so the existing column-width pass
43
- * measures and pads it for free.
44
- */
45
- export function accountColumnLabel(info) {
46
- if (!info)
47
- return '';
48
- if (info.email) {
49
- const badge = accountOrgBadge(info);
50
- return badge ? `${info.email} (${badge})` : info.email;
51
- }
52
- if (info.signedIn)
53
- return info.accountId ? `id:${info.accountId}` : SIGNED_IN_LABEL;
54
- return '';
55
- }
31
+ /** Shared account identity formatter, re-exported for the view-specific tests. */
32
+ export const accountColumnLabel = accountDisplayLabel;
56
33
  /**
57
34
  * Group profile summaries by their host harness, optionally filtered to a
58
35
  * single agent. Profile YAMLs that fail validation are silently skipped by
@@ -245,6 +222,22 @@ function renderHostClisSection(cwd) {
245
222
  console.log(` ${chalk.red('error')} ${chalk.gray(err.file)}: ${chalk.gray(err.reason)}`);
246
223
  }
247
224
  }
225
+ /**
226
+ * A compact live-auth chip for a version row, read from the fleet auth-health
227
+ * cache (written by `agents fleet ping` / the daemon). Empty when the install
228
+ * hasn't been probed — this is the local "signed in" flag's ground-truth
229
+ * companion: ● live, ○ revoked (re-login), ◐ unverified/limited, plus its age.
230
+ */
231
+ function liveAuthChip(cache, host, agentId, version) {
232
+ const h = cache[authCacheKey(host, agentId, version)];
233
+ if (!h)
234
+ return '';
235
+ const glyph = h.verdict === 'live' ? chalk.green('●')
236
+ : h.verdict === 'revoked' ? chalk.red('○')
237
+ : h.verdict === 'expired' ? chalk.yellow('○')
238
+ : chalk.yellow('◐');
239
+ return `${glyph} ${chalk.gray(formatCheckedAge(h.checkedAt))}`;
240
+ }
248
241
  async function showInstalledVersions(filterAgentId, viewOpts) {
249
242
  const spinnerText = filterAgentId
250
243
  ? `Checking ${agentLabel(filterAgentId)} agents...`
@@ -276,6 +269,9 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
276
269
  }
277
270
  // Shim healing is silent — users don't need to know about internal repairs
278
271
  console.log(chalk.bold('Installed Agent CLIs\n'));
272
+ const selfHost = machineId();
273
+ // Read the auth-health cache once (not per version row — see the batching note above).
274
+ const authCache = readAuthHealthCache();
279
275
  // Pre-fetch account info for all versions in parallel
280
276
  const infoFetches = [];
281
277
  const globalInfoFetches = [];
@@ -500,6 +496,9 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
500
496
  if (runDefaultBits.length > 0) {
501
497
  parts.push(chalk.gray(`run ${runDefaultBits.join(' ')}`));
502
498
  }
499
+ const authChip = liveAuthChip(authCache, selfHost, agentId, version);
500
+ if (authChip)
501
+ parts.push(authChip);
503
502
  console.log(parts.join(' '));
504
503
  if (showPaths) {
505
504
  const versionDir = getVersionDir(agentId, version);
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);
@@ -148,6 +148,16 @@ export declare function formatClaudeOrgLabel(orgType: string | null | undefined)
148
148
  * organizationType (signed out, non-Claude agents, configs predating the field).
149
149
  */
150
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;
151
161
  /** Return the email address associated with the agent's auth config, or null. */
152
162
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
153
163
  /** Decrypted contents of Droid's auth.v2.file (subset we consume). */
@@ -992,6 +992,38 @@ export function accountOrgBadge(info) {
992
992
  return info.organizationName;
993
993
  return null;
994
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 '';
1026
+ }
995
1027
  /** Return the email address associated with the agent's auth config, or null. */
996
1028
  export async function getAccountEmail(agentId, home) {
997
1029
  const info = await getAccountInfo(agentId, home);
@@ -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
@@ -0,0 +1,51 @@
1
+ /**
2
+ * On-demand download + verification of the macOS `agents computer` helper
3
+ * ("ComputerHelper.app").
4
+ *
5
+ * The helper is a signed + notarized universal `.app` bundle published as a
6
+ * GitHub release asset per tagged CLI version — the same distribution model as
7
+ * the Windows helper (see `lib/ssh-tunnel.ts`). A fresh `npm i -g` machine has
8
+ * no local build, so `agents computer setup` / `agents setup computer` fetch the
9
+ * asset for the running CLI version, verify its sha256 against the published
10
+ * `.sha256`, then verify the code signature (Developer ID Team + notarization)
11
+ * before it is ever copied to /Applications.
12
+ *
13
+ * A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
14
+ * we extract it with `ditto -x -k` after the checksum passes.
15
+ */
16
+ /** GitHub repo whose `v<version>` releases carry the helper asset. */
17
+ export declare const HELPER_RELEASE_REPO = "phnx-labs/agents-cli";
18
+ /** The zipped `.app` release asset name. */
19
+ export declare const MAC_HELPER_ASSET = "ComputerHelper.app.zip";
20
+ /** The bundle directory name once extracted. */
21
+ export declare const MAC_HELPER_APP_NAME = "ComputerHelper.app";
22
+ /** Apple Developer ID Team the helper must be signed by (defense in depth on top
23
+ * of `spctl` notarization assessment). "Developer ID Application: Muqit Nawaz". */
24
+ export declare const EXPECTED_TEAM_ID = "2HTP252L87";
25
+ /** Cache dir for the downloaded helper, one subdir per release tag. */
26
+ export declare function macHelperCacheDir(version: string): string;
27
+ /** Release-asset URLs for the helper zip + its checksum at one `v<version>` tag. */
28
+ export declare function macHelperAssetUrls(version: string): {
29
+ zip: string;
30
+ sha256: string;
31
+ };
32
+ /**
33
+ * Verify a helper `.app` bundle is intact, signed by the expected Developer ID
34
+ * Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
35
+ * on any failure — a downloaded bundle is never trusted without this.
36
+ */
37
+ export declare function verifyMacHelper(appPath: string): void;
38
+ /**
39
+ * Download the helper release asset for `version`, verify sha256, extract the
40
+ * `.app`, and verify its signature. Returns the path to the extracted
41
+ * `ComputerHelper.app`. A missing asset is a hard error naming the exact tag —
42
+ * never a silent fallback to another release.
43
+ */
44
+ export declare function downloadMacHelperApp(version: string): Promise<string>;
45
+ /**
46
+ * Resolve the helper `.app` to install from: a local build / bundled copy first
47
+ * (repo checkout), else the checksum + signature-verified release-asset download
48
+ * for the running CLI version. Throws with the tag it checked when neither
49
+ * exists. macOS only.
50
+ */
51
+ export declare function ensureMacHelperApp(version?: string): Promise<string>;