@phnx-labs/agents-cli 1.20.66 → 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 (50) hide show
  1. package/CHANGELOG.md +88 -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 +45 -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 +40 -2
  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 +35 -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/exec.d.ts +15 -0
  28. package/dist/lib/exec.js +31 -6
  29. package/dist/lib/git.d.ts +10 -0
  30. package/dist/lib/git.js +23 -0
  31. package/dist/lib/hosts/option.js +1 -1
  32. package/dist/lib/hosts/ready.js +5 -3
  33. package/dist/lib/hosts/remote-cmd.d.ts +12 -0
  34. package/dist/lib/hosts/remote-cmd.js +17 -1
  35. package/dist/lib/mcp.d.ts +21 -0
  36. package/dist/lib/mcp.js +278 -13
  37. package/dist/lib/resources/mcp.js +20 -342
  38. package/dist/lib/routines.d.ts +13 -0
  39. package/dist/lib/routines.js +21 -0
  40. package/dist/lib/runner.js +24 -47
  41. package/dist/lib/secrets/agent.js +11 -15
  42. package/dist/lib/share/capture.js +21 -3
  43. package/dist/lib/signin-badge.d.ts +41 -0
  44. package/dist/lib/signin-badge.js +64 -0
  45. package/dist/lib/ssh-exec.d.ts +5 -0
  46. package/dist/lib/ssh-exec.js +55 -1
  47. package/dist/lib/types.d.ts +10 -0
  48. package/dist/lib/usage.d.ts +17 -3
  49. package/dist/lib/usage.js +63 -16
  50. package/package.json +1 -1
@@ -15,6 +15,7 @@ import * as os from 'os';
15
15
  import * as path from 'path';
16
16
  import chalk from 'chalk';
17
17
  import ora from 'ora';
18
+ import { getCliVersion } from '../lib/version.js';
18
19
  import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
19
20
  import { machineId } from '../lib/session/sync/config.js';
20
21
  import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
@@ -28,8 +29,13 @@ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
28
29
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
29
30
  import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
30
31
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
31
- import { planFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
32
- import { fleetCapacity, fmtBytes, headroom, probeFleetStats, } from '../lib/devices/health.js';
32
+ import { fanOutDevices, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
33
+ import { fleetCapacity, fmtBytes, headroom, probeLocalStats, probeFleetStats, } from '../lib/devices/health.js';
34
+ import { buildFleetHealthReport, renderFleetMatrix, renderFleetWarnings, } from '../lib/devices/health-report.js';
35
+ import { checkSyncStatus, countOrphans } from '../lib/drift.js';
36
+ import { checkAllClis } from '../lib/teams/agents.js';
37
+ import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
38
+ import { sshExecAsync } from '../lib/ssh-exec.js';
33
39
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
34
40
  * command is running on so it stands out from the rest of the tailnet. */
35
41
  function deviceSummary(d, isSelf = false) {
@@ -219,6 +225,92 @@ function printFleetResults(results) {
219
225
  if (failed > 0)
220
226
  process.exitCode = 1;
221
227
  }
228
+ function localHealthRow(self, stats) {
229
+ return {
230
+ name: self,
231
+ platform: process.platform === 'darwin' ? 'macos' : process.platform,
232
+ version: getCliVersion(),
233
+ stats,
234
+ clis: checkAllClis(),
235
+ sync: checkSyncStatus(process.cwd()),
236
+ orphans: countOrphans(),
237
+ };
238
+ }
239
+ async function probeRemoteHealth(target) {
240
+ const isWin = /^win/i.test((target.platform ?? '').trim());
241
+ const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
242
+ const versionCmd = buildRemoteAgentsInvocation(['--version'], undefined, isWin ? 'windows' : undefined, env);
243
+ const versionRes = await sshExecAsync(target.name, versionCmd, { timeoutMs: 15000, multiplex: true });
244
+ const version = versionRes.code === 0 ? versionRes.stdout.trim().split(/\s+/)[0] || null : null;
245
+ const doctorCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, env);
246
+ const doctorRes = await sshExecAsync(target.name, doctorCmd, { timeoutMs: 30000, multiplex: true });
247
+ if (doctorRes.code !== 0) {
248
+ throw new Error(doctorRes.timedOut ? 'timed out' : (doctorRes.stderr.trim() || `exit ${doctorRes.code ?? 'unknown'}`));
249
+ }
250
+ const parsed = JSON.parse(doctorRes.stdout);
251
+ return {
252
+ version,
253
+ clis: parsed.clis ?? {},
254
+ sync: parsed.sync ?? [],
255
+ orphans: parsed.orphans ?? [],
256
+ };
257
+ }
258
+ async function runFleetStatus(opts) {
259
+ const reg = await loadDevices();
260
+ const self = machineId();
261
+ const planned = planFleetTargets(reg);
262
+ const probeable = planned.filter((t) => !t.skip).map((t) => t.device);
263
+ const statsMap = opts.stats === false
264
+ ? new Map()
265
+ : await probeFleetStats(probeable, { selfName: self });
266
+ if (opts.stats !== false && !statsMap.has(self)) {
267
+ statsMap.set(self, await probeLocalStats(self));
268
+ }
269
+ const rows = [localHealthRow(self, statsMap.get(self))];
270
+ const remoteTargets = remoteFleetTargets(planned, self)
271
+ .map((t) => ({
272
+ name: t.device.name,
273
+ platform: t.device.platform,
274
+ skip: t.skip,
275
+ }));
276
+ const remote = await fanOutDevices(remoteTargets, probeRemoteHealth);
277
+ for (const result of remote) {
278
+ const profile = reg[result.name];
279
+ if (result.status === 'ok' && result.value) {
280
+ rows.push({
281
+ name: result.name,
282
+ platform: profile?.platform,
283
+ stats: statsMap.get(result.name),
284
+ ...result.value,
285
+ });
286
+ }
287
+ else {
288
+ rows.push({
289
+ name: result.name,
290
+ platform: profile?.platform,
291
+ stats: statsMap.get(result.name),
292
+ skipped: result.reason ? String(result.reason) : undefined,
293
+ error: result.error,
294
+ clis: {},
295
+ sync: [],
296
+ orphans: [],
297
+ });
298
+ }
299
+ }
300
+ const report = buildFleetHealthReport(rows);
301
+ if (opts.json) {
302
+ console.log(JSON.stringify(report, null, 2));
303
+ }
304
+ else {
305
+ for (const line of renderFleetWarnings(report))
306
+ console.log(line);
307
+ console.log();
308
+ for (const line of renderFleetMatrix(report))
309
+ console.log(line);
310
+ }
311
+ if (opts.strict && report.hasWarnings)
312
+ process.exitCode = 1;
313
+ }
222
314
  /** Register the `agents devices` command tree (also aliased as `fleet`). */
223
315
  function registerDevicesCommands(program) {
224
316
  const devicesCmd = program
@@ -346,6 +438,15 @@ Typical workflow:
346
438
  for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full))
347
439
  console.log(line);
348
440
  });
