@phnx-labs/agents-cli 1.20.56 → 1.20.58

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 (47) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +34 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +28 -4
  6. package/dist/commands/secrets.d.ts +3 -2
  7. package/dist/commands/secrets.js +35 -25
  8. package/dist/commands/teams.d.ts +20 -1
  9. package/dist/commands/teams.js +105 -2
  10. package/dist/commands/versions.js +11 -3
  11. package/dist/commands/view.js +19 -4
  12. package/dist/lib/agents.d.ts +21 -0
  13. package/dist/lib/agents.js +28 -4
  14. package/dist/lib/daemon.d.ts +5 -5
  15. package/dist/lib/daemon.js +88 -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/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  21. package/dist/lib/permissions.d.ts +19 -1
  22. package/dist/lib/permissions.js +137 -0
  23. package/dist/lib/project-root.d.ts +65 -0
  24. package/dist/lib/project-root.js +133 -0
  25. package/dist/lib/resources/permissions.js +2 -0
  26. package/dist/lib/resources/types.d.ts +1 -1
  27. package/dist/lib/secrets/agent.d.ts +48 -18
  28. package/dist/lib/secrets/agent.js +288 -165
  29. package/dist/lib/secrets/remote.js +1 -0
  30. package/dist/lib/session/active.d.ts +3 -0
  31. package/dist/lib/session/active.js +1 -0
  32. package/dist/lib/session/parse.js +38 -15
  33. package/dist/lib/session/state.d.ts +4 -1
  34. package/dist/lib/session/state.js +18 -1
  35. package/dist/lib/session/types.d.ts +8 -0
  36. package/dist/lib/staleness/detectors/permissions.js +42 -0
  37. package/dist/lib/staleness/detectors/subagents.js +30 -0
  38. package/dist/lib/staleness/writers/subagents.js +13 -1
  39. package/dist/lib/subagents.d.ts +22 -0
  40. package/dist/lib/subagents.js +146 -0
  41. package/dist/lib/teams/agents.d.ts +30 -0
  42. package/dist/lib/teams/agents.js +271 -42
  43. package/dist/lib/types.d.ts +13 -0
  44. package/dist/lib/versions.d.ts +39 -0
  45. package/dist/lib/versions.js +199 -12
  46. package/package.json +1 -1
  47. package/scripts/postinstall.js +26 -11
@@ -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);
@@ -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. */
@@ -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',
@@ -661,6 +661,30 @@ export const AGENTS = {
661
661
  };
662
662
  /** All registered agent IDs derived from the AGENTS registry. */
663
663
  export const ALL_AGENT_IDS = Object.keys(AGENTS);
664
+ /**
665
+ * A self-updating agent is a single global binary installed by an official
666
+ * `curl … | sh` / `brew install` script that carries NO version token — the
667
+ * installer can only ever fetch the *current* release, and the binary then keeps
668
+ * itself up to date in place (droid, grok, antigravity, cursor, hermes, forge,
669
+ * kiro, goose). There is no semver to pin, so agents-cli must not model these as
670
+ * having multiple installable version-homes the way it does for npm-packaged
671
+ * agents (claude, codex, kimi, …).
672
+ *
673
+ * The predicate is `!npmPackage && installScript && !installScript.includes('VERSION')`:
674
+ * - `npmPackage` empty → not installed from npm, so `agents add x@1.2.3`
675
+ * can't resolve a registry version.
676
+ * - `installScript` present → it IS installed by a script (not unmanaged).
677
+ * - no `VERSION` placeholder → the script has no slot for a pinned version
678
+ * (contrast: an installer templated with `VERSION`
679
+ * could pin, and is NOT self-updating).
680
+ *
681
+ * Route every "is this a pinnable, multi-version agent?" decision through here —
682
+ * never a scattered `agent === 'droid'`.
683
+ */
684
+ export function isSelfUpdatingAgent(agent) {
685
+ const cfg = AGENTS[agent];
686
+ return !cfg.npmPackage && !!cfg.installScript && !cfg.installScript.includes('VERSION');
687
+ }
664
688
  // Capability-filtered agent lists used to live here as `*_CAPABLE_AGENTS`
