@phnx-labs/agents-cli 1.20.67 → 1.20.68

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 +55 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/check.js +96 -2
  5. package/dist/commands/doctor.js +37 -9
  6. package/dist/commands/exec.js +39 -1
  7. package/dist/commands/plugins.js +11 -2
  8. package/dist/commands/repo.js +47 -2
  9. package/dist/commands/ssh.js +103 -2
  10. package/dist/commands/teams.d.ts +11 -0
  11. package/dist/commands/teams.js +39 -1
  12. package/dist/commands/view.d.ts +1 -0
  13. package/dist/commands/view.js +12 -8
  14. package/dist/lib/agents.d.ts +11 -5
  15. package/dist/lib/agents.js +27 -24
  16. package/dist/lib/browser/profiles.js +4 -25
  17. package/dist/lib/cli-entry.d.ts +23 -0
  18. package/dist/lib/cli-entry.js +116 -0
  19. package/dist/lib/daemon.d.ts +2 -2
  20. package/dist/lib/daemon.js +8 -89
  21. package/dist/lib/devices/fleet.d.ts +23 -0
  22. package/dist/lib/devices/fleet.js +38 -0
  23. package/dist/lib/devices/health-report.d.ts +46 -0
  24. package/dist/lib/devices/health-report.js +159 -0
  25. package/dist/lib/devices/health.d.ts +14 -4
  26. package/dist/lib/devices/health.js +49 -8
  27. package/dist/lib/git.d.ts +10 -0
  28. package/dist/lib/git.js +23 -0
  29. package/dist/lib/hosts/option.js +1 -1
  30. package/dist/lib/hosts/ready.js +5 -3
  31. package/dist/lib/hosts/remote-cmd.d.ts +12 -0
  32. package/dist/lib/hosts/remote-cmd.js +17 -1
  33. package/dist/lib/mcp.d.ts +21 -0
  34. package/dist/lib/mcp.js +278 -13
  35. package/dist/lib/resources/mcp.js +20 -342
  36. package/dist/lib/routines.d.ts +13 -0
  37. package/dist/lib/routines.js +21 -0
  38. package/dist/lib/runner.js +18 -40
  39. package/dist/lib/secrets/agent.js +11 -15
  40. package/dist/lib/share/capture.js +21 -3
  41. package/dist/lib/signin-badge.d.ts +41 -0
  42. package/dist/lib/signin-badge.js +64 -0
  43. package/dist/lib/ssh-exec.d.ts +5 -0
  44. package/dist/lib/ssh-exec.js +55 -1
  45. package/dist/lib/usage.d.ts +17 -3
  46. package/dist/lib/usage.js +63 -16
  47. package/package.json +1 -1
@@ -809,6 +809,23 @@ async function pickTeamOr(mgr, command) {
809
809
  throw err;
810
810
  }
811
811
  }
812
+ /**
813
+ * `teams add --remote-cwd` is a no-op trap. `--remote-cwd` comes from the shared
814
+ * `--host` option family (option.ts) and is meaningful for commands that *route*
815
+ * to a host, but `teams add` special-cases `--host`/`--device` as PLACEMENT, so
816
+ * the flag is never read — a teammate's directory is the team's repo plus its
817
+ * `--worktree`, not a path passed here. Silently ignoring it misleads you into
818
+ * thinking it set the teammate's repo path (the exact wrong model that turns one
819
+ * team into a teardown-and-rebuild). Reject with guidance instead. Exported for
820
+ * the unit test that pins the message.
821
+ */
822
+ export function remoteCwdOnAddError(team) {
823
+ return (`--remote-cwd has no effect on 'teams add' — it does not set a teammate's repo or directory.\n` +
824
+ ` A teammate works in the team's repo plus its own --worktree:\n` +
825
+ ` • Set the repo once, on the team: agents teams create ${team} --repo <url|path>\n` +
826
+ ` • Place the teammate on a machine: agents teams add ${team} <agent> "<task>" --device <host> --worktree <name>\n` +
827
+ ` (Remote worktrees fork from the host's freshly-fetched origin/<default> automatically.)`);
828
+ }
812
829
  /** Register the `agents teams` command tree (list, create, add, status, start, remove, disband, logs, doctor). */
