@phnx-labs/agents-cli 1.20.57 → 1.20.59

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 (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +29 -5
  6. package/dist/commands/output.d.ts +19 -0
  7. package/dist/commands/output.js +333 -0
  8. package/dist/commands/secrets.js +34 -25
  9. package/dist/commands/versions.js +11 -3
  10. package/dist/commands/view.js +19 -4
  11. package/dist/index.js +2 -1
  12. package/dist/lib/agents.d.ts +21 -0
  13. package/dist/lib/agents.js +42 -14
  14. package/dist/lib/daemon.d.ts +5 -5
  15. package/dist/lib/daemon.js +65 -17
  16. package/dist/lib/git.d.ts +9 -0
  17. package/dist/lib/git.js +12 -0
  18. package/dist/lib/hosts/dispatch.d.ts +21 -0
  19. package/dist/lib/hosts/dispatch.js +88 -5
  20. package/dist/lib/hosts/passthrough.js +1 -0
  21. package/dist/lib/mcp.js +1 -1
  22. package/dist/lib/output/git-output.d.ts +74 -0
  23. package/dist/lib/output/git-output.js +213 -0
  24. package/dist/lib/permissions.d.ts +31 -1
  25. package/dist/lib/permissions.js +210 -9
  26. package/dist/lib/project-root.d.ts +65 -0
  27. package/dist/lib/project-root.js +134 -0
  28. package/dist/lib/resources/mcp.js +1 -1
  29. package/dist/lib/resources/permissions.js +5 -1
  30. package/dist/lib/resources/skills.js +6 -1
  31. package/dist/lib/resources/types.d.ts +1 -1
  32. package/dist/lib/secrets/agent.d.ts +26 -23
  33. package/dist/lib/secrets/agent.js +196 -216
  34. package/dist/lib/secrets/remote.d.ts +7 -2
  35. package/dist/lib/secrets/remote.js +12 -10
  36. package/dist/lib/session/active.d.ts +3 -0
  37. package/dist/lib/session/active.js +1 -0
  38. package/dist/lib/session/db.d.ts +3 -0
  39. package/dist/lib/session/db.js +20 -4
  40. package/dist/lib/session/discover.d.ts +2 -0
  41. package/dist/lib/session/discover.js +40 -4
  42. package/dist/lib/session/parse.js +38 -15
  43. package/dist/lib/session/state.d.ts +4 -1
  44. package/dist/lib/session/state.js +18 -1
  45. package/dist/lib/session/types.d.ts +10 -0
  46. package/dist/lib/staleness/detectors/permissions.js +64 -1
  47. package/dist/lib/staleness/detectors/subagents.js +30 -0
  48. package/dist/lib/staleness/writers/commands.js +3 -3
  49. package/dist/lib/staleness/writers/subagents.js +13 -1
  50. package/dist/lib/startup/command-registry.d.ts +1 -0
  51. package/dist/lib/startup/command-registry.js +2 -0
  52. package/dist/lib/subagents.d.ts +23 -0
  53. package/dist/lib/subagents.js +161 -12
  54. package/dist/lib/teams/agents.d.ts +14 -0
  55. package/dist/lib/teams/agents.js +158 -22
  56. package/dist/lib/types.d.ts +13 -0
  57. package/dist/lib/versions.d.ts +39 -0
  58. package/dist/lib/versions.js +199 -12
  59. package/package.json +1 -1
  60. package/scripts/postinstall.js +26 -11
@@ -12,14 +12,14 @@ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/widt
12
12
  import * as fs from 'fs';
13
13
  import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
14
14
  import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
15
- import { ensureDaemonStarted } from '../lib/daemon.js';
15
+ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
16
16
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveSshTarget, } from '../lib/secrets/remote.js';
17
17
  import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
18
18
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
19
19
  import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