441
+ devicesCmd
442
+ .command('status')
443
+ .description('Show fleet health: warnings rollup, per-device sync drift, CLI readiness, version skew, and resource headroom.')
444
+ .option('--json', 'output machine-readable JSON')
445
+ .option('--strict', 'exit non-zero when any device has drift or is unreachable')
446
+ .option('--no-stats', 'skip the live resource probe')
447
+ .action(async (opts) => {
448
+ await runFleetStatus(opts);
449
+ });
349
450
  devicesCmd
350
451
  .command('show <name>')
351
452
  .description('Show the full profile for one device.')
@@ -32,5 +32,16 @@ export declare function decideTeamMessageRoute(status: AgentStatus, hasMessage:
32
32
  * the teammate itself so we don't need the original --cloud CLI args.
33
33
  */
34
34
  export declare function wireCloudDispatcher(mgr: AgentManager): void;
35
+ /**
36
+ * `teams add --remote-cwd` is a no-op trap. `--remote-cwd` comes from the shared
37
+ * `--host` option family (option.ts) and is meaningful for commands that *route*
38
+ * to a host, but `teams add` special-cases `--host`/`--device` as PLACEMENT, so
39
+ * the flag is never read — a teammate's directory is the team's repo plus its
40
+ * `--worktree`, not a path passed here. Silently ignoring it misleads you into
41
+ * thinking it set the teammate's repo path (the exact wrong model that turns one
42
+ * team into a teardown-and-rebuild). Reject with guidance instead. Exported for
43
+ * the unit test that pins the message.
44
+ */
45
+ export declare function remoteCwdOnAddError(team: string): string;
35
46
  /** Register the `agents teams` command tree (list, create, add, status, start, remove, disband, logs, doctor). */
36
47
  export declare function registerTeamsCommands(program: Command): void;
@@ -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 {
@@ -1059,7 +1090,7 @@ export function registerTeamsCommands(program) {
1059
1090
  .alias('a')
1060
1091
  .description("Add a teammate to work on a task. Runs in background; returns immediately. Use 'status' to check in.")
1061
1092
  .option('-n, --name <name>', 'Friendly name for this teammate (e.g. alice). Required if using --after. Unique within team.')
1062
- .option('-m, --mode <mode>', `Permissions: plan (read-only) | edit (can write files) | auto (smart classifier auto-approves safe ops) | skip (bypass all permission prompts). 'full' accepted as alias for skip.`, 'edit')
1093
+ .option('-m, --mode <mode>', `Permissions: plan (read-only) | edit (can write files) | auto (smart classifier auto-approves safe ops) | skip (bypass all permission prompts). 'full' accepted as alias for skip. Teammates run headless: plan works headless on claude/codex/droid/opencode; kimi/grok/cursor/antigravity have no headless plan mode and auto-downgrade a plan request to auto.`, 'edit')
1063
1094
  .option('-e, --effort <effort>', `Reasoning intensity: ${VALID_EFFORTS.join('|')}`, 'medium')
1064
1095
  .option('--model <model>', 'Override the effort tier and use this specific model (e.g. claude-opus-4-6)')
1065
1096
  .option('--env <key=value>', 'Set an environment variable for this teammate (repeatable for multiple vars)', (val, prev) => [...prev, val], [])
@@ -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
  */
@@ -505,6 +505,10 @@ export const AGENTS = {
505
505
  workflows: false,
506
506
  memory: true,
507
507
  modes: ['plan', 'edit', 'skip'],
508
+ // grok's `--permission-mode plan` silently stalls a headless `-p` run at
509
+ // its ExitPlanMode gate (no TTY to approve). Headless plan auto-downgrades
510
+ // to auto (→ edit via resolveMode). Interactive plan is unaffected.
511
+ headlessPlan: false,
508
512
  rulesImports: true,
509
513
  },
510
514
  },
@@ -544,6 +548,10 @@ export const AGENTS = {
544
548
  workflows: true,
545
549
  memory: false,
546
550
  modes: ['plan', 'edit', 'auto', 'skip'],
551
+ // kimi's headless `-p` refuses to combine with `--plan` (`Cannot combine
552
+ // --prompt with --plan`). Headless plan auto-downgrades to auto (kimi -p
553
+ // auto-runs). Interactive plan is unaffected.
554
+ headlessPlan: false,
547
555
  rulesImports: false,
548
556
  },
549
557
  },
@@ -970,20 +978,19 @@ export function formatClaudeOrgLabel(orgType) {
970
978
  .join(' ');
971
979
  }
972
980
  /**
973
- * Short badge identifying which org an account belongs to: "Turing Labs · Team"
974
- * for multi-seat orgs, just the tier label ("Max") for personal plans — a
975
- * personal org's name is auto-generated boilerplate ("<email>'s Organization"),
976
- * not identity. Returns null when the account carries no organizationType
977
- * (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).
978
988
  */
979
989
  export function accountOrgBadge(info) {
980
- const label = formatClaudeOrgLabel(info?.organizationType);
981
- if (!label)
982
- return null;
983
990
  const isMultiSeat = info?.organizationType === 'claude_team' || info?.organizationType === 'claude_enterprise';
984
991
  if (isMultiSeat && info?.organizationName)
985
- return `${info.organizationName} · ${label}`;
986
- return label;
992
+ return info.organizationName;
993
+ return null;
987
994
  }
988
995
  /** Return the email address associated with the agent's auth config, or null. */
989
996
  export async function getAccountEmail(agentId, home) {
@@ -1313,19 +1320,23 @@ export async function getAccountInfo(agentId, home) {
1313
1320
  ['org', organizationId],
1314
1321
  ]);
1315
1322
  const usageKey = buildIdentityKey(agentId, [['org', organizationId]]);
1316
- // Plan is derived from .claude.json's billingType only. Reading
1317
- // subscriptionType from the keychain item ("Claude Code-credentials-<hash>")
1318
- // forces a macOS Keychain ACL prompt on every `agents run` (one prompt per
1319
- // installed version under balanced rotation) because Claude Code writes its
1320
- // credentials with its own process in the ACL our helper isn't trusted by
1321
- // that item. Callers that genuinely need subscriptionType (e.g. detailed
1322
- // `agents view`) should call loadClaudeOauth() directly.
1323
- let plan = null;
1324
- if (oa?.billingType === 'stripe_subscription') {
1325
- plan = 'Pro';
1326
- }
1327
- else if (oa?.billingType) {
1328
- 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
+ }
1329
1340
  }
1330
1341
  // usageStatus is NOT derived from cachedExtraUsageDisabledReason. That
1331
1342
  // field reports why pay-as-you-go overage is off (out_of_credits = no
@@ -2186,7 +2197,7 @@ export function getMcpConfigPathForHome(agentId, home) {
2186
2197
  /**
2187
2198
  * Get project-scoped MCP config path for an agent.
2188
2199
  */
2189
- function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
2200
+ export function getProjectMcpConfigPath(agentId, cwd = process.cwd()) {
2190
2201
  switch (agentId) {
2191
2202
  case 'claude':
2192
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
+ }