813
830
  export function registerTeamsCommands(program) {
814
831
  const teams = program
@@ -853,6 +870,20 @@ export function registerTeamsCommands(program) {
853
870
  '<profile>' a profile from 'agents view' — runs through 'agents
854
871
  run <profile>' with the profile's host harness
855
872
 
873
+ Placement & repos (the part people get wrong):
874
+ --remote-cwd does NOT place a teammate or set its repo — it is ignored on
875
+ 'teams add' (and rejected, so you find out immediately). A teammate's
876
+ directory is the team's repo plus its --worktree:
877
+ --device <host> run THIS teammate on <host>
878
+ create --repo <r> ONE repo for the whole team (defaults to this
879
+ checkout's origin). Work spanning repos → one team
880
+ per repo — don't build a cross-repo team then rebuild.
881
+ With --enable-worktrees each teammate gets its own worktree + branch:
882
+ local teammate forks from your CURRENT local HEAD — no fetch, so
883
+ pull/sync the checkout first or it forks stale
884
+ remote (--device) forks from the freshly-fetched origin/<default> on
885
+ the host (no manual sync needed)
886
+
856
887
  Short aliases:
857
888
  teams c = create teams a = add teams s = status
858
889
  teams rm = remove teams d = disband teams ls = list
@@ -978,7 +1009,7 @@ export function registerTeamsCommands(program) {
978
1009
  .option('--use-worktree <path>', 'All teammates share this existing worktree path (mutually exclusive with --enable-worktrees)')
979
1010
  .option('--devices <list>', 'Pool of machines this team may run teammates on (comma-separated). Enables distributed auto-scheduling.')
980
1011
  .option('--hosts <list>', 'Alias for --devices.')
981
- .option('--repo <urlOrPath>', 'How each device gets the code (git URL to clone, or a path). Defaults to the local checkout origin.')
1012
+ .option('--repo <urlOrPath>', 'How each remote (--device) teammate gets the code — ONE git URL/path for the whole team (existing checkout reused, else cloned). A team is single-repo; for work across repos, make one team per repo. Defaults to this checkout origin.')
982
1013
  .option('--json', 'Output machine-readable JSON')
983
1014
  .action(async (team, opts) => {
984
1015
  try {
@@ -1073,6 +1104,13 @@ export function registerTeamsCommands(program) {
1073
1104
  .option('--force', "Skip the advisory 'may not be signed in' / 'account throttled' warnings")
1074
1105
  .option('--json', 'Output machine-readable JSON')
1075
1106
  .action(async (team, teammate, task, opts) => {
1107
+ // `--remote-cwd` rides the shared --host option family but is never read by
1108
+ // `teams add` (placement, not routing). Fail loud with guidance rather than
1109
+ // silently ignoring it — see remoteCwdOnAddError. `!== undefined` so even an
1110
+ // explicit empty value (`--remote-cwd ""`) is rejected, not silently dropped.
1111
+ if (opts.remoteCwd !== undefined) {
1112
+ die(remoteCwdOnAddError(team));
1113
+ }
1076
1114
  if (!VALID_MODES.includes(opts.mode)) {
1077
1115
  die(`Invalid mode '${opts.mode}'. Use one of: ${VALID_MODES.join(', ')}`);
1078
1116
  }
@@ -133,6 +133,7 @@ export declare function viewAction(agentArg?: string, options?: {
133
133
  dryRun?: boolean;
134
134
  resources?: string | boolean;
135
135
  detailed?: boolean;
136
+ refresh?: boolean;
136
137
  } & ViewSectionFilter): Promise<void>;
137
138
  /** Register the `agents view` command. */
138
139
  export declare function registerViewCommand(program: Command): void;
@@ -5,7 +5,8 @@ import ora from 'ora';
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import { AGENTS, ALL_AGENT_IDS, accountOrgBadge, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
8
- import { deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
8
+ import { loginHint } from '../lib/signin-badge.js';
9
+ import { agentReportsUsage, deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
9
10
  import { readManifest } from '../lib/manifest.js';
10
11
  import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, } from '../lib/versions.js';
11
12
  import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
@@ -244,7 +245,7 @@ function renderHostClisSection(cwd) {
244
245
  console.log(` ${chalk.red('error')} ${chalk.gray(err.file)}: ${chalk.gray(err.reason)}`);
245
246
  }
246
247
  }
247
- async function showInstalledVersions(filterAgentId) {
248
+ async function showInstalledVersions(filterAgentId, viewOpts) {
248
249
  const spinnerText = filterAgentId
249
250
  ? `Checking ${agentLabel(filterAgentId)} agents...`
250
251
  : 'Checking installed agents...';
@@ -326,7 +327,7 @@ async function showInstalledVersions(filterAgentId) {
326
327
  cliVersion,
327
328
  info,
328
329
  })),
329
- ]);
330
+ ], { forceRefresh: viewOpts?.forceRefresh });
330
331
  const mergeCanonical = (info) => {
331
332
  const key = getUsageLookupKey(info);
332
333
  if (!key)
@@ -417,7 +418,8 @@ async function showInstalledVersions(filterAgentId) {
417
418
  const info = rawInfo ? mergeCanonical(rawInfo) : undefined;
418
419
  const usageKey = getUsageLookupKey(info);
419
420
  const usageInfo = usageKey ? usageByKey.get(usageKey) : undefined;
420
- const usageStr = formatUsageSummary(info?.plan || null, usageInfo?.snapshot || null, maxPlanWidth);
421
+ const usageUnavailable = agentReportsUsage(agentId) && !!info?.signedIn && !usageInfo?.snapshot;
422
+ const usageStr = formatUsageSummary(info?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, { unavailable: usageUnavailable });
421
423
  maxUsageWidth = Math.max(maxUsageWidth, visibleWidth(usageStr));
422
424
  const statusStr = formatUsageStatusBadge(info?.usageStatus);
423
425
  maxStatusWidth = Math.max(maxStatusWidth, visibleWidth(statusStr));
@@ -457,7 +459,8 @@ async function showInstalledVersions(filterAgentId) {
457
459
  const parts = [` ${label}`];
458
460
  const hasEmail = !!vInfo?.email;
459
461
  const signedIn = !!vInfo?.signedIn;
460
- const usageStr = formatUsageSummary(vInfo?.plan || null, usageInfo?.snapshot || null, maxPlanWidth);
462
+ const usageUnavailable = agentReportsUsage(agentId) && signedIn && !usageInfo?.snapshot;
463
+ const usageStr = formatUsageSummary(vInfo?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, { unavailable: usageUnavailable });
461
464
  const hasUsage = usageStr.length > 0;
462
465
  // Only show lastActive for versions with an actual logged-in account.
463
466
  // Otherwise it reflects install time (misleading "just now" for fresh installs).
@@ -471,7 +474,7 @@ async function showInstalledVersions(filterAgentId) {
471
474
  runDefaultBits.push(`model:${runDefaults.model}`);
472
475
  if (!hasEmail && !hasUsage && !signedIn) {
473
476
  // Installed but never signed in
474
- parts.push(chalk.gray('(not signed in run ' + agent.cliCommand + ' to log in)'));
477
+ parts.push(chalk.gray('(logged out — log in with: ' + loginHint(agentId) + ')'));
475
478
  }
476
479
  else {
477
480
  if (hasEmail || hasUsage || hasActive || signedIn) {
@@ -1438,7 +1441,7 @@ export async function viewAction(agentArg, options) {
1438
1441
  return;
1439
1442
  }
1440
1443
  // No argument: show all installed versions
1441
- await showInstalledVersions();
1444
+ await showInstalledVersions(undefined, { forceRefresh: options?.refresh === true });
1442
1445
  return;
1443
1446
  }
1444
1447
  // Parse agent@version syntax
@@ -1499,7 +1502,7 @@ export async function viewAction(agentArg, options) {
1499
1502
  }
1500
1503
  else {
1501
1504
  // Just agent name: show versions for that agent
1502
- await showInstalledVersions(agentId);
1505
+ await showInstalledVersions(agentId, { forceRefresh: options?.refresh === true });
1503
1506
  }
1504
1507
  }
1505
1508
  /** Register the `agents view` command. */
@@ -1509,6 +1512,7 @@ export function registerViewCommand(program) {
1509
1512
  .option('--json', 'Emit machine-readable JSON (version list, usage, signed-in status).')
1510
1513
  .option('--resources [sections]', 'In --json mode, include each version\'s resources: "all" (default) or a comma list (skills,plugins,mcp,commands,workflows,memory,hooks). Implies --json.')
1511
1514
  .option('--detailed', 'Include all resources in --json output (alias for --resources all). Implies --json.')
1515
+ .option('-r, --refresh', 'Force a live usage refresh, bypassing the cache (slower). Repopulates the S:/W: limit bars for every account whose token is reachable.')
1512
1516
  .option('--prune', 'Remove older installed versions that share an account with a newer installed version. Skips the global default.')
1513
1517
  .option('--dry-run', 'With --prune, show duplicate versions without deleting')
1514
1518
  .option('-y, --yes', 'Skip the prune confirmation prompt.')
@@ -139,11 +139,13 @@ export interface AccountInfo {
139
139
  */
140
140
  export declare function formatClaudeOrgLabel(orgType: string | null | undefined): string | null;
141
141
  /**
142
- * Short badge identifying which org an account belongs to: "Turing Labs · Team"
143
- * for multi-seat orgs, just the tier label ("Max") for personal plans — a
144
- * personal org's name is auto-generated boilerplate ("<email>'s Organization"),
145
- * not identity. Returns null when the account carries no organizationType
146
- * (signed out, non-Claude agents, configs predating the field).
142
+ * Short badge identifying which ORG an account belongs to "Turing Labs" for a
143
+ * multi-seat Team/Enterprise org, whose name is real identity that disambiguates
144
+ * a Team seat from a same-email personal plan. Returns null for personal plans
145
+ * (Max/Pro/Free): the tier label now lives in the aligned plan column, so a badge
146
+ * would only duplicate it, and a personal org's name is auto-generated boilerplate
147
+ * ("<email>'s Organization"), not identity. Also null when the account carries no
148
+ * organizationType (signed out, non-Claude agents, configs predating the field).
147
149
  */
148
150
  export declare function accountOrgBadge(info?: Pick<AccountInfo, 'organizationType' | 'organizationName'> | null): string | null;
149
151
  /** Return the email address associated with the agent's auth config, or null. */
@@ -303,6 +305,10 @@ export declare function getUserMcpConfigPath(agentId: AgentId): string;
303
305
  * Get MCP config path for a specific HOME directory (used for version-managed agents).
304
306
  */
305
307
  export declare function getMcpConfigPathForHome(agentId: AgentId, home: string): string;
308
+ /**
309
+ * Get project-scoped MCP config path for an agent.
310
+ */
311
+ export declare function getProjectMcpConfigPath(agentId: AgentId, cwd?: string): string;
306
312
  /**
307
313
  * Parse MCP config based on agent type.
308
314
  */
@@ -978,20 +978,19 @@ export function formatClaudeOrgLabel(orgType) {
978
978
  .join(' ');
979
979
  }
980
980
  /**
981
- * Short badge identifying which org an account belongs to: "Turing Labs · Team"
982
- * for multi-seat orgs, just the tier label ("Max") for personal plans — a
983
- * personal org's name is auto-generated boilerplate ("<email>'s Organization"),
984
- * not identity. Returns null when the account carries no organizationType
985
- * (signed out, non-Claude agents, configs predating the field).
981
+ * Short badge identifying which ORG an account belongs to "Turing Labs" for a
982
+ * multi-seat Team/Enterprise org, whose name is real identity that disambiguates
983
+ * a Team seat from a same-email personal plan. Returns null for personal plans
984
+ * (Max/Pro/Free): the tier label now lives in the aligned plan column, so a badge
985
+ * would only duplicate it, and a personal org's name is auto-generated boilerplate
986
+ * ("<email>'s Organization"), not identity. Also null when the account carries no
987
+ * organizationType (signed out, non-Claude agents, configs predating the field).
986
988
  */
987
989
  export function accountOrgBadge(info) {
988
- const label = formatClaudeOrgLabel(info?.organizationType);
989
- if (!label)
990
- return null;
991
990
  const isMultiSeat = info?.organizationType === 'claude_team' || info?.organizationType === 'claude_enterprise';
992
991
  if (isMultiSeat && info?.organizationName)
993
- return `${info.organizationName} · ${label}`;
994
- return label;
992
+ return info.organizationName;
993
+ return null;
995
994
  }
996
995
  /** Return the email address associated with the agent's auth config, or null. */
997
996
  export async function getAccountEmail(agentId, home) {
@@ -1321,19 +1320,23 @@ export async function getAccountInfo(agentId, home) {
1321
1320
  ['org', organizationId],
1322
1321
  ]);
1323
1322
  const usageKey = buildIdentityKey(agentId, [['org', organizationId]]);
1324
- // Plan is derived from .claude.json's billingType only. Reading
1325
- // subscriptionType from the keychain item ("Claude Code-credentials-<hash>")
1326
- // forces a macOS Keychain ACL prompt on every `agents run` (one prompt per
1327
- // installed version under balanced rotation) because Claude Code writes its
1328
- // credentials with its own process in the ACL our helper isn't trusted by
1329
- // that item. Callers that genuinely need subscriptionType (e.g. detailed
1330
- // `agents view`) should call loadClaudeOauth() directly.
1331
- let plan = null;
1332
- if (oa?.billingType === 'stripe_subscription') {
1333
- plan = 'Pro';
1334
- }
1335
- else if (oa?.billingType) {
1336
- plan = oa.billingType;
1323
+ // Plan tier is derived from .claude.json's organizationType, which carries
1324
+ // the TRUE tier (claude_max "Max", claude_pro → "Pro", claude_team →
1325
+ // "Team") and is already in-hand from the config we just read no Keychain
1326
+ // prompt. billingType only distinguishes "has a Stripe subscription" and so
1327
+ // mislabels every Max account as "Pro"; keep it as a fallback for older
1328
+ // configs predating organizationType. (Reading subscriptionType from the
1329
+ // keychain item would force a macOS Keychain ACL prompt on every `agents
1330
+ // run`, so we deliberately avoid it — organizationType gives us the tier
1331
+ // without that cost.)
1332
+ let plan = formatClaudeOrgLabel(oa?.organizationType);
1333
+ if (!plan) {
1334
+ if (oa?.billingType === 'stripe_subscription') {
1335
+ plan = 'Pro';
1336
+ }
1337
+ else if (oa?.billingType) {
1338
+ plan = oa.billingType;
1339
+ }
1337
1340
  }
1338
1341
  // usageStatus is NOT derived from cachedExtraUsageDisabledReason. That
1339
1342
  // field reports why pay-as-you-go overage is off (out_of_credits = no
@@ -2194,7 +2197,7 @@ export function getMcpConfigPathForHome(agentId, home) {
2194
2197
  /**
2195
2198
  * Get project-scoped MCP config path for an agent.
2196
2199
  */
2197
- function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
2200
+ export function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
2198
2201
  switch (agentId) {
2199
2202
  case 'claude':
2200
2203
  // Claude uses .mcp.json at project root for project-scoped MCPs
@@ -228,27 +228,6 @@ function hasSshEndpoint(endpoints) {
228
228
  }
229
229
  });
230
230
  }
231
- /**
232
- * True when any endpoint is an `ssh://…?os=windows` target — i.e. the browser
233
- * lives on a remote Windows host. Such a profile's binary (`msedge.exe`) will
234
- * never exist on this Mac, so create-time local-binary validation must be
235
- * skipped; the binary is resolved on the remote at connect time instead.
236
- */
237
- function hasRemoteWindowsEndpoint(endpoints) {
238
- const targets = Array.isArray(endpoints)
239
- ? endpoints
240
- : Object.values(endpoints).map((preset) => preset.target);
241
- return targets.some((target) => {
242
- try {
243
- const url = new URL(target);
244
- return (url.protocol === 'ssh:' &&
245
- (url.searchParams.get('os') || '').toLowerCase() === 'windows');
246
- }
247
- catch {
248
- return false;
249
- }
250
- });
251
- }
252
231
  export async function createProfile(profile) {
253
232
  const meta = readMeta();
254
233
  if (meta.browser?.[profile.name]) {
@@ -276,10 +255,10 @@ export async function createProfile(profile) {
276
255
  // deferring the failure to the first task. `findBrowserPath` short-circuits
277
256
  // for browser=custom without a binary by throwing — same outcome.
278
257
  //
279
- // Skip for remote-Windows profiles: the browser is `msedge.exe` on the
280
- // remote box, never on this Mac, so a local lookup would always (wrongly)
281
- // fail. The remote launcher resolves it at connect time via App Paths.
282
- if (!hasRemoteWindowsEndpoint(profile.endpoints)) {
258
+ // Skip for SSH profiles: the browser binary lives on the remote host, so a
259
+ // local lookup would validate the wrong machine. The remote launcher resolves
260
+ // it at connect time.
261
+ if (!hasSshEndpoint(profile.endpoints)) {
283
262
  findBrowserPath(profile.browser, profile.binary);
284
263
  }
285
264
  meta.browser = meta.browser ?? {};
@@ -0,0 +1,23 @@
1
+ export declare const BUN_VIRTUAL_ROOT: RegExp;
2
+ export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
3
+ /**
4
+ * A CLI entry must be launched through the Node runtime when it is a Node
5
+ * script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
6
+ * shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
7
+ * (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
8
+ * extension check alone misses them and they get run directly. A real compiled
9
+ * binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the direct branch
10
+ * and owns its own runtime resolution.
11
+ */
12
+ export declare function isNodeScriptEntry(agentsBin: string): boolean;
13
+ /**
14
+ * Build the `{ command, args }` to re-invoke this CLI with `sub` as its argv,
15
+ * resolving the JS-vs-standalone shape above. This is the single primitive behind
16
+ * both the daemon launch and the secrets-broker spawn — never hand-roll
17
+ * `[process.execPath, process.argv[1], …]`, which appends the bun virtual entry
18
+ * as a bogus subcommand on standalone builds.
19
+ */
20
+ export declare function getCliLaunch(sub: string[], agentsBin?: string): {
21
+ command: string;
22
+ args: string[];
23
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Resolve how to re-invoke THIS `agents` CLI as a child process, correctly across
3
+ * both install shapes:
4
+ *
5
+ * 1. **JS install** — `agents` is a `dist/index.js` (or a symlink / `#!node`
6
+ * shim to it). `process.execPath` is `node`, `process.argv[1]` is the script.
7
+ * Relaunch as `node <entry> <sub…>`.
8
+ * 2. **Bun standalone binary** (#315) — `agents` is a compiled Mach-O/ELF/PE.
9
+ * `process.execPath` is the physical signed binary, and `process.argv[1]` is
10
+ * the *virtual* embedded entry `/$bunfs/root/agents`, which Bun reports as an
11
+ * existing path. Passing that virtual path as an argv element makes the CLI
12
+ * receive it as a subcommand and die with `unknown command '/$bunfs/root/agents'`.
13
+ * Relaunch by executing the physical binary directly: `<binary> <sub…>`.
14
+ *
15
+ * Both `getDaemonLaunch` (daemon.ts) and the secrets-broker `cliSpawn`
16
+ * (secrets/agent.ts) route through here so the two never drift. This module is a
17
+ * leaf — it imports nothing from `lib/` — so it can be pulled into either without
18
+ * an import cycle (daemon.ts ↔ secrets/agent.ts already form one).
19
+ */
20
+ import { execFileSync } from 'child_process';
21
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+ export const BUN_VIRTUAL_ROOT = /[/\\]\$bunfs[/\\]root[/\\]/;
24
+ function resolveBunStandaloneEntry(entry, execPath) {
25
+ if (!BUN_VIRTUAL_ROOT.test(entry))
26
+ return entry;
27
+ if (!execPath || BUN_VIRTUAL_ROOT.test(execPath) || !fs.existsSync(execPath)) {
28
+ throw new Error(`Cannot resolve agents CLI: Bun standalone executable not found at ${execPath || '(empty path)'}`);
29
+ }
30
+ return execPath;
31
+ }
32
+ export function getAgentsBinPath(argv1 = process.argv[1], execPath = process.execPath) {
33
+ // Prefer the binary actively executing this code. `which agents` returns
34
+ // whatever happens to be first on PATH, which means a side-by-side dev
35
+ // build at ~/.local/bin would silently spawn the registry-installed
36
+ // daemon and run stale code. For a JS install, process.argv[1] is the
37
+ // absolute entrypoint the user actually invoked. A Bun standalone instead
38
+ // exposes its embedded /$bunfs/root entry at argv[1] and its physical signed
39
+ // executable at process.execPath; Bun reports both as existing paths.
40
+ const runningEntry = argv1 ? resolveBunStandaloneEntry(argv1, execPath) : undefined;
41
+ if (runningEntry && fs.existsSync(runningEntry)) {
42
+ // The package's browser/computer entrypoints are sibling shims without a
43
+ // `daemon` command. A daemon started as their IPC side effect must launch
44
+ // through the main agents entrypoint instead of replaying the shim path.
45
+ const entryName = path.basename(runningEntry);
46
+ const compiledShim = /^(browser|computer)\.(c|m)?js$/.test(entryName);
47
+ const installedShim = /^(browser|computer)$/.test(entryName);
48
+ if (compiledShim || installedShim) {
49
+ const agentsEntry = path.join(path.dirname(runningEntry), compiledShim ? 'index.js' : 'agents');
50
+ if (!fs.existsSync(agentsEntry)) {
51
+ throw new Error(`Cannot start agents daemon: main CLI entry not found at ${agentsEntry}`);
52
+ }
53
+ return agentsEntry;
54
+ }
55
+ return runningEntry;
56
+ }
57
+ try {
58
+ return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
59
+ }
60
+ catch {
61
+ return 'agents';
62
+ }
63
+ }
64
+ /**
65
+ * A CLI entry must be launched through the Node runtime when it is a Node
66
+ * script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
67
+ * shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
68
+ * (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
69
+ * extension check alone misses them and they get run directly. A real compiled
70
+ * binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the direct branch
71
+ * and owns its own runtime resolution.
72
+ */
73
+ export function isNodeScriptEntry(agentsBin) {
74
+ let resolved = agentsBin;
75
+ try {
76
+ resolved = fs.realpathSync(agentsBin);
77
+ }
78
+ catch {
79
+ // Unresolvable (e.g. a template path that does not exist on this box): fall
80
+ // back to the extension check on the path as given.
81
+ }
82
+ if (/\.(c|m)?js$/.test(resolved))
83
+ return true;
84
+ try {
85
+ const fd = fs.openSync(resolved, 'r');
86
+ try {
87
+ const buf = Buffer.alloc(128);
88
+ const n = fs.readSync(fd, buf, 0, 128, 0);
89
+ const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
90
+ return firstLine.startsWith('#!') && /\bnode\b/.test(firstLine);
91
+ }
92
+ finally {
93
+ fs.closeSync(fd);
94
+ }
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ /**
101
+ * Build the `{ command, args }` to re-invoke this CLI with `sub` as its argv,
102
+ * resolving the JS-vs-standalone shape above. This is the single primitive behind
103
+ * both the daemon launch and the secrets-broker spawn — never hand-roll
104
+ * `[process.execPath, process.argv[1], …]`, which appends the bun virtual entry
105
+ * as a bogus subcommand on standalone builds.
106
+ */
107
+ export function getCliLaunch(sub, agentsBin = getAgentsBinPath()) {
108
+ // Resolve a bun virtual entry to the physical executable even when a caller
109
+ // passes agentsBin explicitly (getAgentsBinPath already does this for the
110
+ // default), so a `/$bunfs/root/agents` never becomes the command or an argv.
111
+ const bin = resolveBunStandaloneEntry(agentsBin, process.execPath);
112
+ if (isNodeScriptEntry(bin)) {
113
+ return { command: process.execPath, args: [bin, ...sub] };
114
+ }
115
+ return { command: bin, args: [...sub] };
116
+ }
@@ -6,6 +6,7 @@
6
6
  * (macOS), systemd (Linux), or as a plain detached process. PID tracking,
7
7
  * log output, reload (SIGHUP), and graceful shutdown are handled here.
8
8
  */
9
+ import { getAgentsBinPath } from './cli-entry.js';
9
10
  /** Read the stored daemon PID from disk. Returns null if not present or invalid. */
10
11
  export declare function readDaemonPid(): number | null;
11
12
  /** Write the daemon PID to the pid file. */
@@ -101,7 +102,7 @@ export declare function generateLaunchdPlist(agentsBin?: string): string;
101
102
  * store and never touches the unit file.
102
103
  */
103
104
  export declare function generateSystemdUnit(agentsBin?: string): string;
104
- export declare function getAgentsBinPath(argv1?: string | undefined, execPath?: string): string;
105
+ export { getAgentsBinPath };
105
106
  /** Start the daemon via launchd, systemd, or as a detached process. */
106
107
  export declare function startDaemon(agentsBin?: string): {
107
108
  pid: number | null;
@@ -201,4 +202,3 @@ export declare function getDaemonStatus(): {
201
202
  export declare function readDaemonLog(lines?: number): string;
202
203
  /** Send SIGHUP to the daemon to trigger a job reload. */
203
204
  export declare function signalDaemonReload(): boolean;
204
- export {};
@@ -21,6 +21,7 @@ import { BrowserService } from './browser/service.js';
21
21
  import { BrowserIPCServer } from './browser/ipc.js';
22
22
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
23
23
  import { redactSecrets } from './redact.js';
24
+ import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
24
25
  const PID_FILE = 'daemon.pid';
25
26
  const LOCK_FILE = 'daemon.lock';
26
27
  const LOG_FILE = 'logs.jsonl';
@@ -781,47 +782,11 @@ Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin
781
782
  [Install]
782
783
  WantedBy=default.target`;
783
784
  }
784
- const BUN_VIRTUAL_ROOT = /[/\\]\$bunfs[/\\]root[/\\]/;
785
- function resolveBunStandaloneEntry(entry, execPath) {
786
- if (!BUN_VIRTUAL_ROOT.test(entry))
787
- return entry;
788
- if (!execPath || BUN_VIRTUAL_ROOT.test(execPath) || !fs.existsSync(execPath)) {
789
- throw new Error(`Cannot resolve agents CLI: Bun standalone executable not found at ${execPath || '(empty path)'}`);
790
- }
791
- return execPath;
792
- }
793
- export function getAgentsBinPath(argv1 = process.argv[1], execPath = process.execPath) {
794
- // Prefer the binary actively executing this code. `which agents` returns
795
- // whatever happens to be first on PATH, which means a side-by-side dev
796
- // build at ~/.local/bin would silently spawn the registry-installed
797
- // daemon and run stale code. For a JS install, process.argv[1] is the
798
- // absolute entrypoint the user actually invoked. A Bun standalone instead
799
- // exposes its embedded /$bunfs/root entry at argv[1] and its physical signed
800
- // executable at process.execPath; Bun reports both as existing paths.
801
- const runningEntry = argv1 ? resolveBunStandaloneEntry(argv1, execPath) : undefined;
802
- if (runningEntry && fs.existsSync(runningEntry)) {
803
- // The package's browser/computer entrypoints are sibling shims without a
804
- // `daemon` command. A daemon started as their IPC side effect must launch
805
- // through the main agents entrypoint instead of replaying the shim path.
806
- const entryName = path.basename(runningEntry);
807
- const compiledShim = /^(browser|computer)\.(c|m)?js$/.test(entryName);
808
- const installedShim = /^(browser|computer)$/.test(entryName);
809
- if (compiledShim || installedShim) {
810
- const agentsEntry = path.join(path.dirname(runningEntry), compiledShim ? 'index.js' : 'agents');
811
- if (!fs.existsSync(agentsEntry)) {
812
- throw new Error(`Cannot start agents daemon: main CLI entry not found at ${agentsEntry}`);
813
- }
814
- return agentsEntry;
815
- }
816
- return runningEntry;
817
- }
818
- try {
819
- return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
820
- }
821
- catch {
822
- return 'agents';
823
- }
824
- }
785
+ // Binary-resolution helpers (getAgentsBinPath / isNodeScriptEntry / getCliLaunch)
786
+ // live in ./cli-entry.js a leaf module the secrets broker also imports without
787
+ // forming a cycle. Re-exported so existing `from './daemon.js'` importers of
788
+ // getAgentsBinPath keep resolving.
789
+ export { getAgentsBinPath };
825
790
  /**
826
791
  * Ask the service manager for the daemon's live PID. Used as a fallback when
827
792
  * the daemon hasn't yet written its pid file but launchd/systemd already report
@@ -980,49 +945,7 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
980
945
  const { warnings } = validateDaemonBinary(agentsBin);
981
946
  for (const w of warnings)
982
947
  process.stderr.write(`[agents] ${w}\n`);
983
- if (isNodeScriptEntry(agentsBin)) {
984
- return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
985
- }
986
- return { command: agentsBin, args: ['daemon', '_run'] };
987
- }
988
- /**
989
- * A daemon entry must be launched through the Node runtime when it is a Node
990
- * script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
991
- * shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
992
- * (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
993
- * extension check alone misses them and they get run directly. Executing such an
994
- * entry then relies on the shebang resolving `node` off the daemon's PATH — and
995
- * when that PATH points at a pruned nvm version (or an ancient system node), the
996
- * daemon crash-loops at import (`node:util` has no `styleText` on Node 18). A
997
- * real compiled binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the
998
- * direct branch and owns its own runtime resolution.
999
- */
1000
- function isNodeScriptEntry(agentsBin) {
1001
- let resolved = agentsBin;
1002
- try {
1003
- resolved = fs.realpathSync(agentsBin);
1004
- }
1005
- catch {
1006
- // Unresolvable (e.g. a template path that does not exist on this box): fall
1007
- // back to the extension check on the path as given.
1008
- }
1009
- if (/\.(c|m)?js$/.test(resolved))
1010
- return true;
1011
- try {
1012
- const fd = fs.openSync(resolved, 'r');
1013
- try {
1014
- const buf = Buffer.alloc(128);
1015
- const n = fs.readSync(fd, buf, 0, 128, 0);
1016
- const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
1017
- return firstLine.startsWith('#!') && /\bnode\b/.test(firstLine);
1018
- }
1019
- finally {
1020
- fs.closeSync(fd);
1021
- }
1022
- }
1023
- catch {
1024
- return false;
1025
- }
948
+ return getCliLaunch(['daemon', '_run'], agentsBin);
1026
949
  }
1027
950
  /**
1028
951
  * The directory of the Node runtime that generated this service manifest, kept
@@ -1050,11 +973,7 @@ function daemonNodeBinDir() {
1050
973
  * getAgentsBinPath() resolves that virtual entry to the physical process.execPath.
1051
974
  */
1052
975
  export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
1053
- const resolvedBin = resolveBunStandaloneEntry(agentsBin, process.execPath);
1054
- if (/\.(c|m)?js$/.test(resolvedBin)) {
1055
- return { command: process.execPath, args: [resolvedBin, ...subArgs] };
1056
- }
1057
- return { command: resolvedBin, args: subArgs };
976
+ return getCliLaunch(subArgs, agentsBin);
1058
977
  }
1059
978
  export function validateDaemonBinary(binPath) {
1060
979
  const warnings = [];
@@ -24,6 +24,17 @@ export interface FleetRunResult {
24
24
  /** Truncated combined stderr/stdout for failures. */
25
25
  detail?: string;
26
26
  }
27
+ export interface FanOutDeviceTarget {
28
+ name: string;
29
+ skip?: FleetSkipReason | string;
30
+ }
31
+ export interface FanOutDeviceResult<T> {
32
+ name: string;
33
+ status: 'ok' | 'failed' | 'skipped';
34
+ value?: T;
35
+ error?: string;
36
+ reason?: FleetSkipReason | string;
37
+ }
27
38
  /**
28
39
  * Classify each registered device for a fleet operation.
29
40
  *
@@ -34,6 +45,16 @@ export interface FleetRunResult {
34
45
  * it has a registry address — same path as any other box).
35
46
  */
36
47
  export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
48
+ /**
49
+ * Remote fan-out targets for the fleet health/drift gates (`fleet status`,
50
+ * `check --devices`): every planned device except this machine and control-only
51
+ * cockpits. A control device never runs agents (mirrors doctor's fan-out, which
52
+ * drops it via `isControlDevice`), so counting it as unreachable/drift would make
53
+ * the CI gate fail on every run for a fleet that merely has a cockpit registered.
54
+ * Offline / no-address devices are kept — those are genuine faults a gate should
55
+ * surface — so their `skip` reason still flows through as an `unreachable` row.
56
+ */
57
+ export declare function remoteFleetTargets(planned: FleetTarget[], self: string): FleetTarget[];
37
58
  /** Human label for a skip reason. */
38
59
  export declare function skipLabel(reason: FleetSkipReason): string;
39
60
  /**
@@ -61,3 +82,5 @@ export declare function upgradeCommand(version?: string): string[];
61
82
  * the runner are recorded as `failed` so one bad device never aborts the rest.
62
83
  */
63
84
  export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
85
+ /** Run one async probe per device in parallel, preserving input order. */
86
+ export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>): Promise<FanOutDeviceResult<T>[]>;