20
20
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
21
21
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
22
- import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, installSecretsAgentService, runAgentLoadFromStdin, runSecretsAgent, secretsAgentServiceInstalled, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
22
+ import { DEFAULT_TTL_MS, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
23
23
  import { parseDuration } from '../lib/hooks/cache.js';
24
24
  import { emit } from '../lib/events.js';
25
25
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
@@ -328,12 +328,12 @@ async function browseRemote(targets, args, tty) {
328
328
  if (tty) {
329
329
  for (const t of targets) {
330
330
  const target = await resolveSshTarget(t);
331
- render(t, remoteSecretsRaw(target, args, { tty: true }));
331
+ render(t, remoteSecretsRaw(target, args, { tty: true, osLookupName: t }));
332
332
  }
333
333
  }
334
334
  else {
335
- const resolved = await Promise.all(targets.map((t) => resolveSshTarget(t)));
336
- const results = resolved.map((target) => remoteSecretsRaw(target, args));
335
+ const resolved = await Promise.all(targets.map(async (t) => ({ name: t, target: await resolveSshTarget(t) })));
336
+ const results = resolved.map(({ name, target }) => remoteSecretsRaw(target, args, { osLookupName: name }));
337
337
  targets.forEach((t, i) => render(t, results[i]));
338
338
  }
339
339
  if (failures > 0)
@@ -1476,7 +1476,7 @@ Examples:
1476
1476
  // engine (BatchMode, ConnectTimeout, keepalive, control-socket
1477
1477
  // reuse) via the same path the READ inverse (`remoteResolveEnv`)
1478
1478
  // uses. `--from -` reads the .env off ssh stdin.
1479
- res = remoteSecretsRaw(host, ['import', resolvedBundleName, '--from', '-', ...(opts.force ? ['--force'] : [])], { input: dotenv });
1479
+ res = remoteSecretsRaw(host, ['import', resolvedBundleName, '--from', '-', ...(opts.force ? ['--force'] : [])], { input: dotenv, osLookupName: host });
1480
1480
  }
1481
1481
  if (res.code === null) {
1482
1482
  failures++;
@@ -1584,7 +1584,7 @@ Examples:
1584
1584
  // locally instead.
1585
1585
  const { assertRemoteBundleFlagsUnsupported } = await import('../lib/secrets/bundles.js');
1586
1586
  assertRemoteBundleFlagsUnsupported(bundleName, execOpts.host, { keys: keysSubset, allowExpired: execOpts.allowExpired }, { keysFlag: '--keys', allowExpiredFlag: '--allow-expired' });
1587
- secretEnv = await remoteResolveEnv(await resolveSshTarget(execOpts.host), bundleName);
1587
+ secretEnv = await remoteResolveEnv(await resolveSshTarget(execOpts.host), bundleName, { osLookupName: execOpts.host });
1588
1588
  }
1589
1589
  else {
1590
1590
  const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
@@ -1738,7 +1738,7 @@ Examples:
1738
1738
  // sees a real TTY, which requires our local terminal to pass straight
1739
1739
  // through. The remote's prompt + output stream to this terminal; we get
1740
1740
  // back only the exit code.
1741
- const code = remoteSecretsStream(target, unlockArgs);
1741
+ const code = remoteSecretsStream(target, unlockArgs, { osLookupName: h });
1742
1742
  if (code === 0) {
1743
1743
  console.log(chalk.green(`${h}: unlocked`));
1744
1744
  }
@@ -1770,13 +1770,13 @@ Examples:
1770
1770
  ttlMs = secs * 1000;
1771
1771
  }
1772
1772
  if (!(await ensureAgentRunning())) {
1773
- console.error(chalk.red('Could not start the secrets-agent.'));
1773
+ console.error(chalk.red('Could not start the secrets broker.'));
1774
1774
  process.exit(1);
1775
1775
  }
1776
1776
  // #415: the daemon should be always-on for any background need, not only
1777
- // after `routines add`. A user who only ever unlocks secrets still gets
1778
- // the daemon installed + running here. `ensureAgentRunning` above only
1779
- // brings up the standalone secrets broker, not the daemon. Idempotent
1777
+ // after `routines add`. `ensureAgentRunning` prefers the daemon (it hosts
1778
+ // the broker, #416), but can fall back to a one-off broker spawn when the
1779
+ // daemon can't come up so ensure the daemon is up regardless. Idempotent
1780
1780
  // (single-instance start lock, #414) and best-effort — never blocks unlock.
1781
1781
  ensureDaemonStarted();
1782
1782
  let loaded = 0;
@@ -1830,13 +1830,14 @@ Examples:
1830
1830
  console.log(chalk.gray('secrets-agent is macOS-only.'));
1831
1831
  return;
1832
1832
  }
1833
- console.log(chalk.gray('service: ') +
1834
- (secretsAgentServiceInstalled()
1835
- ? chalk.green('installed (persistent)')
1836
- : chalk.yellow('not installed run `agents secrets start` for a persistent broker')));
1833
+ const brokerUp = (await agentPing()).reachable;
1834
+ console.log(chalk.gray('broker: ') +
1835
+ (brokerUp
1836
+ ? chalk.green('running') + chalk.gray(isDaemonRunning() ? ' (hosted by the daemon)' : ' (standalone)')
1837
+ : chalk.yellow('not running — starts on demand, or run `agents secrets start` to bring the daemon up now')));
1837
1838
  const entries = await agentStatus();
1838
1839
  if (entries.length === 0) {
1839
- console.log(chalk.gray('No bundles unlocked. The secrets-agent is idle or not running.'));
1840
+ console.log(chalk.gray('No bundles unlocked. The secrets broker is idle or not running.'));
1840
1841
  console.log(chalk.gray('Try: agents secrets unlock <bundle>'));
1841
1842
  return;
1842
1843
  }
