@phnx-labs/agents-cli 1.22.29 → 1.22.31

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 (82) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +44 -5
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.d.ts +13 -0
  5. package/dist/commands/accounts.js +32 -0
  6. package/dist/commands/daemon.d.ts +18 -0
  7. package/dist/commands/daemon.js +581 -0
  8. package/dist/commands/exec.js +66 -20
  9. package/dist/commands/focus.d.ts +4 -1
  10. package/dist/commands/focus.js +19 -4
  11. package/dist/commands/routines.js +29 -11
  12. package/dist/commands/secrets.d.ts +37 -0
  13. package/dist/commands/secrets.js +86 -105
  14. package/dist/commands/sessions-bookmark.d.ts +20 -0
  15. package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +42 -42
  16. package/dist/commands/sessions-browser.d.ts +10 -8
  17. package/dist/commands/sessions-browser.js +61 -32
  18. package/dist/commands/sessions-picker.d.ts +33 -1
  19. package/dist/commands/sessions-picker.js +102 -27
  20. package/dist/commands/sessions-stats.js +1 -1
  21. package/dist/commands/sessions.d.ts +21 -8
  22. package/dist/commands/sessions.js +328 -74
  23. package/dist/commands/view.d.ts +11 -0
  24. package/dist/commands/view.js +56 -29
  25. package/dist/index.js +37 -2
  26. package/dist/lib/account-labels.d.ts +24 -0
  27. package/dist/lib/account-labels.js +72 -0
  28. package/dist/lib/agents.d.ts +32 -1
  29. package/dist/lib/agents.js +96 -31
  30. package/dist/lib/daemon-health.d.ts +24 -0
  31. package/dist/lib/daemon-health.js +84 -0
  32. package/dist/lib/daemon-ticks.d.ts +81 -0
  33. package/dist/lib/daemon-ticks.js +190 -0
  34. package/dist/lib/daemon.d.ts +68 -18
  35. package/dist/lib/daemon.js +303 -338
  36. package/dist/lib/device-config.d.ts +10 -0
  37. package/dist/lib/device-config.js +27 -0
  38. package/dist/lib/exec.d.ts +27 -0
  39. package/dist/lib/exec.js +49 -2
  40. package/dist/lib/hosts/dispatch.d.ts +4 -0
  41. package/dist/lib/hosts/dispatch.js +4 -0
  42. package/dist/lib/hosts/remote-cmd.js +1 -0
  43. package/dist/lib/hosts/run-target.d.ts +1 -0
  44. package/dist/lib/hosts/run-target.js +1 -0
  45. package/dist/lib/import.js +7 -6
  46. package/dist/lib/memory-cache.d.ts +19 -0
  47. package/dist/lib/memory-cache.js +31 -0
  48. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  50. package/dist/lib/migrate.d.ts +1 -1
  51. package/dist/lib/migrate.js +13 -2
  52. package/dist/lib/picker.d.ts +6 -3
  53. package/dist/lib/picker.js +7 -2
  54. package/dist/lib/routine-activation.d.ts +2 -0
  55. package/dist/lib/routine-activation.js +16 -0
  56. package/dist/lib/runner.d.ts +18 -0
  57. package/dist/lib/runner.js +52 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/agent.d.ts +19 -1
  61. package/dist/lib/secrets/agent.js +32 -6
  62. package/dist/lib/secrets/scope.d.ts +3 -3
  63. package/dist/lib/secrets/scope.js +3 -3
  64. package/dist/lib/secrets/session-store.d.ts +0 -4
  65. package/dist/lib/secrets/session-store.js +0 -5
  66. package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +15 -15
  67. package/dist/lib/session/{favorites.js → bookmarks.js} +23 -23
  68. package/dist/lib/session/db.d.ts +15 -0
  69. package/dist/lib/session/db.js +90 -15
  70. package/dist/lib/session/discover.js +91 -39
  71. package/dist/lib/session/parse.d.ts +63 -0
  72. package/dist/lib/session/parse.js +165 -20
  73. package/dist/lib/session/session-cache.d.ts +9 -6
  74. package/dist/lib/session/session-cache.js +23 -6
  75. package/dist/lib/shims.js +12 -0
  76. package/dist/lib/startup/command-registry.d.ts +15 -1
  77. package/dist/lib/startup/command-registry.js +49 -0
  78. package/dist/lib/usage-refresh.js +3 -2
  79. package/dist/lib/usage.d.ts +12 -10
  80. package/dist/lib/usage.js +63 -144
  81. package/package.json +4 -1
  82. package/dist/commands/sessions-favorite.d.ts +0 -20
@@ -15,6 +15,17 @@ import { type ProfileSummary } from '../lib/profiles.js';
15
15
  import { type ByokUsageResult } from '../lib/byok-usage.js';