665
689
  // constants. They were a frequent source of silent-skip bugs (e.g. grok
666
690
  // rules sync gated on `COMMANDS_CAPABLE_AGENTS`). Use `capableAgents(cap)`
@@ -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;
@@ -306,6 +306,28 @@ export async function runDaemon() {
306
306
  catch (err) {
307
307
  log('ERROR', `Stray daemon reaper failed: ${err.message}`);
308
308
  }
309
+ // #416: host the secrets broker socket-first — before the scheduler and the
310
+ // heavy browser/session-sync services — so `agents secrets` resolves within
311
+ // ms of daemon start. Only host when no broker is already reachable, so we
312
+ // never orphan a live standalone broker's clients (that broker stays the
313
+ // server until it idle-exits or the daemon restarts). Best-effort: a failure
314
+ // here must not stop the daemon. Retiring the standalone launchd service is
315
+ // the follow-on (#416 step 2 / #417).
316
+ let hostedBroker = null;
317
+ try {
318
+ const { agentPing, startHostedBroker } = await import('./secrets/agent.js');
319
+ if ((await agentPing()).reachable) {
320
+ log('INFO', 'Secrets broker already running (standalone); daemon not hosting it');
321
+ }
322
+ else {
323
+ hostedBroker = await startHostedBroker();
324
+ if (hostedBroker)
325
+ log('INFO', 'Secrets broker hosted in daemon (socket-first)');
326
+ }
327
+ }
328
+ catch (err) {
329
+ log('WARN', `Secrets broker host skipped: ${err.message}`);
330
+ }
309
331
  const scheduler = new JobScheduler(async (config) => {
310
332
  log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
311
333
  try {
@@ -594,6 +616,7 @@ export async function runDaemon() {
594
616
  clearTimeout(tmuxReconcileKickoff);
595
617
  clearInterval(launchHealthInterval);
596
618
  clearTimeout(launchHealthKickoff);
619
+ hostedBroker?.close();
597
620
  removeDaemonPid();
598
621
  removeHeartbeat();
599
622
  process.exit(0);
@@ -651,8 +674,7 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
651
674
  fs.writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
652
675
  }
653
676
  /** Generate a macOS launchd plist for auto-starting the daemon. */
654
- export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken()) {
655
- const agentsBin = getAgentsBinPath();
677
+ export function generateLaunchdPlist(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
656
678
  const launch = getDaemonLaunch(agentsBin);
657
679
  const logPath = getLogPath();
658
680
  const oauthEntry = oauthToken
@@ -681,7 +703,7 @@ ${[launch.command, ...launch.args].map((arg) => ` <string>${xmlEscape(arg)}</
681
703
  <key>EnvironmentVariables</key>
682
704
  <dict>
683
705
  <key>PATH</key>
684
- <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}
685
707
  </dict>
686
708
  </dict>
687
709
  </plist>`;
@@ -691,8 +713,7 @@ function systemdExecArg(value) {
691
713
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
692
714
  }
693
715
  /** Generate a Linux systemd user unit for auto-starting the daemon. */
694
- export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken()) {
695
- const agentsBin = getAgentsBinPath();
716
+ export function generateSystemdUnit(oauthToken = readDaemonClaudeOAuthToken(), agentsBin = getAgentsBinPath()) {
696
717
  const launch = getDaemonLaunch(agentsBin);
697
718
  const execStart = [launch.command, ...launch.args].map(systemdExecArg).join(' ');
698
719
  const oauthLine = oauthToken
@@ -707,7 +728,7 @@ Type=simple
707
728
  ExecStart=${execStart}
708
729
  Restart=always
709
730
  RestartSec=10
710
- 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}
711
732
 
712
733
  [Install]
713
734
  WantedBy=default.target`;
@@ -779,7 +800,7 @@ function readServiceManagerPid(platform = os.platform()) {
779
800
  return null;
780
801
  }
781
802
  /** Start the daemon via launchd, systemd, or as a detached process. */
782
- export function startDaemon() {
803
+ export function startDaemon(agentsBin) {
783
804
  if (isDaemonRunning()) {
784
805
  const pid = readDaemonPid();
785
806
  return { pid, method: 'already-running' };
@@ -791,7 +812,7 @@ export function startDaemon() {
791
812
  return { pid, method: 'already-starting' };
792
813
  }
793
814
  try {
794
- return startDaemonLocked();
815
+ return startDaemonLocked(agentsBin ?? getAgentsBinPath());
795
816
  }
796
817
  finally {
797
818
  releaseLock();
@@ -815,7 +836,7 @@ export function ensureDaemonStarted() {
815
836
  return null;
816
837
  }
817
838
  }
818
- function startDaemonLocked() {
839
+ function startDaemonLocked(agentsBin) {
819
840
  const platform = os.platform();
820
841
  if (platform === 'darwin') {
821
842
  try {
@@ -826,7 +847,7 @@ function startDaemonLocked() {
826
847
  }
827
848
  // The plist may embed a long-lived OAuth token in EnvironmentVariables;
828
849
  // create owner-only atomically (no world-readable window before chmod).
829
- writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist());
850
+ writeOwnerOnlyServiceManifest(plistPath, generateLaunchdPlist(undefined, agentsBin));
830
851
  try {
831
852
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
832
853
  }
@@ -844,7 +865,7 @@ function startDaemonLocked() {
844
865
  catch {
845
866
  // load threw — fall through to detached spawn
846
867
  }
847
- return startDetached();
868
+ return startDetached({ agentsBin });
848
869
  }
849
870
  if (platform === 'linux') {
850
871
  try {
@@ -854,7 +875,7 @@ function startDaemonLocked() {
854
875
  fs.mkdirSync(unitDir, { recursive: true });
855
876
  }
856
877
  // May embed a long-lived OAuth token in an Environment= line; owner-only.
857
- writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit());
878
+ writeOwnerOnlyServiceManifest(unitPath, generateSystemdUnit(undefined, agentsBin));
858
879
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
859
880
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
860
881
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
@@ -867,9 +888,9 @@ function startDaemonLocked() {
867
888
  catch {
868
889
  // start threw — fall through to detached spawn
869
890
  }
870
- return startDetached();
891
+ return startDetached({ agentsBin });
871
892
  }
872
- return startDetached();
893
+ return startDetached({ agentsBin });
873
894
  }
874
895
  /**
875
896
  * Environment for the detached daemon fallback. The launchd/systemd paths
@@ -902,18 +923,68 @@ export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readD
902
923
  * Going through `process.execPath` means a real PE/binary is spawned with
903
924
  * `detached: true` and no console, so nothing signals the daemon after launch.
904
925
  *
905
- * When the entry isn't a JS file (e.g. a native launcher resolved via
906
- * `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.
907
928
  */
908
929
  export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
909
930
  const { warnings } = validateDaemonBinary(agentsBin);
910
931
  for (const w of warnings)
911
932
  process.stderr.write(`[agents] ${w}\n`);
912
- if (/\.(c|m)?js$/.test(agentsBin)) {
933
+ if (isNodeScriptEntry(agentsBin)) {
913
934
  return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
914
935
  }
915
936
  return { command: agentsBin, args: ['daemon', '_run'] };
916
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
+ }
917
988
  /**
918
989
  * Build the argv to relaunch the `agents` CLI with the given subcommand args.
919
990
  *
package/dist/lib/git.d.ts CHANGED
@@ -107,6 +107,15 @@ export declare function isGitRepo(dir: string): boolean;
107
107
  * {@link isGitRepo} above). Throws if `dir` is not inside a git repository.
108
108
  */
109
109
  export declare function getGitRoot(dir: string): Promise<string>;
110
+ /**
111
+ * Return the absolute path to the **main** working-tree root for `dir`.
112
+ *
113
+ * Unlike {@link getGitRoot}, this stays correct when `dir` is inside a *linked*
114
+ * worktree: `--show-toplevel` there returns the worktree's own path, but the
115
+ * common git dir (`--git-common-dir`) always points at the primary repo's
116
+ * `.git`, whose parent is the main checkout. Throws if `dir` is not in a repo.
117
+ */
118
+ export declare function getMainRepoRoot(dir: string): Promise<string>;
110
119
  /**
111
120
  * Initialize a git repo in an existing directory.
112
121
  */
package/dist/lib/git.js CHANGED
@@ -427,6 +427,18 @@ export async function getGitRoot(dir) {
427
427
  const root = await simpleGit(dir).revparse(['--show-toplevel']);
428
428
  return root.trim();
429
429
  }
430
+ /**
431
+ * Return the absolute path to the **main** working-tree root for `dir`.
432
+ *
433
+ * Unlike {@link getGitRoot}, this stays correct when `dir` is inside a *linked*
434
+ * worktree: `--show-toplevel` there returns the worktree's own path, but the
435
+ * common git dir (`--git-common-dir`) always points at the primary repo's
436
+ * `.git`, whose parent is the main checkout. Throws if `dir` is not in a repo.
437
+ */
438
+ export async function getMainRepoRoot(dir) {
439
+ const common = await simpleGit(dir).raw(['rev-parse', '--path-format=absolute', '--git-common-dir']);
440
+ return path.dirname(common.trim());
441
+ }
430
442
  /**
431
443
  * Initialize a git repo in an existing directory.
432
444
  */
@@ -10,11 +10,32 @@
10
10
  */
11
11
  import type { Host } from './types.js';
12
12
  import { type HostTask } from './tasks.js';
13
+ /**
14
+ * Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
15
+ *
16
+ * A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
17
+ * the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
18
+ * for that segment — the remote login shell expands it — and shell-quote the
19
+ * remainder. Any other path (absolute or relative) is quoted verbatim.
20
+ */
21
+ export declare function remoteCdPrefix(remoteCwd?: string): string;
22
+ /**
23
+ * Launch a detached login-shell command in its own Unix session/process group.
24
+ *
25
+ * Node is already a hard requirement for a host that can run `agents`. Its
26
+ * `detached: true` contract calls setsid(2) on Unix, unlike `nohup ... &` under
27
+ * a non-interactive shell where the background wrapper can remain in the SSH
28
+ * shell's process group. Returning the group leader PID makes `kill(-pid)` a
29
+ * reliable whole-tree operation for both normal stops and rollback cleanup.
30
+ */
31
+ export declare function buildDetachedLaunchCommand(inner: string): string;
13
32
  export interface DispatchResult {
14
33
  task: HostTask;
15
34
  /** Exit code when followed; undefined when detached (--no-follow). */
16
35
  exitCode?: number;
17
36
  }
37
+ /** Terminate a detached dispatch that its caller could not persist locally. */
38
+ export declare function terminateDispatchedTask(task: HostTask): void;
18
39
  export interface DispatchOptions {
19
40
  agent: string;
20
41
  prompt: string;
@@ -20,6 +20,77 @@ import { followHostTask } from './progress.js';
20
20
  // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
21
21
  // injection-safe to interpolate unquoted into remote commands.
22
22
  const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
23
+ /**
24
+ * If `p` is anchored at the home dir — a leading `~` or `$HOME` — return the
25
+ * remainder (no leading slash), else null. Callers that want a local-home
26
+ * absolute (`/Users/<me>/x`, from a shell-expanded `--cwd ~/x`) re-rooted at the
27
+ * remote home normalize it to `~/x` first (`toRemotePortable`); explicit
28
+ * `--remote-cwd` is left literal and so is never re-rooted here.
29
+ */
30
+ function homeRemainder(p) {
31
+ if (p === '~' || p === '$HOME')
32
+ return '';
33
+ if (p.startsWith('~/'))
34
+ return p.slice(2);
35
+ if (p.startsWith('$HOME/'))
36
+ return p.slice(6);
37
+ return null;
38
+ }
39
+ /**
40
+ * Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
41
+ *
42
+ * A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
43
+ * the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
44
+ * for that segment — the remote login shell expands it — and shell-quote the
45
+ * remainder. Any other path (absolute or relative) is quoted verbatim.
46
+ */
47
+ export function remoteCdPrefix(remoteCwd) {
48
+ if (!remoteCwd)
49
+ return '';
50
+ const rest = homeRemainder(remoteCwd);
51
+ if (rest === '')
52
+ return 'cd "$HOME" && ';
53
+ if (rest !== null)
54
+ return `cd "$HOME"/${shellQuote(rest)} && `;
55
+ return `cd ${shellQuote(remoteCwd)} && `;
56
+ }
57
+ /**
58
+ * Launch a detached login-shell command in its own Unix session/process group.
59
+ *
60
+ * Node is already a hard requirement for a host that can run `agents`. Its
61
+ * `detached: true` contract calls setsid(2) on Unix, unlike `nohup ... &` under
62
+ * a non-interactive shell where the background wrapper can remain in the SSH
63
+ * shell's process group. Returning the group leader PID makes `kill(-pid)` a
64
+ * reliable whole-tree operation for both normal stops and rollback cleanup.
65
+ */
66
+ export function buildDetachedLaunchCommand(inner) {
67
+ const nodeScript = [
68
+ "const { spawn } = require('node:child_process');",
69
+ `const child = spawn('/bin/bash', ['-lc', ${JSON.stringify(inner)}], { detached: true, stdio: 'ignore' });`,
70
+ "child.once('error', error => { console.error(error.message); process.exitCode = 1; });",
71
+ "child.once('spawn', () => { console.log(child.pid); child.unref(); });",
72
+ ].join(' ');
73
+ return `bash -lc ${shellQuote(`node -e ${shellQuote(nodeScript)}`)}`;
74
+ }
75
+ function terminateRemoteLaunch(task) {
76
+ if (!task.pid)
77
+ throw new Error(`Cannot terminate remote task ${task.id}: launch returned no PID.`);
78
+ const pid = task.pid;
79
+ const command = `if kill -TERM -- -${pid} 2>/dev/null; then ` +
80
+ `sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
81
+ `elif kill -0 -- -${pid} 2>/dev/null; then exit 1; fi; ` +
82
+ `rm -f ${task.remoteLog} ${task.remoteExit}`;
83
+ const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
84
+ if (result.code !== 0) {
85
+ throw new Error(`Failed to terminate remote task ${task.id} on ${task.host}: ` +
86
+ `${(result.stderr || result.stdout).trim() || 'ssh error'}`);
87
+ }
88
+ }
89
+ /** Terminate a detached dispatch that its caller could not persist locally. */
90
+ export function terminateDispatchedTask(task) {
91
+ terminateRemoteLaunch(task);
92
+ updateTask(task.id, terminalPatch(143));
93
+ }
23
94
  /**
24
95
  * The launch + task-record + optional follow core. Both `dispatchToHost` (run)
25
96
  * and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
@@ -45,10 +116,11 @@ async function launchDetached(host, target, opts) {
45
116
  const remoteExit = `${REMOTE_DIR}/${id}.exit`;
46
117
  // Inner command run under a login shell so PATH resolves `agents`.
47
118
  const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
48
- const cwd = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
119
+ const cwd = remoteCdPrefix(opts.remoteCwd);
49
120
  const inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
50
- // Outer: ensure dir, launch detached under bash -lc, print the PID.
51
- const launch = `mkdir -p ${REMOTE_DIR}; nohup bash -lc ${shellQuote(inner)} >/dev/null 2>&1 & echo $!`;
121
+ // Outer: ensure dir, launch the login-shell wrapper as a new process-group
122
+ // leader, and print that leader PID.
123
+ const launch = `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
52
124
  const res = sshExec(target, launch, { timeoutMs: 30000, multiplex: true });
53
125
  if (res.code !== 0) {
54
126
  throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
@@ -68,7 +140,18 @@ async function launchDetached(host, target, opts) {
68
140
  status: 'running',
69
141
  createdAt: new Date().toISOString(),
70
142
  };
71
- saveTask(task);
143
+ try {
144
+ saveTask(task);
145
+ }
146
+ catch (err) {
147
+ try {
148
+ terminateRemoteLaunch(task);
149
+ }
150
+ catch (cleanupErr) {
151
+ throw new Error(`Failed to persist remote task ${task.id}; cleanup also failed: ${cleanupErr.message}`, { cause: err });
152
+ }
153
+ throw err;
154
+ }
72
155
  if (opts.follow === false) {
73
156
  return { task };
74
157
  }
@@ -146,7 +229,7 @@ export async function runInteractiveOnHost(host, opts) {
146
229
  for (const w of warnings)
147
230
  process.stderr.write(`[hosts] warning: ${w}\n`);
148
231
  const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
149
- const cwd = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
232
+ const cwd = remoteCdPrefix(opts.remoteCwd);
150
233
  const remoteCmd = `${cwd}${invocation}`;
151
234
  return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
152
235
  }
@@ -1,4 +1,4 @@
1
- import type { AgentId, PermissionSet, InstalledPermission, ClaudePermissions, OpenCodePermissions, CodexPermissions } from './types.js';
1
+ import type { AgentId, PermissionSet, InstalledPermission, ClaudePermissions, CursorPermissions, OpenCodePermissions, CodexPermissions } from './types.js';
2
2
  /** Filename used for Codex Starlark deny-rules generated from permission groups. */
3
3
  export declare const CODEX_RULES_FILENAME = "agents-deny.rules";
4
4
  export type ParsedRules = PermissionSet;
@@ -99,6 +99,11 @@ export declare function removePermissionSet(name: string): {
99
99
  * Claude uses: { permissions: { allow: ["Bash(*)", "Read(**)"], deny: [] } }
100
100
  */
101
101
  export declare function convertToClaudeFormat(set: PermissionSet): ClaudePermissions;
102
+ /**
103
+ * Convert canonical permission set to Cursor CLI format
104
+ * (`~/.cursor/cli-config.json` permissions.allow/deny).
105
+ */
106
+ export declare function convertToCursorFormat(set: PermissionSet): CursorPermissions;
102
107
  /**
103
108
  * Convert canonical permission set to Gemini format.
104
109
  * Gemini reads tool allow-lists from settings.json under `tools.allowed`.
@@ -152,6 +157,19 @@ export type KimiRule = {
152
157
  decision: 'allow' | 'deny';
153
158
  pattern: string;
154
159
  };
160
+ export type KiroRule = {
161
+ capability: string;
162
+ effect: 'allow' | 'deny';
163
+ match?: string[];
164
+ exclude?: string[];
165
+ };
166
+ /**
167
+ * Convert canonical permissions to Kiro CLI v3 capability rules.
168
+ * Kiro stores these rules in ~/.kiro/settings/permissions.yaml.
169
+ */
170
+ export declare function convertToKiroFormat(set: PermissionSet): {
171
+ rules: KiroRule[];
172
+ };
155
173
  /**
156
174
  * Convert a canonical permission set to Kimi Code's `[permission].rules` format.
157
175
  * Kimi (`~/.kimi-code/config.toml`) reads rules of the form