@@ -1887,29 +1888,37 @@ Examples:
1887
1888
  });
1888
1889
  cmd
1889
1890
  .command('start')
1890
- .description('Install + start the secrets-agent as a persistent background service (macOS). Survives heavy load; reads connect instantly.')
1891
+ .description('Bring up the always-on daemon that hosts the secrets broker (macOS). Survives heavy load; reads connect instantly.')
1891
1892
  .action(async () => {
1892
1893
  if (process.platform !== 'darwin') {
1893
- console.error(chalk.red('secrets-agent service is macOS-only.'));
1894
+ console.error(chalk.red('The secrets broker is macOS-only.'));
1894
1895
  process.exit(1);
1895
1896
  }
1896
- process.stdout.write(chalk.gray('Installing launchd service…\n'));
1897
- if (await installSecretsAgentService()) {
1898
- console.log(chalk.green('secrets-agent service running.') + chalk.gray(' It stays up across your macOS login session; unlock/auto-cache now connect instantly.'));
1897
+ process.stdout.write(chalk.gray('Starting the daemon…\n'));
1898
+ ensureDaemonStarted();
1899
+ // The daemon hosts the broker socket-first; wait briefly for it to answer.
1900
+ const deadline = Date.now() + 10000;
1901
+ let reachable = (await agentPing()).reachable;
1902
+ while (!reachable && Date.now() < deadline) {
1903
+ await new Promise((r) => setTimeout(r, 200));
1904
+ reachable = (await agentPing()).reachable;
1905
+ }
1906
+ if (reachable) {
1907
+ console.log(chalk.green('secrets broker running.') + chalk.gray(' Hosted by the always-on daemon; unlock/auto-cache now connect instantly.'));
1899
1908
  }
1900
1909
  else {
1901
- console.error(chalk.red('Service installed but did not become reachable in time (machine may be heavily loaded — launchd will keep retrying).'));
1910
+ console.error(chalk.red('Daemon started but the broker did not become reachable in time (machine may be heavily loaded — it will keep retrying).'));
1902
1911
  process.exit(1);
1903
1912
  }
1904
1913
  });
1905
1914
  cmd
1906
1915
  .command('stop')
1907
- .description('Stop + remove the persistent secrets-agent service and wipe what it held.')
1916
+ .description('Lock all bundles and retire any legacy standalone service. The always-on daemon (which hosts the broker) is left running.')
1908
1917
  .action(async () => {
1909
1918
  if (process.platform !== 'darwin')
1910
1919
  return;
1911
1920
  await uninstallSecretsAgentService();
1912
- console.log(chalk.green('secrets-agent service stopped and removed.'));
1921
+ console.log(chalk.green('Locked all bundles.') + chalk.gray(' The broker stays hosted by the always-on daemon; a legacy standalone service, if any, was retired.'));
1913
1922
  });
1914
1923
  cmd
1915
1924
  .command('_agent-run', { hidden: true })
@@ -4,7 +4,7 @@ import ora from 'ora';
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
  import { select, confirm, checkbox } from '@inquirer/prompts';
7
- import { AGENTS, ALL_AGENT_IDS, getAccountEmail, getAccountInfo, agentLabel, warnAgentDeprecated, } from '../lib/agents.js';
7
+ import { AGENTS, ALL_AGENT_IDS, getAccountEmail, getAccountInfo, agentLabel, warnAgentDeprecated, isSelfUpdatingAgent, } from '../lib/agents.js';
8
8
  import { formatUsageSummary, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
9
9
  import { viewAction } from './view.js';
10
10
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
@@ -67,7 +67,7 @@ async function setDefaultVersion(agent, installedVersion) {
67
67
  createVersionedAlias(agent, installedVersion);
68
68
  const symlinkResult = await switchConfigSymlink(agent, installedVersion);
69
69
  if (symlinkResult.success) {
70
- console.log(chalk.green(' Set as default'));
70
+ console.log(chalk.green(isSelfUpdatingAgent(agent) ? ' Set as active config profile' : ' Set as default'));
71
71
  if (symlinkResult.backupPath) {
72
72
  console.log(chalk.gray(` Backed up existing config to: ${symlinkResult.backupPath}`));
73
73
  }
@@ -752,7 +752,15 @@ export function registerVersionsCommands(program) {
752
752
  warnIfShimShadowed(agentId);
753
753
  const useEmail = await getAccountEmail(agentId, getVersionHomePath(agentId, finalVersion));
754
754
  const useEmailStr = useEmail ? chalk.cyan(` (${useEmail})`) : '';
755
- console.log(chalk.green(`Set ${agentLabel(agentConfig.id)}@${finalVersion} as global default`) + useEmailStr);
755
+ // Self-updating agents are one binary; `use` only swaps the config
756
+ // symlink (the profile), it does not change which binary runs — so say
757
+ // "profile", not "version", to match what actually happened.
758
+ if (isSelfUpdatingAgent(agentId)) {
759
+ console.log(chalk.green(`Switched ${agentLabel(agentConfig.id)} to config profile ${finalVersion}`) + useEmailStr);
760
+ }
761
+ else {
762
+ console.log(chalk.green(`Set ${agentLabel(agentConfig.id)}@${finalVersion} as global default`) + useEmailStr);
763
+ }
756
764
  }
757
765
  }
758
766
  catch (err) {
@@ -7,7 +7,7 @@ import * as path from 'path';
7
7
  import { AGENTS, ALL_AGENT_IDS, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
8
8
  import { deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
9
9
  import { readManifest } from '../lib/manifest.js';
10
- import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, } from '../lib/versions.js';
10
+ import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, } from '../lib/versions.js';
11
11
  import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
12
12
  import { getAgentResources } from '../lib/resources.js';
13
13
  import { resolveVersionFilter, AgentSpecError } from '../lib/agent-spec/index.js';
@@ -358,6 +358,19 @@ async function showInstalledVersions(filterAgentId) {
358
358
  profileOnly.push(agentId);
359
359
  }
360
360
  }
361
+ // For self-updating global-binary agents (droid) the on-disk version-dir name
362
+ // is a stale label — the real version is whatever `<cli> --version` reports.
363
+ // Resolve it once so every row/width pass shows the live version, while the
364
+ // per-version home + account lookups keep using the real dir name.
365
+ const liveVersionByAgent = new Map();
366
+ await Promise.all(versionManaged
367
+ .filter((agentId) => isGlobalBinaryAgent(agentId))
368
+ .map(async (agentId) => {
369
+ const live = await getLiveVersion(agentId);
370
+ if (live)
371
+ liveVersionByAgent.set(agentId, live);
372
+ }));
373
+ const displayVersion = (agentId, dirVersion) => liveVersionByAgent.get(agentId) ?? dirVersion;
361
374
  // Show version-managed agents
362
375
  if (versionManaged.length > 0) {
363
376
  // Calculate column widths across all agents for alignment
@@ -370,7 +383,8 @@ async function showInstalledVersions(filterAgentId) {
370
383
  const versions = listInstalledVersions(agentId);
371
384
  const globalDefault = getGlobalDefault(agentId);
372
385
  for (const v of versions) {
373
- const label = v === globalDefault ? `${v} (default)` : v;
386
+ const shown = displayVersion(agentId, v);
387
+ const label = v === globalDefault ? `${shown} (default)` : shown;
374
388
  maxVerLabel = Math.max(maxVerLabel, label.length);
375
389
  const rawInfo = infoMap.get(`${agentId}:${v}`);
376
390
  const info = rawInfo ? mergeCanonical(rawInfo) : undefined;
@@ -423,9 +437,10 @@ async function showInstalledVersions(filterAgentId) {
423
437
  });
424
438
  for (const version of sortedVersions) {
425
439
  const isDefault = version === globalDefault;
426
- const base = isDefault ? `${version} (default)` : version;
440
+ const shown = displayVersion(agentId, version);
441
+ const base = isDefault ? `${shown} (default)` : shown;
427
442
  const padded = base.padEnd(maxVerLabel);
428
- const label = isDefault ? `${version}${chalk.green(' (default)')}${' '.repeat(maxVerLabel - base.length)}` : padded;
443
+ const label = isDefault ? `${shown}${chalk.green(' (default)')}${' '.repeat(maxVerLabel - base.length)}` : padded;
429
444
  const rawInfo = infoMap.get(`${agentId}:${version}`);
430
445
  const vInfo = rawInfo ? mergeCanonical(rawInfo) : undefined;
431
446
  const usageKey = getUsageLookupKey(vInfo);
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, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } 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, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, 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, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } 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(loadFactory);
691
691
  await reg(loadUsage);
692
692
  await reg(loadCost);
693
+ await reg(loadOutput);
693
694
  await reg(loadBudget);
694
695
  await reg(loadAlias);
695
696
  await reg(loadPty);
@@ -34,6 +34,27 @@ export declare function findInPath(command: string): string | null;
34
34
  export declare const AGENTS: Record<AgentId, AgentConfig>;
35
35
  /** All registered agent IDs derived from the AGENTS registry. */
36
36
  export declare const ALL_AGENT_IDS: AgentId[];
37
+ /**
38
+ * A self-updating agent is a single global binary installed by an official
39
+ * `curl … | sh` / `brew install` script that carries NO version token — the
40
+ * installer can only ever fetch the *current* release, and the binary then keeps
41
+ * itself up to date in place (droid, grok, antigravity, cursor, hermes, forge,
42
+ * kiro, goose). There is no semver to pin, so agents-cli must not model these as
43
+ * having multiple installable version-homes the way it does for npm-packaged
44
+ * agents (claude, codex, kimi, …).
45
+ *
46
+ * The predicate is `!npmPackage && installScript && !installScript.includes('VERSION')`:
47
+ * - `npmPackage` empty → not installed from npm, so `agents add x@1.2.3`
48
+ * can't resolve a registry version.
49
+ * - `installScript` present → it IS installed by a script (not unmanaged).
50
+ * - no `VERSION` placeholder → the script has no slot for a pinned version
51
+ * (contrast: an installer templated with `VERSION`
52
+ * could pin, and is NOT self-updating).
53
+ *
54
+ * Route every "is this a pinnable, multi-version agent?" decision through here —
55
+ * never a scattered `agent === 'droid'`.
56
+ */
57
+ export declare function isSelfUpdatingAgent(agent: AgentId): boolean;
37
58
  /** Get the chalk color function for an agent. Works for any AgentId or SessionAgentId. */
38
59
  export declare function colorAgent(agentId: string): (s: string) => string;
39
60
  /** Return the agent's display name, colored. */
@@ -236,7 +236,7 @@ export const AGENTS = {
236
236
  cloudProvider: 'codex',
237
237
  // Subagents: multi-agent plumbing since 0.117.0; custom agents as
238
238
  // ~/.codex/agents/*.toml (name, description, developer_instructions).
239
- capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: false, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'] },
239
+ capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: { since: '0.138.0' }, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'] },
240
240
  },
241
241
  gemini: {
242
242
  id: 'gemini',
@@ -290,7 +290,7 @@ export const AGENTS = {
290
290
  format: 'markdown',
291
291
  variableSyntax: '$ARGUMENTS',
292
292
  supportsHooks: true,
293
- capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: true, commands: true, plugins: true, subagents: false, rules: { file: '.cursorrules' }, workflows: false, memory: false, modes: ['edit', 'skip'] },
293
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: false, rules: { file: '.cursorrules' }, workflows: false, memory: false, modes: ['edit', 'skip'] }, // allowlist: ~/.cursor/cli-config.json
294
294
  },
295
295
  opencode: {
296
296
  id: 'opencode',
@@ -310,7 +310,7 @@ export const AGENTS = {
310
310
  format: 'markdown',
311
311
  variableSyntax: '$ARGUMENTS',
312
312
  supportsHooks: false,
313
- capabilities: { hooks: false, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '1.1.1' }, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'] }, // subagents: ~/.config/opencode/agents/*.md
313
+ capabilities: { hooks: false, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '1.1.1' }, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'] },
314
314
  },
315
315
  openclaw: {
316
316
  id: 'openclaw',
@@ -353,7 +353,7 @@ export const AGENTS = {
353
353
  format: 'markdown',
354
354
  variableSyntax: '$ARGUMENTS',
355
355
  supportsHooks: true,
356
- capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: true, commands: true, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'auto', 'skip'] },
356
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: true, commands: true, plugins: true, subagents: { since: '0.0.353' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit', 'auto', 'skip'] },
357
357
  },
358
358
  amp: {
359
359
  id: 'amp',
@@ -393,7 +393,7 @@ export const AGENTS = {
393
393
  format: 'markdown',
394
394
  variableSyntax: '$ARGUMENTS',
395
395
  supportsHooks: true,
396
- capabilities: { hooks: { since: '0.10.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: true, commands: true, plugins: false, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'] },
396
+ capabilities: { hooks: { since: '0.10.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '2.8.0' }, skills: true, commands: true, plugins: false, subagents: { since: '1.23.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'] },
397
397
  },
398
398
  goose: {
399
399
  id: 'goose',
@@ -405,7 +405,10 @@ export const AGENTS = {
405
405
  configDir: path.join(HOME, '.config', 'goose'),
406
406
  commandsDir: path.join(HOME, '.config', 'goose', 'commands'),
407
407
  commandsSubdir: 'commands',
408
- skillsDir: path.join(HOME, '.config', 'goose', 'skills'),
408
+ // Goose reads skills directly from central ~/.agents/skills/ via the Summon
409
+ // extension (block-goose-cli ≥ 1.25.0). No per-version copy is written.
410
+ skillsDir: path.join(HOME, '.agents', 'skills'),
411
+ nativeAgentsSkillsDir: true,
409
412
  // Hooks: Open Plugins format — auto-discovered from
410
413
  // ~/.agents/plugins/<name>/hooks/hooks.json (shipped block-goose-cli
411
414
  // ≥ 1.34.0). See registerHooksForGoose.
@@ -416,7 +419,7 @@ export const AGENTS = {
416
419
  supportsHooks: true,
417
420
  // Plugins: Open Plugins under ~/.agents/plugins/<name>/ (same layout as
418
421
  // agents-cli source). Version isolation copies into versionHome/.agents/plugins/.
419
- capabilities: { hooks: { since: '1.34.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: false, commands: false, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'] },
422
+ capabilities: { hooks: { since: '1.34.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: { since: '1.25.0' }, commands: false, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['edit'] },
420
423
  },
421
424
  // Google Antigravity CLI (`agy`) — official replacement for Gemini CLI as of IO 2026.
422
425
  // configDir nests inside `~/.gemini/` since agy shares the parent dir with the Gemini
@@ -560,7 +563,7 @@ export const AGENTS = {
560
563
  authFiles: ['auth.v2.file', 'auth.v2.key'],
561
564
  commandsDir: path.join(HOME, '.factory', 'commands'),
562
565
  commandsSubdir: 'commands',
563
- skillsDir: '', // no skills concept
566
+ skillsDir: path.join(HOME, '.factory', 'skills'),
564
567
  hooksDir: 'hooks',
565
568
  pluginManifestDir: '.factory-plugin',
566
569
  instructionsFile: 'AGENTS.md',
@@ -575,8 +578,8 @@ export const AGENTS = {
575
578
  mcp: true,
576
579
  mcpHttp: false,
577
580
  mcpHeaders: false,
578
- allowlist: false,
579
- skills: false,
581
+ allowlist: { since: '0.57.5' },
582
+ skills: { since: '0.26.0' },
580
583
  commands: true,
581
584
  plugins: true,
582
585
  subagents: true,
@@ -661,6 +664,30 @@ export const AGENTS = {
661
664
  };
662
665
  /** All registered agent IDs derived from the AGENTS registry. */
663
666
  export const ALL_AGENT_IDS = Object.keys(AGENTS);
667
+ /**
668
+ * A self-updating agent is a single global binary installed by an official
669
+ * `curl … | sh` / `brew install` script that carries NO version token — the
670
+ * installer can only ever fetch the *current* release, and the binary then keeps
671
+ * itself up to date in place (droid, grok, antigravity, cursor, hermes, forge,
672
+ * kiro, goose). There is no semver to pin, so agents-cli must not model these as
673
+ * having multiple installable version-homes the way it does for npm-packaged
674
+ * agents (claude, codex, kimi, …).
675
+ *
676
+ * The predicate is `!npmPackage && installScript && !installScript.includes('VERSION')`:
677
+ * - `npmPackage` empty → not installed from npm, so `agents add x@1.2.3`
678
+ * can't resolve a registry version.
679
+ * - `installScript` present → it IS installed by a script (not unmanaged).
680
+ * - no `VERSION` placeholder → the script has no slot for a pinned version
681
+ * (contrast: an installer templated with `VERSION`
682
+ * could pin, and is NOT self-updating).
683
+ *
684
+ * Route every "is this a pinnable, multi-version agent?" decision through here —
685
+ * never a scattered `agent === 'droid'`.
686
+ */
687
+ export function isSelfUpdatingAgent(agent) {
688
+ const cfg = AGENTS[agent];
689
+ return !cfg.npmPackage && !!cfg.installScript && !cfg.installScript.includes('VERSION');
690
+ }
664
691
  // Capability-filtered agent lists used to live here as `*_CAPABLE_AGENTS`
665
692
  // constants. They were a frequent source of silent-skip bugs (e.g. grok
666
693
  // rules sync gated on `COMMANDS_CAPABLE_AGENTS`). Use `capableAgents(cap)`
@@ -1802,8 +1829,8 @@ export function getUserMcpConfigPath(agentId) {
1802
1829
  // Codex uses TOML config
1803
1830
  return path.join(agent.configDir, 'config.toml');
1804
1831
  case 'opencode':
1805
- // OpenCode uses JSONC config
1806
- return path.join(agent.configDir, 'opencode.jsonc');
1832
+ // OpenCode loads ~/.config/opencode/opencode.jsonc (not ~/.opencode/)
1833
+ return path.join(HOME, '.config', 'opencode', 'opencode.jsonc');
1807
1834
  case 'cursor':
1808
1835
  // Cursor uses mcp.json
1809
1836
  return path.join(agent.configDir, 'mcp.json');
@@ -1841,7 +1868,7 @@ export function getMcpConfigPathForHome(agentId, home) {
1841
1868
  case 'codex':
1842
1869
  return path.join(home, '.codex', 'config.toml');
1843
1870
  case 'opencode':
1844
- return path.join(home, '.opencode', 'opencode.jsonc');
1871
+ return path.join(home, '.config', 'opencode', 'opencode.jsonc');
1845
1872
  case 'cursor':
1846
1873
  return path.join(home, '.cursor', 'mcp.json');
1847
1874
  case 'openclaw':
@@ -1879,7 +1906,8 @@ function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
1879
1906
  case 'codex':
1880
1907
  return path.join(cwd, `.${agentId}`, 'config.toml');
1881
1908
  case 'opencode':
1882
- return path.join(cwd, `.${agentId}`, 'opencode.jsonc');
1909
+ // Project config is opencode.jsonc at project root (not .opencode/)
1910
+ return path.join(cwd, 'opencode.jsonc');
1883
1911
  case 'cursor':
1884
1912
  return path.join(cwd, `.${agentId}`, 'mcp.json');
1885
1913
  case 'openclaw':
@@ -82,12 +82,12 @@ export declare function readDaemonClaudeOAuthToken(opts?: {
82
82
  */
83
83
  export declare function writeOwnerOnlyServiceManifest(filePath: string, content: string): void;
84
84
  /** Generate a macOS launchd plist for auto-starting the daemon. */
85
- export declare function generateLaunchdPlist(oauthToken?: string | null): string;
85
+ export declare function generateLaunchdPlist(oauthToken?: string | null, agentsBin?: string): string;
86
86
  /** Generate a Linux systemd user unit for auto-starting the daemon. */
87
- export declare function generateSystemdUnit(oauthToken?: string | null): string;
87
+ export declare function generateSystemdUnit(oauthToken?: string | null, agentsBin?: string): string;
88
88
  export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
89
89
  /** Start the daemon via launchd, systemd, or as a detached process. */
90
- export declare function startDaemon(): {
90
+ export declare function startDaemon(agentsBin?: string): {
91
91
  pid: number | null;
92
92
  method: string;
93
93
  };
@@ -129,8 +129,8 @@ export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv, oaut
129
129
  * Going through `process.execPath` means a real PE/binary is spawned with
130
130
  * `detached: true` and no console, so nothing signals the daemon after launch.
131
131
  *
132
- * When the entry isn't a JS file (e.g. a native launcher resolved via
133
- * `which agents`), run it directly — it owns its own runtime resolution.
132
+ * When the entry isn't a Node script (e.g. a native compiled launcher), run it
133
+ * directly — it owns its own runtime resolution.
134
134
  */
135
135
  export declare function getDaemonLaunch(agentsBin?: string): {
136
136
  command: string;
@@ -674,8 +674,7 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
674
674
  fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
675
675
  }
676
676
  /** Generate a macOS launchd plist for auto-starting the daemon. */
677
- export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken()) {
678
- const agentsBin = getAgentsBinPath();
677
+ export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
679
678
  const launch = getDaemonLaunch(agentsBin);
680
679
  const logPath = getLogPath();
681
680
  const oauthEntry = oauthToken
@@ -704,7 +703,7 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
704
703
  <key>EnvironmentVariables</key>
705
704
  <dict>
706
705
  <key>PATH</key>
707
- <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin</string>${oauthEntry}
706
+ <string>${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin</string>${oauthEntry}
708
707
  </dict>
709
708
  </dict>
710
709
  </plist>`;
@@ -714,8 +713,7 @@ function systemdExecArg(value) {
714
713
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
715
714
  }
716
715
  /** Generate a Linux systemd user unit for auto-starting the daemon. */
717
- export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken()) {
718
- const agentsBin = getAgentsBinPath();
716
+ export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
719
717
  const launch = getDaemonLaunch(agentsBin);
720
718
  const execStart = [launch.command, ...launch.args].map(systemdExecArg).join(' ');
721
719
  const oauthLine = oauthToken
@@ -730,7 +728,7 @@ Type=simple
730
728
  ExecStart=${execStart}
731
729
  Restart=always
732
730
  RestartSec=10
733
- Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin${oauthLine}
731
+ Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin${oauthLine}
734
732
 
735
733
  [Install]
736
734
  WantedBy=default.target`;
@@ -802,7 +800,7 @@ function readServiceManagerPid(platform = os.platform()) {
802
800
  return null;
803
801
  }
804
802
  /** Start the daemon via launchd, systemd, or as a detached process. */
805
- export function startDaemon() {
803
+ export function startDaemon(agentsBin) {
806
804
  if (isDaemonRunning()) {
807
805
  const pid = readDaemonPid();
808
806
  return { pid, method: 'already-running' };
@@ -814,7 +812,7 @@ export function startDaemon() {
814
812
  return { pid, method: 'already-starting' };
815
813
  }
816
814
  try {
817
- return startDaemonLocked();
815
+ return startDaemonLocked(agentsBin ?? getAgentsBinPath());
818
816
  }
819
817
  finally {
820
818
  releaseLock();
@@ -838,7 +836,7 @@ export function ensureDaemonStarted() {
838
836
  return null;
839
837
  }
840
838
  }
841
- function startDaemonLocked() {
839
+ function startDaemonLocked(agentsBin) {
842
840
  const platform = os.platform();
843
841
  if (platform === 'darwin') {
844
842
  try {
@@ -849,7 +847,7 @@ function startDaemonLocked() {
849
847
  }
850
848
  // The plist may embed a long-lived OAuth token in EnvironmentVariables;
851
849
  // create owner-only atomically (no world-readable window before chmod).
852
- writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist());
850
+ writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist(undefined, agentsBin));
853
851
  try {
854
852
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
855
853
  }
@@ -867,7 +865,7 @@ function startDaemonLocked() {
867
865
  catch {
868
866
  // load threw — fall through to detached spawn
869
867
  }
870
- return startDetached();
868
+ return startDetached({ agentsBin });
871
869
  }
872
870
  if (platform === 'linux') {
873
871
  try {
@@ -877,7 +875,7 @@ function startDaemonLocked() {
877
875
  fs.mkdirSync(unitDir, { recursive: true });
878
876
  }
879
877
  // May embed a long-lived OAuth token in an Environment= line; owner-only.
880
- writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit());
878
+ writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit(undefined, agentsBin));
881
879
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
882
880
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
883
881
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
@@ -890,9 +888,9 @@ function startDaemonLocked() {
890
888
  catch {
891
889
  // start threw — fall through to detached spawn
892
890
  }
893
- return startDetached();
891
+ return startDetached({ agentsBin });
894
892
  }
895
- return startDetached();
893
+ return startDetached({ agentsBin });
896
894
  }
897
895
  /**
898
896
  * Environment for the detached daemon fallback. The launchd/systemd paths
@@ -925,18 +923,68 @@ export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readD
925
923
  * Going through `process.execPath` means a real PE/binary is spawned with
926
924
  * `detached: true` and no console, so nothing signals the daemon after launch.
927
925
  *
928
- * When the entry isn't a JS file (e.g. a native launcher resolved via
929
- * `which agents`), run it directly — it owns its own runtime resolution.
926
+ * When the entry isn't a Node script (e.g. a native compiled launcher), run it
927
+ * directly — it owns its own runtime resolution.
930
928
  */
931
929
  export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
932
930
  const { warnings } = validateDaemonBinary(agentsBin);
933
931
  for (const w of warnings)
934
932
  process.stderr.write(`[agents] ${w}\n`);
935
- if (/\.(c|m)?js$/.test(agentsBin)) {
933
+ if (isNodeScriptEntry(agentsBin)) {
936
934
  return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
937
935
  }
938
936
  return { command: agentsBin, args: ['daemon', '_run'] };
939
937
  }
938
+ /**
939
+ * A daemon entry must be launched through the Node runtime when it is a Node
940
+ * script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
941
+ * shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
942
+ * (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
943
+ * extension check alone misses them and they get run directly. Executing such an
944
+ * entry then relies on the shebang resolving `node` off the daemon's PATH — and
945
+ * when that PATH points at a pruned nvm version (or an ancient system node), the
946
+ * daemon crash-loops at import (`node:util` has no `styleText` on Node 18). A
947
+ * real compiled binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the
948
+ * direct branch and owns its own runtime resolution.
949
+ */
950
+ function isNodeScriptEntry(agentsBin) {
951
+ let resolved = agentsBin;
952
+ try {
953
+ resolved = fs.realpathSync(agentsBin);
954
+ }
955
+ catch {
956
+ // Unresolvable (e.g. a template path that does not exist on this box): fall
957
+ // back to the extension check on the path as given.
958
+ }
959
+ if (/\.(c|m)?js$/.test(resolved))
960
+ return true;
961
+ try {
962
+ const fd = fs.openSync(resolved, 'r');
963
+ try {
964
+ const buf = Buffer.alloc(128);
965
+ const n = fs.readSync(fd, buf, 0, 128, 0);
966
+ const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
967
+ return firstLine.startsWith('#!') && /\bnode\b/.test(firstLine);
968
+ }
969
+ finally {
970
+ fs.closeSync(fd);
971
+ }
972
+ }
973
+ catch {
974
+ return false;
975
+ }
976
+ }
977
+ /**
978
+ * The directory of the Node runtime that generated this service manifest, kept
979
+ * first on the daemon's PATH. Both the shim's shebang and any child routine
980
+ * process then resolve the exact Node that installed the service — never an
981
+ * ancient system node or a pruned nvm version. Replaces the old hardcoded
982
+ * `~/.nvm/versions/node/v24.0.0/bin`, which went stale the moment that patch
983
+ * release was upgraded away and bricked the daemon fleet-wide.
984
+ */
985
+ function daemonNodeBinDir() {
986
+ return path.dirname(process.execPath);
987
+ }
940
988
  /**
941
989
  * Build the argv to relaunch the `agents` CLI with the given subcommand args.
942
990
  *