16
16
  /** Shared account identity formatter, re-exported for the view-specific tests. */
17
17
  export declare const accountColumnLabel: typeof accountDisplayLabel;
18
+ export interface AccountOrderedVersion {
19
+ version: string;
20
+ email: string | null;
21
+ }
22
+ /**
23
+ * Human `agents view` row order: selected default first, then email-bearing
24
+ * accounts alphabetically, then installs whose account has no email. Version
25
+ * descending is the deterministic tie-breaker and preserves the old order for
26
+ * every non-email harness.
27
+ */
28
+ export declare function compareAccountOrderedVersions(a: AccountOrderedVersion, b: AccountOrderedVersion, globalDefault: string | null): number;
18
29
  /**
19
30
  * Join fixed view columns with a consistent two-space gutter. Empty trailing
20
31
  * columns are dropped so a row without an auth chip does not grow a dangling
@@ -33,6 +33,30 @@ import { formatPath, isInteractiveTerminal, isPromptCancelled } from './utils.js
33
33
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
34
34
  /** Shared account identity formatter, re-exported for the view-specific tests. */
35
35
  export const accountColumnLabel = accountDisplayLabel;
36
+ /**
37
+ * Human `agents view` row order: selected default first, then email-bearing
38
+ * accounts alphabetically, then installs whose account has no email. Version
39
+ * descending is the deterministic tie-breaker and preserves the old order for
40
+ * every non-email harness.
41
+ */
42
+ export function compareAccountOrderedVersions(a, b, globalDefault) {
43
+ const aIsDefault = a.version === globalDefault;
44
+ const bIsDefault = b.version === globalDefault;
45
+ if (aIsDefault !== bIsDefault)
46
+ return aIsDefault ? -1 : 1;
47
+ const aEmail = a.email?.toLowerCase() ?? null;
48
+ const bEmail = b.email?.toLowerCase() ?? null;
49
+ if (aEmail !== null && bEmail === null)
50
+ return -1;
51
+ if (aEmail === null && bEmail !== null)
52
+ return 1;
53
+ if (aEmail !== null && bEmail !== null) {
54
+ const emailOrder = aEmail.localeCompare(bEmail);
55
+ if (emailOrder !== 0)
56
+ return emailOrder;
57
+ }
58
+ return compareVersions(b.version, a.version);
59
+ }
36
60
  /**
37
61
  * Overview (`agents view` with no agent filter) caps compact usage windows so
38
62
  * multi-meter agents (Antigravity's four model quotas, Droid's three buckets)
@@ -56,15 +80,12 @@ export function joinViewColumns(cols) {
56
80
  return cols.slice(0, end).join(' ');
57
81
  }
58
82
  /**
59
- * Custom harnesses (the `~/.agents/profiles/*.yml` bundles), sorted by name and
60
- * optionally narrowed to the ones that run on one host agent. YAMLs that fail
61
- * validation are silently skipped by `listProfiles`, so this never throws on a
62
- * malformed file.
83
+ * Custom harnesses (the `~/.agents/profiles/*.yml` bundles), sorted by name.
84
+ * YAMLs that fail validation are silently skipped by `listProfiles`, so this
85
+ * never throws on a malformed file.
63
86
  */
64
- function getHarnesses(filterAgentId) {
65
- return listProfiles()
66
- .filter((profile) => !filterAgentId || profile.host.agent === filterAgentId)
67
- .map(profileSummary);
87
+ function getHarnesses() {
88
+ return listProfiles().map(profileSummary);
68
89
  }
69
90
  /** Version-first label: "<version> (forked from <host>[, tracks default])" */
70
91
  function harnessVersionLabel(harness, globalDefault) {
@@ -307,7 +328,9 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
307
328
  .filter((agentId) => !hasNonIsolatedVersion(agentId))
308
329
  .map(async (agentId) => [agentId, await getUnmanagedCliState(agentId)])));
309
330
  const showPaths = !!filterAgentId;
310
- const harnesses = getHarnesses(filterAgentId);
331
+ // A filtered native view is about that native harness's installed versions.
332
+ // Custom forks are standalone agent types and only belong in the overview.
333
+ const harnesses = filterAgentId ? [] : getHarnesses();
311
334
  // Auto-heal stale versioned aliases. Pre-v2 aliases (e.g. pre-CLAUDE_CONFIG_DIR
312
335
  // claude shims) silently route login through the default version's symlinked
313
336
  // home, so `agents view` would never reflect the right account. Regenerate on
@@ -524,14 +547,16 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
524
547
  ? chalk.yellow(' (no default)')
525
548
  : '';
526
549
  console.log(` ${chalk.bold(agentLabel(agentId))}${strategyLabel}${noDefaultLabel}`);
527
- // Sort versions with default first, then by semver descending
528
- const sortedVersions = [...versions].sort((a, b) => {
529
- if (a === globalDefault)
530
- return -1;
531
- if (b === globalDefault)
532
- return 1;
533
- return compareVersions(b, a);
534
- });
550
+ // Account information is already loaded above. Keep the selected default
551
+ // first, then make multi-account installs scannable by email. Harnesses
552
+ // without email identities retain their prior version-descending order.
553
+ const sortedVersions = versions
554
+ .map((version) => ({
555
+ version,
556
+ email: infoMap.get(`${agentId}:${version}`)?.email ?? null,
557
+ }))
558
+ .sort((a, b) => compareAccountOrderedVersions(a, b, globalDefault))
559
+ .map(({ version }) => version);
535
560
  for (const version of sortedVersions) {
536
561
  const isDefault = version === globalDefault;
537
562
  const isolated = !isDefault && isVersionIsolated(agentId, version);
@@ -1273,7 +1298,9 @@ export async function collectAgentsJson(filterAgentId, resourceSections) {
1273
1298
  else
1274
1299
  byAgent.set(agentId, [entry]);
1275
1300
  }
1276
- const harnesses = getHarnesses(filterAgentId);
1301
+ // Keep filtered native JSON consistent with the text view: custom forks are
1302
+ // not children of the native harness they execute through.
1303
+ const harnesses = filterAgentId ? [] : getHarnesses();
1277
1304
  const out = [];
1278
1305
  for (const agentId of agentsToShow) {
1279
1306
  const versions = byAgent.get(agentId) ?? [];
@@ -1544,19 +1571,19 @@ export async function viewAction(agentArg, options) {
1544
1571
  // Parse agent@version syntax
1545
1572
  const parts = agentArg.split('@');
1546
1573
  const agentName = parts[0];
1547
- const agentId = resolveAgentName(agentName);
1548
- if (!agentId) {
1549
- // A custom harness is an agent type here, not an unknown name: `agents run
1550
- // <name>` launches it, so `agents view <name>` describes it.
1551
- if (profileExists(agentName)) {
1552
- const harness = profileSummary(readProfile(agentName));
1553
- if (json) {
1554
- console.log(JSON.stringify(harness, null, 2));
1555
- return;
1556
- }
1557
- renderHarnessDetail(agentName);
1574
+ // Match run resolution: an exact custom harness name wins over a native id or
1575
+ // alias with the same spelling, so every fork remains independently viewable.
1576
+ if (profileExists(agentName)) {
1577
+ const harness = profileSummary(readProfile(agentName));
1578
+ if (json) {
1579
+ console.log(JSON.stringify(harness, null, 2));
1558
1580
  return;
1559
1581
  }
1582
+ renderHarnessDetail(agentName);
1583
+ return;
1584
+ }
1585
+ const agentId = resolveAgentName(agentName);
1586
+ if (!agentId) {
1560
1587
  if (json) {
1561
1588
  console.log(JSON.stringify({ error: formatAgentError(agentName) }));
1562
1589
  process.exit(1);
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadDaemon, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadAccounts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -120,6 +120,22 @@ if (process.argv[2] === '__daemon-run') {
120
120
  await runDaemon();
121
121
  process.exit(process.exitCode ?? 0);
122
122
  }
123
+ // One-shot invocation of a migrated daemon housekeeping tick (RUSH-2353). The
124
+ // shipped system routines (`watchdog`, `device-probe`, `fleet-cache-warm`, ...)
125
+ // run this as their `command:` instead of the daemon holding a setInterval —
126
+ // same tick body, now scheduled/tracked/pinnable through the routines system.
127
+ if (process.argv[2] === '__daemon-tick') {
128
+ const name = process.argv[3] || '';
129
+ const { runDaemonTick } = await import('./lib/daemon-ticks.js');
130
+ try {
131
+ await runDaemonTick(name);
132
+ process.exit(0);
133
+ }
134
+ catch (err) {
135
+ process.stderr.write(`[agents] daemon tick '${name}' failed: ${err.message}\n`);
136
+ process.exit(1);
137
+ }
138
+ }
123
139
  // White-label: the shim for a brand (e.g. `jack`) exports AGENTS_BRAND, so the
124
140
  // CLI presents its own name/help/errors as the brand. Unbranded (AGENTS_BRAND
125
141
  // unset) resolves to 'agents' and everything below is byte-identical to before.
@@ -303,6 +319,7 @@ Run and dispatch:
303
319
  defaults Configure run defaults by agent/version selector
304
320
  teams Coordinate multiple agents on shared work
305
321
  routines Run agents on a cron schedule (scheduler auto-starts)
322
+ daemon Runtime status/control for the always-on daemon (secrets broker, browser IPC, scheduler)
306
323
  webhook Receive signed GitHub/Linear webhooks for trigger routines
307
324
  funnel Expose a webhook receiver through Tailscale Funnel
308
325
  sessions Browse, search, and replay past runs (live-search in TTY; grouped by workspace)
@@ -929,6 +946,7 @@ async function registerAllEagerCommands() {
929
946
  await reg(loadExport);
930
947
  await reg(loadPackages);
931
948
  await reg(loadRoutines);
949
+ await reg(loadDaemon);
932
950
  await reg(loadMonitors);
933
951
  await reg(loadProjects);
934
952
  await reg(loadRun);
@@ -980,6 +998,7 @@ async function registerAllEagerCommands() {
980
998
  await reg(loadWebhook);
981
999
  await reg(loadFunnel);
982
1000
  await reg(loadHumans);
1001
+ await reg(loadAccounts);
983
1002
  registerHqTombstoneCommand(program);
984
1003
  await reg(loadFeed);
985
1004
  await reg(loadMailboxes);
@@ -1060,6 +1079,22 @@ program.on('command:*', (operands) => {
1060
1079
  // and the doc flags (--version/--help/-h) drive both the registration strategy
1061
1080
  // and whether the update check + background sync run at all.
1062
1081
  const passedArgs = process.argv.slice(2);
1082
+ // Commander owns `--version` on the root command and otherwise intercepts it
1083
+ // even after `sessions`, before the subcommand can parse its version filter.
1084
+ // Rewrite only that value-taking nested form; bare `agents --version` and every
1085
+ // other command retain the root documentation flag unchanged.
1086
+ if (passedArgs[0] === 'sessions') {
1087
+ const nestedVersionIndex = passedArgs.indexOf('--version', 1);
1088
+ if (nestedVersionIndex >= 0) {
1089
+ const nestedVersion = passedArgs[nestedVersionIndex + 1];
1090
+ if (!nestedVersion || nestedVersion.startsWith('-')) {
1091
+ console.error("error: option '--version <version>' argument missing");
1092
+ process.exit(1);
1093
+ }
1094
+ passedArgs[nestedVersionIndex] = '--session-version';
1095
+ process.argv[nestedVersionIndex + 2] = '--session-version';
1096
+ }
1097
+ }
1063
1098
  const requestedCommand = passedArgs.find((arg) => !arg.startsWith('-'));
1064
1099
  const verboseStartup = passedArgs.includes('--verbose');
1065
1100
  // Help and version output are pure documentation — they must never gate on
@@ -1212,7 +1247,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
1212
1247
  // Bumping the suffix re-runs migrations for every user; binary releases that
1213
1248
  // don't change the schema must NOT re-run (they would destroy user content
1214
1249
  // when migration steps overlap with user-authored paths). See issue #20.
1215
- const sentinelValue = 'v15';
1250
+ const sentinelValue = 'v18';
1216
1251
  let needRun = true;
1217
1252
  try {
1218
1253
  if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
@@ -0,0 +1,24 @@
1
+ import type { AgentId } from './types.js';
2
+ export interface AccountLabel {
3
+ agent: AgentId;
4
+ fingerprint: string;
5
+ }
6
+ export interface AccountLabelsDocument {
7
+ labels: Record<string, AccountLabel>;
8
+ }
9
+ export interface DiscoveredAccount {
10
+ agent: AgentId;
11
+ fingerprint: string;
12
+ display: string;
13
+ versions: string[];
14
+ label: string | null;
15
+ }
16
+ export declare function identityFingerprint(agent: string, accountKey: string): string;
17
+ export declare function accountLabelsPath(base?: string): string;
18
+ export declare function readAccountLabels(base?: string): AccountLabelsDocument;
19
+ export declare function nameAccount(label: string, agent: AgentId, fingerprint: string, base?: string): void;
20
+ export declare function renameAccountLabel(oldLabel: string, newLabel: string, base?: string): void;
21
+ export declare function removeAccountLabel(label: string, base?: string): void;
22
+ export declare function labelForFingerprint(agent: AgentId, fingerprint: string, doc?: AccountLabelsDocument): string | null;
23
+ export declare function discoverAccounts(agentIds?: readonly AgentId[]): Promise<DiscoveredAccount[]>;
24
+ export declare function resolveAccountLabel(agent: AgentId, label: string): Promise<string>;
@@ -0,0 +1,72 @@
1
+ import * as crypto from 'crypto';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as yaml from 'yaml';
5
+ import { atomicWriteFileSync } from './fs-atomic.js';
6
+ import { getUserAgentsDir } from './state.js';
7
+ import { ACCOUNT_INSPECTION_AGENT_IDS } from './agents.js';
8
+ import { collectRunCandidates, pickBalancedCandidate } from './rotate.js';
9
+ function emptyDocument() { return { labels: {} }; }
10
+ export function identityFingerprint(agent, accountKey) { return crypto.createHash('sha256').update(`${agent}\0${accountKey}`).digest('hex'); }
11
+ export function accountLabelsPath(base = getUserAgentsDir()) { return path.join(base, 'accounts.yaml'); }
12
+ export function readAccountLabels(base = getUserAgentsDir()) {
13
+ const file = accountLabelsPath(base);
14
+ if (!fs.existsSync(file))
15
+ return emptyDocument();
16
+ const value = yaml.parse(fs.readFileSync(file, 'utf8'));
17
+ if (!value || typeof value !== 'object' || Array.isArray(value))
18
+ throw new Error(`Account labels corrupted at ${file}: expected a YAML map.`);
19
+ const doc = value;
20
+ doc.labels ??= {};
21
+ return doc;
22
+ }
23
+ function writeAccountLabels(doc, base = getUserAgentsDir()) { const file = accountLabelsPath(base); fs.mkdirSync(path.dirname(file), { recursive: true }); atomicWriteFileSync(file, yaml.stringify(doc)); }
24
+ function assertLabel(label) { if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(label))
25
+ throw new Error('Label must start with a letter or number and contain only letters, numbers, dot, underscore, or dash.'); }
26
+ export function nameAccount(label, agent, fingerprint, base = getUserAgentsDir()) {
27
+ assertLabel(label);
28
+ const doc = readAccountLabels(base);
29
+ for (const [other, account] of Object.entries(doc.labels))
30
+ if (other !== label && account.agent === agent && account.fingerprint === fingerprint)
31
+ throw new Error(`This ${agent} account is already named '${other}'.`);
32
+ if (doc.labels[label] && (doc.labels[label].agent !== agent || doc.labels[label].fingerprint !== fingerprint))
33
+ throw new Error(`Account label '${label}' already names another account.`);
34
+ doc.labels[label] = { agent, fingerprint };
35
+ writeAccountLabels(doc, base);
36
+ }
37
+ export function renameAccountLabel(oldLabel, newLabel, base = getUserAgentsDir()) { assertLabel(newLabel); const doc = readAccountLabels(base); if (!doc.labels[oldLabel])
38
+ throw new Error(`Unknown account label '${oldLabel}'.`); if (doc.labels[newLabel])
39
+ throw new Error(`Account label '${newLabel}' already exists.`); doc.labels[newLabel] = doc.labels[oldLabel]; delete doc.labels[oldLabel]; writeAccountLabels(doc, base); }
40
+ export function removeAccountLabel(label, base = getUserAgentsDir()) { const doc = readAccountLabels(base); if (!doc.labels[label])
41
+ throw new Error(`Unknown account label '${label}'.`); delete doc.labels[label]; writeAccountLabels(doc, base); }
42
+ export function labelForFingerprint(agent, fingerprint, doc = readAccountLabels()) { return Object.entries(doc.labels).find(([, account]) => account.agent === agent && account.fingerprint === fingerprint)?.[0] ?? null; }
43
+ export async function discoverAccounts(agentIds = ACCOUNT_INSPECTION_AGENT_IDS) {
44
+ const labels = readAccountLabels();
45
+ const grouped = new Map();
46
+ await Promise.all(agentIds.map(async (agent) => {
47
+ for (const candidate of await collectRunCandidates(agent)) {
48
+ if (!candidate.signedIn || !candidate.accountKey)
49
+ continue;
50
+ const fingerprint = identityFingerprint(agent, candidate.accountKey);
51
+ const key = `${agent}:${fingerprint}`;
52
+ const existing = grouped.get(key);
53
+ if (existing)
54
+ existing.versions.push(candidate.version);
55
+ else
56
+ grouped.set(key, { agent, fingerprint, display: candidate.accountLabel || 'signed-in account', versions: [candidate.version], label: labelForFingerprint(agent, fingerprint, labels) });
57
+ }
58
+ }));
59
+ return [...grouped.values()].sort((a, b) => a.agent.localeCompare(b.agent) || a.display.localeCompare(b.display));
60
+ }
61
+ export async function resolveAccountLabel(agent, label) {
62
+ const account = readAccountLabels().labels[label];
63
+ if (!account)
64
+ throw new Error(`Unknown account label '${label}'.`);
65
+ if (account.agent !== agent)
66
+ throw new Error(`Account label '${label}' names a ${account.agent} account, not ${agent}.`);
67
+ const candidates = (await collectRunCandidates(agent)).filter(candidate => candidate.accountKey && identityFingerprint(agent, candidate.accountKey) === account.fingerprint);
68
+ const result = pickBalancedCandidate(candidates);
69
+ if (!result)
70
+ throw new Error(`No healthy installed ${agent} version is currently signed into account '${label}'.`);
71
+ return result.picked.version;
72
+ }
@@ -23,7 +23,18 @@ export declare const GEMINI_HOOKS_MIN_VERSION = "0.26.0";
23
23
  * dispatcher) showed up under `agents view`'s "Not Managed by Agents CLI"
24
24
  * section, even though the user had nothing to import.
25
25
  */
26
- export declare function findInPath(command: string): string | null;
26
+ interface NativeBinaryResolutionOptions {
27
+ shimsDir?: string;
28
+ historyDir?: string;
29
+ }
30
+ /**
31
+ * Resolve a PATH candidate to the immutable native executable agents-cli may
32
+ * safely register. An adopted launcher can live outside the shims directory
33
+ * while resolving back into it; in that case its durable adoption record is
34
+ * the source of truth for the original executable.
35
+ */
36
+ export declare function resolveNativeBinaryPath(command: string, candidate: string, options?: NativeBinaryResolutionOptions): string | null;
37
+ export declare function findInPath(command: string, options?: NativeBinaryResolutionOptions): string | null;
27
38
  /**
28
39
  * Master registry of all supported agents keyed by AgentId.
29
40
  *
@@ -289,6 +300,26 @@ export declare function antigravityOsKeyringProbe(platform?: NodeJS.Platform): {
289
300
  } | null;
290
301
  /** @internal test hook — clear the per-process keyring probe cache. */
291
302
  export declare function __resetAntigravityKeychainCacheForTest(): void;
303
+ /**
304
+ * OpenCode's account identity: the sorted, "+"-joined list of provider ids
305
+ * that hold a valid credential in `auth.json` (e.g. `"anthropic+muse-spark"`).
306
+ * `auth.json` carries no email/identity claim (see `isValidOpenCodeCredential`),
307
+ * so this join is the closest thing to "which account is this" available — the
308
+ * same value `agents view`/`agents doctor` show for OpenCode's signed-in state.
309
+ *
310
+ * This is the ONLY correct source for an OpenCode "account". OpenCode's SQLite
311
+ * `opencode.db` also carries `account`/`account_state`/`control_account` tables,
312
+ * but on a real, actively-used install (yosemite-s1, 1.16.0, 35 applied
313
+ * migrations) all three are permanently empty — no migration ever populates
314
+ * them, and no session has ever written a row. Reading from them instead of
315
+ * `auth.json` always yields `undefined`, credential or not; `session/discover.ts`
316
+ * uses this function rather than duplicating a sqlite lookup against those
317
+ * dead tables.
318
+ *
319
+ * Sync (`fs.readFileSync`), no network. Returns undefined when `auth.json` is
320
+ * missing, unreadable, or carries no valid credential.
321
+ */
322
+ export declare function resolveOpenCodeAccountId(base: string): string | undefined;
292
323
  /**
293
324
  * Whether a Claude version home's credential file is present but carries no
294
325
  * token — the "must have a real credential" floor (see
@@ -21,7 +21,7 @@ import chalk from 'chalk';
21
21
  import { execFileShellSpec } from './platform/index.js';
22
22
  import { latestFileMtimeMs } from './fs-walk.js';
23
23
  import { damerauLevenshtein } from './fuzzy.js';
24
- import { getCacheDir, getVersionsDir, getShimsDir, getCliVersionCachePath } from './state.js';
24
+ import { getCacheDir, getVersionsDir, getShimsDir, getHistoryDir, getCliVersionCachePath } from './state.js';
25
25
  import { resolveVersion, getVersionHomePath, getBinaryPath } from './versions.js';
26
26
  import { supports } from './capabilities.js';
27
27
  const execFileAsync = promisify(execFile);
@@ -61,20 +61,57 @@ function saveCliVersionCache() {
61
61
  /* best-effort cache persist */
62
62
  }
63
63
  }
64
+ function pathIsWithin(candidate, directory) {
65
+ const relative = path.relative(directory, candidate);
66
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
67
+ }
64
68
  /**
65
- * Synchronous PATH search -- no subprocess. Returns first matching binary path.
66
- *
67
- * Skips our own shims dir (`~/.agents/.cache/shims/`) those shims are
68
- * dispatch helpers, not real installs. Counting them as installed produced a
69
- * false positive where agents with NO real binary on the host (e.g. a
70
- * never-installed Cursor whose only PATH entry was our `cursor-agent` shim
71
- * dispatcher) showed up under `agents view`'s "Not Managed by Agents CLI"
72
- * section, even though the user had nothing to import.
69
+ * Resolve a PATH candidate to the immutable native executable agents-cli may
70
+ * safely register. An adopted launcher can live outside the shims directory
71
+ * while resolving back into it; in that case its durable adoption record is
72
+ * the source of truth for the original executable.
73
73
  */
74
- export function findInPath(command) {
74
+ export function resolveNativeBinaryPath(command, candidate, options = {}) {
75
+ const shimsDir = options.shimsDir ?? getShimsDir();
76
+ const historyDir = options.historyDir ?? getHistoryDir();
77
+ let canonicalCandidate;
78
+ try {
79
+ canonicalCandidate = fs.realpathSync(candidate);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ let canonicalShimsDir = path.resolve(shimsDir);
85
+ try {
86
+ canonicalShimsDir = fs.realpathSync(shimsDir);
87
+ }
88
+ catch {
89
+ /* An absent shims dir cannot make an existing native binary invalid. */
90
+ }
91
+ if (!pathIsWithin(canonicalCandidate, canonicalShimsDir))
92
+ return canonicalCandidate;
93
+ const recordPath = path.join(historyDir, 'adopted-launchers', command);
94
+ try {
95
+ const [original] = fs.readFileSync(recordPath, 'utf-8').split(/\r?\n/, 1);
96
+ if (!original)
97
+ return null;
98
+ const canonicalOriginal = fs.realpathSync(original);
99
+ if (pathIsWithin(canonicalOriginal, canonicalShimsDir))
100
+ return null;
101
+ const stat = fs.statSync(canonicalOriginal);
102
+ if (!stat.isFile())
103
+ return null;
104
+ fs.accessSync(canonicalOriginal, fs.constants.X_OK);
105
+ return canonicalOriginal;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ export function findInPath(command, options = {}) {
75
112
  const pathEnv = process.env.PATH || '';
76
113
  const pathExt = process.platform === 'win32' ? (process.env.PATHEXT || '').split(';') : [''];
77
- const shimsDir = getShimsDir();
114
+ const shimsDir = options.shimsDir ?? getShimsDir();
78
115
  for (const dir of pathEnv.split(path.delimiter)) {
79
116
  if (!dir)
80
117
  continue;
@@ -84,8 +121,11 @@ export function findInPath(command) {
84
121
  const full = path.join(dir, command + ext);
85
122
  try {
86
123
  const stat = fs.statSync(full);
87
- if (stat.isFile())
88
- return full;
124
+ if (!stat.isFile())
125
+ continue;
126
+ const native = resolveNativeBinaryPath(command, full, options);
127
+ if (native)
128
+ return native;
89
129
  }
90
130
  catch {
91
131
  /* not in this dir */
@@ -305,9 +345,8 @@ export const AGENTS = {
305
345
  // + warns for pre-2.4 installs); the direct `subagents add --agents cursor` path
306
346
  // writes unconditionally, same as the other since-gated agents.
307
347
  // See transformSubagentForCursor / https://cursor.com/docs/subagents.
308
- // interactiveRepl: false — cursor-agent exits immediately with no argv. It requires a
309
- // prompt to do anything useful; a bare invocation is not a REPL (RUSH-2185, EXEC-23a).
310
- capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: { since: '2026.1.22' }, rules: { file: '.cursorrules' }, workflows: false, memory: false, modes: ['plan', 'edit', 'skip'], interactiveRepl: false }, // allowlist: ~/.cursor/cli-config.json
348
+ // Current cursor-agent builds open their interactive TUI with no argv.
349
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: { since: '2026.1.22' }, rules: { file: '.cursorrules' }, workflows: false, memory: false, modes: ['plan', 'edit', 'skip'], interactiveRepl: true }, // allowlist: ~/.cursor/cli-config.json
311
350
  },
312
351
  opencode: {
313
352
  id: 'opencode',
@@ -1602,6 +1641,43 @@ function isValidOpenCodeCredential(value) {
1602
1641
  default: return false;
1603
1642
  }
1604
1643
  }
1644
+ /**
1645
+ * OpenCode's account identity: the sorted, "+"-joined list of provider ids
1646
+ * that hold a valid credential in `auth.json` (e.g. `"anthropic+muse-spark"`).
1647
+ * `auth.json` carries no email/identity claim (see `isValidOpenCodeCredential`),
1648
+ * so this join is the closest thing to "which account is this" available — the
1649
+ * same value `agents view`/`agents doctor` show for OpenCode's signed-in state.
1650
+ *
1651
+ * This is the ONLY correct source for an OpenCode "account". OpenCode's SQLite
1652
+ * `opencode.db` also carries `account`/`account_state`/`control_account` tables,
1653
+ * but on a real, actively-used install (yosemite-s1, 1.16.0, 35 applied
1654
+ * migrations) all three are permanently empty — no migration ever populates
1655
+ * them, and no session has ever written a row. Reading from them instead of
1656
+ * `auth.json` always yields `undefined`, credential or not; `session/discover.ts`
1657
+ * uses this function rather than duplicating a sqlite lookup against those
1658
+ * dead tables.
1659
+ *
1660
+ * Sync (`fs.readFileSync`), no network. Returns undefined when `auth.json` is
1661
+ * missing, unreadable, or carries no valid credential.
1662
+ */
1663
+ export function resolveOpenCodeAccountId(base) {
1664
+ const authPath = resolveOpenCodeAuthPath(base);
1665
+ if (!authPath)
1666
+ return undefined;
1667
+ try {
1668
+ const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
1669
+ if (!data || typeof data !== 'object')
1670
+ return undefined;
1671
+ const providers = Object.entries(data)
1672
+ .filter(([, cred]) => isValidOpenCodeCredential(cred))
1673
+ .map(([id]) => id)
1674
+ .sort();
1675
+ return providers.length ? providers.join('+') : undefined;
1676
+ }
1677
+ catch {
1678
+ return undefined;
1679
+ }
1680
+ }
1605
1681
  /**
1606
1682
  * Whether a Muse Code `~/.config/muse/auth.json` document holds any usable
1607
1683
  * access token. Live shape from `muse login` (device OAuth, Muse Code 0.1.0):
@@ -2020,22 +2096,11 @@ export async function getAccountInfo(agentId, home) {
2020
2096
  // themselves. The user's complaint was the row read "not signed in"
2021
2097
  // despite a live login; a valid provider entry now shows e.g.
2022
2098
  // "id:muse-spark" so they can see exactly which provider is configured.
2023
- const authPath = resolveOpenCodeAuthPath(base);
2024
- if (!authPath)
2025
- return { ...empty, lastActive };
2026
- const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
2027
- if (!data || typeof data !== 'object')
2028
- return { ...empty, lastActive };
2029
- const providers = Object.entries(data)
2030
- .filter(([, cred]) => isValidOpenCodeCredential(cred))
2031
- .map(([id]) => id)
2032
- .sort();
2033
- if (providers.length === 0)
2099
+ // resolveOpenCodeAccountId is the single source of truth for this join —
2100
+ // session/discover.ts reuses it for the indexed `account` field.
2101
+ const accountId = resolveOpenCodeAccountId(base);
2102
+ if (!accountId)
2034
2103
  return { ...empty, lastActive };
2035
- // Provider ids are config keys (e.g. "anthropic", "muse-spark"), not
2036
- // secrets. Join them into a stable, human-readable account label +
2037
- // identity key for usage dedup.
2038
- const accountId = providers.join('+');
2039
2104
  const accountKey = buildIdentityKey(agentId, [['providers', accountId]]);
2040
2105
  return { ...empty, signedIn: true, accountId, accountKey, lastActive };
2041
2106
  }
@@ -0,0 +1,24 @@
1
+ /** Stable subsystem identifiers shared by the daemon (writer) and `agents daemon` (reader). */
2
+ export declare const SUBSYSTEM_SECRETS_BROKER = "secrets-broker";
3
+ export declare const SUBSYSTEM_BROWSER_IPC = "browser-ipc";
4
+ /** One subsystem's health as of the last time it reported in. */
5
+ export interface SubsystemHealth {
6
+ /** Stable identifier, e.g. 'secrets-broker', 'browser-ipc'. */
7
+ subsystem: string;
8
+ /** Most recent error message, or null if it has never failed. */
9
+ lastError: string | null;
10
+ /** ISO timestamp of the most recent error, or null. */
11
+ lastErrorAt: string | null;
12
+ /** Consecutive failures since the last success (0 when currently healthy). */
13
+ consecutiveFailures: number;
14
+ /** ISO timestamp of the most recent success, or null if it has never succeeded. */
15
+ lastOkAt: string | null;
16
+ }
17
+ /** Record a successful subsystem check-in — clears the failure streak. */
18
+ export declare function recordSubsystemOk(subsystem: string, at?: string): void;
19
+ /** Record a subsystem failure — bumps the consecutive-failure streak. */
20
+ export declare function recordSubsystemError(subsystem: string, error: string, at?: string): void;
21
+ /** Read one subsystem's health record, or null if it has never reported in. */
22
+ export declare function readSubsystemHealth(subsystem: string): SubsystemHealth | null;
23
+ /** Read every subsystem's health record, sorted by subsystem name. */
24
+ export declare function readAllSubsystemHealth(): SubsystemHealth[];