@phnx-labs/agents-cli 1.20.67 → 1.20.69

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 (83) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +14 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/check.js +96 -2
  5. package/dist/commands/computer.d.ts +26 -0
  6. package/dist/commands/computer.js +173 -149
  7. package/dist/commands/doctor.js +37 -9
  8. package/dist/commands/exec.d.ts +18 -0
  9. package/dist/commands/exec.js +126 -6
  10. package/dist/commands/fork.d.ts +9 -0
  11. package/dist/commands/fork.js +67 -0
  12. package/dist/commands/plugins.js +11 -2
  13. package/dist/commands/repo.js +47 -2
  14. package/dist/commands/run-account-picker.d.ts +14 -0
  15. package/dist/commands/run-account-picker.js +131 -0
  16. package/dist/commands/setup-browser.d.ts +18 -0
  17. package/dist/commands/setup-browser.js +142 -0
  18. package/dist/commands/setup-computer.d.ts +19 -0
  19. package/dist/commands/setup-computer.js +133 -0
  20. package/dist/commands/setup-share.d.ts +17 -0
  21. package/dist/commands/setup-share.js +85 -0
  22. package/dist/commands/setup.d.ts +1 -1
  23. package/dist/commands/setup.js +58 -1
  24. package/dist/commands/share.d.ts +15 -0
  25. package/dist/commands/share.js +10 -4
  26. package/dist/commands/ssh.js +267 -2
  27. package/dist/commands/teams.d.ts +11 -0
  28. package/dist/commands/teams.js +39 -1
  29. package/dist/commands/view.d.ts +4 -14
  30. package/dist/commands/view.js +39 -36
  31. package/dist/index.js +2 -1
  32. package/dist/lib/agents.d.ts +21 -5
  33. package/dist/lib/agents.js +59 -24
  34. package/dist/lib/auth-health.d.ts +103 -0
  35. package/dist/lib/auth-health.js +232 -0
  36. package/dist/lib/browser/chrome.d.ts +9 -0
  37. package/dist/lib/browser/chrome.js +22 -0
  38. package/dist/lib/browser/profiles.js +4 -25
  39. package/dist/lib/cli-entry.d.ts +23 -0
  40. package/dist/lib/cli-entry.js +116 -0
  41. package/dist/lib/computer/download.d.ts +51 -0
  42. package/dist/lib/computer/download.js +145 -0
  43. package/dist/lib/computer-rpc.js +9 -3
  44. package/dist/lib/daemon.d.ts +2 -2
  45. package/dist/lib/daemon.js +8 -89
  46. package/dist/lib/devices/connect.d.ts +15 -0
  47. package/dist/lib/devices/connect.js +17 -5
  48. package/dist/lib/devices/fleet.d.ts +23 -0
  49. package/dist/lib/devices/fleet.js +38 -0
  50. package/dist/lib/devices/health-report.d.ts +46 -0
  51. package/dist/lib/devices/health-report.js +159 -0
  52. package/dist/lib/devices/health.d.ts +14 -4
  53. package/dist/lib/devices/health.js +49 -8
  54. package/dist/lib/devices/terminfo.d.ts +44 -0
  55. package/dist/lib/devices/terminfo.js +167 -0
  56. package/dist/lib/git.d.ts +10 -0
  57. package/dist/lib/git.js +23 -0
  58. package/dist/lib/hosts/option.js +1 -1
  59. package/dist/lib/hosts/ready.js +5 -3
  60. package/dist/lib/hosts/remote-cmd.d.ts +12 -0
  61. package/dist/lib/hosts/remote-cmd.js +17 -1
  62. package/dist/lib/mcp.d.ts +21 -0
  63. package/dist/lib/mcp.js +278 -13
  64. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  65. package/dist/lib/resources/mcp.js +20 -342
  66. package/dist/lib/rotate.d.ts +12 -8
  67. package/dist/lib/rotate.js +22 -23
  68. package/dist/lib/routines.d.ts +13 -0
  69. package/dist/lib/routines.js +21 -0
  70. package/dist/lib/runner.js +18 -40
  71. package/dist/lib/secrets/agent.js +11 -15
  72. package/dist/lib/session/fork.d.ts +32 -0
  73. package/dist/lib/session/fork.js +101 -0
  74. package/dist/lib/share/capture.js +21 -3
  75. package/dist/lib/signin-badge.d.ts +41 -0
  76. package/dist/lib/signin-badge.js +64 -0
  77. package/dist/lib/ssh-exec.d.ts +5 -0
  78. package/dist/lib/ssh-exec.js +55 -1
  79. package/dist/lib/startup/command-registry.d.ts +1 -0
  80. package/dist/lib/startup/command-registry.js +2 -0
  81. package/dist/lib/usage.d.ts +40 -3
  82. package/dist/lib/usage.js +147 -16
  83. package/package.json +1 -1
@@ -1,13 +1,15 @@
1
1
  import { addHostOption } from '../lib/hosts/option.js';
2
2
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
3
3
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
4
+ import { fanOutDevices } from '../lib/devices/fleet.js';
4
5
  import { resolveHost } from '../lib/hosts/registry.js';
5
- import { sshExec } from '../lib/ssh-exec.js';
6
+ import { sshExecAsync } from '../lib/ssh-exec.js';
6
7
  import { sshTargetFor } from '../lib/hosts/types.js';
7
8
  import { machineId } from '../lib/session/sync/config.js';
8
9
  import chalk from 'chalk';
9
10
  import { checkAllClis, collectTeamsDoctorData } from '../lib/teams/agents.js';
10
- import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError } from '../lib/agents.js';
11
+ import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError, getAccountInfo } from '../lib/agents.js';
12
+ import { formatSignInBadge } from '../lib/signin-badge.js';
11
13
  import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalledVersions, parseAgentSpec, } from '../lib/versions.js';
12
14
  import { loadManifest, isStale } from '../lib/staleness/index.js';
13
15
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
@@ -55,7 +57,7 @@ function printWrappedLine(prefix, text) {
55
57
  for (const line of wrapLine(prefix, text))
56
58
  console.log(chalk.gray(line));
57
59
  }
58
- function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
60
+ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn) {
59
61
  console.log(chalk.bold('Agent CLIs'));
60
62
  // Show the fleet you actually run — agents that are ready in PATH, plus any
61
63
  // you MANAGE (have installed versions) whose binary isn't resolving (a real
@@ -72,7 +74,9 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
72
74
  for (const [name, entry] of shown) {
73
75
  const pretty = (AGENT_NAMES[name] || name).padEnd(11);
74
76
  if (entry.installed) {
75
- console.log(` ${chalk.green('ready')} ${pretty} ${chalk.gray(entry.path || '')}`);
77
+ const badge = formatSignInBadge(signIn[name]);
78
+ const pathHint = entry.path ? chalk.gray(` ${entry.path}`) : '';
79
+ console.log(` ${chalk.green('ready')} ${pretty} ${badge}${pathHint}`);
76
80
  }
77
81
  else {
78
82
  console.log(` ${chalk.red('no ')} ${pretty} ${chalk.gray(entry.error || 'not installed')}`);
@@ -266,7 +270,7 @@ async function probeFleetTarget(target) {
266
270
  // has it via the install profile, and our single-quote escaping would
267
271
  // prevent $HOME expansion there, so skip the bootstrap on Windows.
268
272
  isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
269
- const res = sshExec(target.sshTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
273
+ const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
270
274
  if (res.code !== 0) {
271
275
  return {
272
276
  name: target.name,
@@ -298,9 +302,18 @@ async function runDevicesDoctor(opts) {
298
302
  if (!singleName) {
299
303
  results.push({ name: localName, online: true, agents: await collectTeamsDoctorData() });
300
304
  }
301
- // Remote targets in parallel.
302
- const remoteResults = await Promise.all(targets.map(probeFleetTarget));
303
- results.push(...remoteResults);
305
+ // Remote targets in parallel, through the shared fleet fan-out helper.
306
+ const remoteResults = await fanOutDevices(targets, probeFleetTarget);
307
+ results.push(...remoteResults.map((r) => {
308
+ if (r.status === 'ok' && r.value)
309
+ return r.value;
310
+ return {
311
+ name: r.name,
312
+ online: false,
313
+ error: r.error ?? String(r.reason ?? 'skipped'),
314
+ agents: {},
315
+ };
316
+ }));
304
317
  if (opts.json) {
305
318
  console.log(JSON.stringify({ devices: results }, null, 2));
306
319
  return;
@@ -710,9 +723,24 @@ export function registerDoctorCommand(program) {
710
723
  const syncRows = checkSyncStatus(cwd);
711
724
  const orphanRows = countOrphans();
712
725
  const hostClis = listCliStatus(cwd);
726
+ // Advisory login state per installed agent (file-based getAccountInfo,
727
+ // no home → the account-global/active credential). Best-effort: a probe
728
+ // failure just leaves that agent's badge as "logged out".
729
+ const signIn = {};
730
+ await Promise.all(Object.entries(clis)
731
+ .filter(([, e]) => e.installed)
732
+ .map(async ([name]) => {
733
+ try {
734
+ signIn[name] = await getAccountInfo(name);
735
+ }
736
+ catch {
737
+ /* advisory only */
738
+ }
739
+ }));
713
740
  if (opts.json) {
714
741
  console.log(JSON.stringify({
715
742
  clis,
743
+ signIn,
716
744
  sync: syncRows,
717
745
  orphans: orphanRows,
718
746
  hostClis: {
@@ -727,7 +755,7 @@ export function registerDoctorCommand(program) {
727
755
  }, null, 2));
728
756
  return;
729
757
  }
730
- renderOverviewText(clis, syncRows, orphanRows, hostClis);
758
+ renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn);
731
759
  // Point at the interactive reconcile when anything is out of sync — the
732
760
  // report shouldn't be a dead end. `agents status` runs the unified
733
761
  // home-reading engine and offers to sync (opt-in, never auto-fires here).
@@ -8,6 +8,24 @@
8
8
  import { type Command } from 'commander';
9
9
  import type { ExecEffort } from '../lib/exec.js';
10
10
  import { type SshGResult } from '../lib/hosts/ssh-config.js';
11
+ export interface RunAccountPickerRequest {
12
+ requested: boolean;
13
+ normalizedAgentSpec: string;
14
+ valid: boolean;
15
+ }
16
+ /** Distinguish a terminal account-picker marker from an explicit @version pin. */
17
+ export declare function parseRunAccountPickerRequest(agentSpec: string): RunAccountPickerRequest;
18
+ /** Return every option whose routing semantics conflict with a local account choice. */
19
+ export declare function runAccountPickerConflicts(options: {
20
+ resume?: string | boolean;
21
+ strategy?: string;
22
+ balanced?: boolean;
23
+ lease?: string | boolean;
24
+ host?: string;
25
+ device?: string;
26
+ on?: string;
27
+ computer?: string;
28
+ }): string[];
11
29
  /** The host descriptor fields the `--copy-creds` security gate reads. */
12
30
  export interface CopyCredsGateHost {
13
31
  name: string;
@@ -18,6 +18,31 @@ import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import * as os from 'os';
20
20
  import { randomUUID } from 'crypto';
21
+ /** Distinguish a terminal account-picker marker from an explicit @version pin. */
22
+ export function parseRunAccountPickerRequest(agentSpec) {
23
+ const requested = agentSpec.endsWith('@');
24
+ const normalizedAgentSpec = requested ? agentSpec.slice(0, -1) : agentSpec;
25
+ return {
26
+ requested,
27
+ normalizedAgentSpec,
28
+ valid: !requested || (!!normalizedAgentSpec && !normalizedAgentSpec.includes('@')),
29
+ };
30
+ }
31
+ /** Return every option whose routing semantics conflict with a local account choice. */
32
+ export function runAccountPickerConflicts(options) {
33
+ const conflicts = [];
34
+ if (options.resume !== undefined)
35
+ conflicts.push('--resume');
36
+ if (options.strategy !== undefined)
37
+ conflicts.push('--strategy');
38
+ if (options.balanced)
39
+ conflicts.push('--balanced');
40
+ if (options.lease)
41
+ conflicts.push('--lease');
42
+ if (options.host || options.device || options.on || options.computer)
43
+ conflicts.push('--host/--device');
44
+ return conflicts;
45
+ }
21
46
  /** Type guard that narrows a string to a known AgentId. */
22
47
  function isValidAgent(agent) {
23
48
  return agent in AGENTS;
@@ -259,6 +284,7 @@ export function registerRunCommand(program) {
259
284
  .option('--json', 'Stream events as JSON lines (for parsing by other tools)')
260
285
  .option('--quiet', 'Suppress preamble (rotation banner, "Running:" line). Useful when piping JSON events to a parser.', false)
261
286
  .option('--headless', 'Force headless mode. Auto-enabled when a prompt is provided; pass explicitly to stay headless with no prompt (reads the prompt from stdin).', false)
287
+ .option('--no-auth-check', 'Skip the pre-launch "looks logged out" warning on an interactive run (advisory; never blocks anyway). Also silenced by AGENTS_NO_AUTH_CHECK=1.')
262
288
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided. Mutually exclusive with --headless.')
263
289
  .option('--resume [id]', 'Resume a previous conversation. Accepts a full or partial session id (prefix-matched against the index); omit the id to pick from recent sessions interactively. Resumes under the version that started the session. claude/codex resume natively; other agents replay via a /continue first message. Pair with a prompt to continue headlessly.')
264
290
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
@@ -281,7 +307,7 @@ export function registerRunCommand(program) {
281
307
  .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.')
282
308
  .option('--host <name>', 'Offload this run onto another machine over SSH instead of running locally — a device, a registered agent host, or user@host. See `agents devices` / `agents hosts`.')
283
309
  .option('--device <name>', 'Alias of --host: offload this run onto a registered device (from `agents devices`).')
284
- .option('--remote-cwd <dir>', 'Explicit host working directory for --host runs (overrides --cwd; usually --cwd suffices).')
310
+ .option('--remote-cwd <dir>', "Explicit host working directory for --host runs, used VERBATIM (overrides --cwd; usually --cwd suffices — it re-roots a local-home path onto the remote home). Pass a single-quoted '$HOME/…' or a valid remote absolute path; a local ~ expands here and won't exist there (/Users/you vs /home/you).")
285
311
  .option('--no-follow', 'With --host, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
286
312
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.')
287
313
  .option('--copy-creds', 'With --host, copy the picked runtime credentials (and Claude OAuth token) to the host, then shred them after the run. Opt-in per run.')
@@ -307,6 +333,9 @@ export function registerRunCommand(program) {
307
333
  # Interactive (TUI) with the pinned default version
308
334
  agents run claude
309
335
 
336
+ # Pick a signed-in account/version for only this run
337
+ agents run claude@
338
+
310
339
  # Pipe JSON events to a parser (--quiet drops the preamble)
311
340
  agents run claude "..." --json --quiet | jq
312
341
 
@@ -341,6 +370,10 @@ export function registerRunCommand(program) {
341
370
  A version/account is skipped when it is rate-limited right now — any usage window (incl. the 5-hour session window) at 100%, matching the 'agents view' badge.
342
371
  --balanced is shorthand for --strategy balanced. Ignored when @version is pinned, when a profile is used, or with --fallback.
343
372
 
373
+ Account picker: append @ with no version (agents run claude@) to choose one
374
+ installed account for this run. Rows show identity, login state, plan,
375
+ and available limits; unsafe accounts stay visible but disabled.
376
+
344
377
  Fallback: --fallback codex,gemini retries on rate-limit failure via /continue handoff. Each entry accepts @version.
345
378
 
346
379
  Resume: --resume <id> continues a prior conversation (full or partial id; omit to pick interactively). claude/codex resume natively; others replay via a /continue first message. Add a prompt to continue headlessly.
@@ -369,6 +402,24 @@ export function registerRunCommand(program) {
369
402
  // a native flag, not a prompt. Run interactively.
370
403
  prompt = undefined;
371
404
  }
405
+ // A trailing @ is an explicit request to choose one installed account.
406
+ // Strip only that terminal marker; concrete agent@version pins retain
407
+ // their existing meaning in every dispatch path below.
408
+ const accountPicker = parseRunAccountPickerRequest(agentSpec);
409
+ const accountPickerRequested = accountPicker.requested;
410
+ const normalizedAgentSpec = accountPicker.normalizedAgentSpec;
411
+ if (!accountPicker.valid) {
412
+ console.error(chalk.red(`Invalid account picker target: ${agentSpec}. Use agents run <agent>@.`));
413
+ process.exit(1);
414
+ }
415
+ if (accountPickerRequested) {
416
+ const conflicts = runAccountPickerConflicts(options);
417
+ if (conflicts.length > 0) {
418
+ console.error(chalk.red(`Account selection with ${agentSpec} cannot be combined with ${conflicts.join(', ')}. ` +
419
+ 'Pick the account locally, or use an explicit agent@version target.'));
420
+ process.exit(1);
421
+ }
422
+ }
372
423
  // --lease: invent a disposable cloud box for this run (via crabbox), run
373
424
  // the agent there, then tear it down. Unlike --host, nothing is registered.
374
425
  if (options.lease) {
@@ -392,7 +443,7 @@ export function registerRunCommand(program) {
392
443
  const { leaseAndRun } = await import('../lib/crabbox/lease.js');
393
444
  const { getConfiguredRunStrategy, resolveRunVersion } = await import('../lib/rotate.js');
394
445
  const detected = await detectSignedInRuntimes();
395
- const agentName = agentSpec.split('@')[0];
446
+ const agentName = normalizedAgentSpec.split('@')[0];
396
447
  const leaseCwd = options.cwd ?? process.cwd();
397
448
  // `--lease` requires a prompt (guarded above), so it is headless by
398
449
  // contract — never block on an interactive picker. Provision exactly the
@@ -595,7 +646,7 @@ export function registerRunCommand(program) {
595
646
  throw e;
596
647
  }
597
648
  try {
598
- const [runAgent, rawRunVersion] = agentSpec.split('@');
649
+ const [runAgent, rawRunVersion] = normalizedAgentSpec.split('@');
599
650
  // Forward the explicit @version pin verbatim. Resolving aliases like
600
651
  // @latest locally would check local installs, but the remote host may
601
652
  // have versions the laptop does not. The remote agents CLI resolves
@@ -893,7 +944,7 @@ export function registerRunCommand(program) {
893
944
  await warnUnpushedWork(resumeExec.cwd ?? process.cwd());
894
945
  process.exit(resumeExit);
895
946
  }
896
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported, isHeadlessSecretsContext }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
947
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported, isHeadlessSecretsContext }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
897
948
  import('../lib/exec.js'),
898
949
  import('../lib/agents.js'),
899
950
  import('../lib/profiles.js'),
@@ -909,7 +960,7 @@ export function registerRunCommand(program) {
909
960
  ]);
910
961
  const isValidAgent = (agent) => ALL_AGENT_IDS.includes(agent);
911
962
  // Parse agent@version
912
- const [rawAgent, rawVersion] = agentSpec.split('@');
963
+ const [rawAgent, rawVersion] = normalizedAgentSpec.split('@');
913
964
  let agent;
914
965
  let version = rawVersion || undefined;
915
966
  let profileEnv;
@@ -937,6 +988,16 @@ export function registerRunCommand(program) {
937
988
  // degenerates to a no-op ("I'll wait for the completion notification").
938
989
  let workflowHasSubagents = false;
939
990
  const cwd = options.cwd ?? process.cwd();
991
+ if (accountPickerRequested && !isValidAgent(rawAgent)) {
992
+ if (profileExists(rawAgent)) {
993
+ console.error(chalk.red(`Account selection is not available for profile '${rawAgent}'. Run its concrete host agent with @ instead.`));
994
+ process.exit(1);
995
+ }
996
+ if (resolveWorkflowRef(rawAgent, cwd)) {
997
+ console.error(chalk.red(`Account selection is not available for workflow '${rawAgent}'. Run a concrete agent with @ instead.`));
998
+ process.exit(1);
999
+ }
1000
+ }
940
1001
  if (isValidAgent(rawAgent)) {
941
1002
  agent = rawAgent;
942
1003
  }
@@ -1160,6 +1221,28 @@ export function registerRunCommand(program) {
1160
1221
  process.exit(1);
1161
1222
  }
1162
1223
  }
1224
+ if (accountPickerRequested) {
1225
+ if (!supportsAccountInspection(agent)) {
1226
+ console.error(chalk.red(`${agentLabel(agent)} does not expose local account state, so agents-cli cannot safely select an account.`));
1227
+ console.error(chalk.gray(`Supported account pickers: ${ACCOUNT_INSPECTION_AGENT_IDS.join(', ')}`));
1228
+ process.exit(1);
1229
+ }
1230
+ try {
1231
+ const { pickRunAccountCandidate } = await import('./run-account-picker.js');
1232
+ const selected = await pickRunAccountCandidate(agent);
1233
+ if (!selected)
1234
+ return;
1235
+ version = selected.version;
1236
+ if (!options.quiet) {
1237
+ const identity = selected.accountLabel || 'signed-in account';
1238
+ process.stderr.write(chalk.gray(`[agents] selected ${identity} · ${agent}@${selected.version} for this run\n`));
1239
+ }
1240
+ }
1241
+ catch (err) {
1242
+ console.error(chalk.red(err.message));
1243
+ process.exit(1);
1244
+ }
1245
+ }
1163
1246
  version = resolveVersionAlias(agent, version);
1164
1247
  // --resume: resolve a prior conversation and rewrite the run target to
1165
1248
  // continue it. `version` here is already the alias-resolved candidate-version
@@ -1297,7 +1380,7 @@ export function registerRunCommand(program) {
1297
1380
  // the bare primary still resolves through the strategy — otherwise every
1298
1381
  // `agents run claude --fallback codex` run lands on the pinned default
1299
1382
  // account and account rotation silently stops (the gh-monitor heal bug).
1300
- if (strategy !== 'pinned' || options.balanced || explicitStrategy) {
1383
+ if (!accountPickerRequested && (strategy !== 'pinned' || options.balanced || explicitStrategy)) {
1301
1384
  if (version) {
1302
1385
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
1303
1386
  }
@@ -1350,6 +1433,43 @@ export function registerRunCommand(program) {
1350
1433
  }
1351
1434
  }
1352
1435
  const defaultVersion = version ?? resolveVersion(agent, cwd);
1436
+ // Login preflight (advisory, warn + continue). On a local INTERACTIVE
1437
+ // launch, probe whether this agent's account has a credential and print a
1438
+ // one-line warning if it looks logged out — so you find out BEFORE the TUI
1439
+ // opens, not after typing a prompt and getting "/login" back. Uses the same
1440
+ // account-global probe as `checkCliSignedIn` / `agents doctor`
1441
+ // (getAccountInfo with no home): file-based, no Keychain ACL prompt, and
1442
+ // correct for HOME-global credential agents (grok/codex) where a per-version
1443
+ // home would false-negative. It can still false-negative for opaque
1444
+ // credentials, so this NEVER blocks — it warns and launches anyway. Skipped
1445
+ // for --json/--quiet, when a rotation already picked a signed-in account,
1446
+ // and via --no-auth-check / AGENTS_NO_AUTH_CHECK=1. (--host/--lease return
1447
+ // earlier.)
1448
+ {
1449
+ const { shouldCheckLoginBeforeLaunch, loginHint } = await import('../lib/signin-badge.js');
1450
+ const preflight = shouldCheckLoginBeforeLaunch({
1451
+ interactive: options.interactive,
1452
+ forceInteractive, // a resumed interactive session (e.g. `run kimi --resume`) opens the TUI too
1453
+ headless: options.headless,
1454
+ hasPrompt: prompt !== undefined,
1455
+ json: options.json,
1456
+ quiet: options.quiet,
1457
+ authCheckDisabled: options.authCheck === false || process.env.AGENTS_NO_AUTH_CHECK === '1',
1458
+ rotated: !!rotationResult || accountPickerRequested,
1459
+ });
1460
+ if (preflight) {
1461
+ try {
1462
+ const { getAccountInfo } = await import('../lib/agents.js');
1463
+ const info = await getAccountInfo(agent);
1464
+ if (!info.signedIn) {
1465
+ process.stderr.write(chalk.yellow(`⚠ ${agent} looks logged out — log in with: ${loginHint(agent)}. Launching anyway...\n`));
1466
+ }
1467
+ }
1468
+ catch {
1469
+ // Advisory only — a probe failure must never block a launch.
1470
+ }
1471
+ }
1472
+ }
1353
1473
  const runDefaults = fromProfile
1354
1474
  ? { sources: {} }
1355
1475
  : resolveRunDefaults(agent, defaultVersion, cwd);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `agents fork <session>` — branch an existing conversation into a new,
3
+ * independent session you can continue separately. The original is untouched.
4
+ *
5
+ * Thin command layer; the copy/register logic lives in `lib/session/fork.ts`.
6
+ */
7
+ import type { Command } from 'commander';
8
+ /** Register the top-level `agents fork` command. */
9
+ export declare function registerForkCommand(program: Command): void;
@@ -0,0 +1,67 @@
1
+ import chalk from 'chalk';
2
+ import { setHelpSections } from '../lib/help.js';
3
+ import { findSessionsById } from '../lib/session/db.js';
4
+ import { discoverSessions } from '../lib/session/discover.js';
5
+ import { forkSession, isForkableAgent, FORKABLE_AGENTS } from '../lib/session/fork.js';
6
+ /** Register the top-level `agents fork` command. */
7
+ export function registerForkCommand(program) {
8
+ const cmd = program
9
+ .command('fork <session>')
10
+ .description('Branch a session into a new, independent copy you can continue separately. The original is untouched.')
11
+ .option('--name <label>', 'Label for the fork (default: "fork of <original>")');
12
+ setHelpSections(cmd, {
13
+ examples: `
14
+ # Fork a session by (partial) id, then continue the fork
15
+ agents fork 4f3a9c21
16
+ agents resume <new-id>
17
+
18
+ # Give the fork a name
19
+ agents fork 4f3a9c21 --name "try redis instead"
20
+ `,
21
+ notes: `
22
+ - 'resume' continues the SAME conversation; 'fork' copies it under a new id so the two diverge.
23
+ - The fork is a full copy of the conversation so far; continuing it never touches the original.
24
+ - Resolve the session the same way as resume: an exact or prefix id fragment.
25
+ - Currently supports: ${FORKABLE_AGENTS.join(', ')}. Other agents are a planned follow-up.
26
+ `,
27
+ });
28
+ cmd.action(async (sessionArg, options) => {
29
+ // Resolve the source. Try the index first; only pay for a rescan if the id
30
+ // isn't found yet (mirrors the resume path's freshen-then-lookup).
31
+ let matches = findSessionsById(sessionArg, {});
32
+ if (matches.length === 0) {
33
+ await discoverSessions({});
34
+ matches = findSessionsById(sessionArg, {});
35
+ }
36
+ if (matches.length === 0) {
37
+ console.log(chalk.red(`No session matching "${sessionArg}".`));
38
+ console.log(chalk.gray('List candidates with: agents sessions'));
39
+ return;
40
+ }
41
+ if (matches.length > 1) {
42
+ console.log(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
43
+ for (const m of matches.slice(0, 8)) {
44
+ console.log(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
45
+ }
46
+ return;
47
+ }
48
+ const source = matches[0];
49
+ if (!isForkableAgent(source.agent)) {
50
+ console.log(chalk.yellow(`fork does not support ${source.agent} sessions yet.`));
51
+ console.log(chalk.gray(` Supported: ${FORKABLE_AGENTS.join(', ')}.`));
52
+ return;
53
+ }
54
+ let result;
55
+ try {
56
+ result = forkSession(source, { name: options.name });
57
+ }
58
+ catch (err) {
59
+ console.log(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
60
+ return;
61
+ }
62
+ console.log(chalk.green(`Forked ${source.shortId} -> ${result.shortId}`));
63
+ console.log(chalk.gray(` Label: ${result.label}`));
64
+ console.log(chalk.gray(` Continue: agents resume ${result.shortId}`));
65
+ console.log(chalk.gray(` Original ${source.shortId} is untouched.`));
66
+ });
67
+ }
@@ -58,8 +58,14 @@ When to use:
58
58
  - Team onboarding: distribute a full toolkit via a single plugin directory
59
59
  `);
60
60
  // Shared list implementation — reused by `list` and the bare `agents plugins` default.
61
- const runList = async () => {
61
+ const runList = async (options = {}) => {
62
62
  const plugins = discoverPlugins();
63
+ if (options.json) {
64
+ // Structured dump (name/description + per-agent-version sync targets) — the
65
+ // same rows the table is built from, mirroring `plugins marketplaces --json`.
66
+ console.log(JSON.stringify(plugins.length === 0 ? [] : buildPluginRows(plugins), null, 2));
67
+ return;
68
+ }
63
69
  if (plugins.length === 0) {
64
70
  console.log(chalk.gray('No plugins found in ~/.agents/plugins/'));
65
71
  console.log(chalk.gray('Plugins are directories with .claude-plugin/plugin.json'));
@@ -75,12 +81,15 @@ When to use:
75
81
  centralPath: getPluginsDir(),
76
82
  });
77
83
  };
78
- // Bare `agents plugins` → same as `list`.
84
+ // Bare `agents plugins` → same as `list`. (--json lives on the explicit `list`
85
+ // subcommand only; declaring it on both parent + child makes commander bind the
86
+ // flag to the parent for `plugins list --json`, dropping it from the subcommand.)
79
87
  pluginsCmd.action(runList);
80
88
  // agents plugins list
81
89
  pluginsCmd
82
90
  .command('list')
83
91
  .description('Show plugins in a table with sync status across agent versions')
92
+ .option('--json', 'Emit machine-readable JSON')
84
93
  .action(runList);
85
94
  // agents plugins marketplaces
86
95
  const marketplacesCmd = pluginsCmd
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { visibleWidth, padVisible } from '../lib/format.js';
3
+ import { stripAnsi } from '../lib/session/width.js';
3
4
  import ora from 'ora';
4
5
  import * as fs from 'fs';
5
6
  import * as path from 'path';
@@ -28,7 +29,7 @@ function resolveRepoPath(target) {
28
29
  return path.join(HOME, `.agents-${trimmed}`);
29
30
  }
30
31
  import { applyExtraAliasToVersions, ensureAgentsDir, getExtraRepoDir, getSystemAgentsDir, getUserAgentsDir, readMeta, resolveExtraRepoDir, updateMeta, } from '../lib/state.js';
31
- import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo, displayHomePath, } from '../lib/git.js';
32
+ import { parseSource, pullRepo, commitAndPush, isGitRepo, isSystemRepoOrigin, adoptRepo, getRemoteUrl, sameGitRemote, displayHomePath, } from '../lib/git.js';
32
33
  import { DEFAULT_SYSTEM_REPO } from '../lib/types.js';
33
34
  import { ALL_AGENT_IDS, isAgentName, resolveAgentName } from '../lib/agents.js';
34
35
  import { refresh } from '../lib/refresh.js';
@@ -444,10 +445,22 @@ async function listRepos(alias, opts = {}) {
444
445
  return;
445
446
  }
446
447
  if (targets.length === 0) {
448
+ if (opts.json) {
449
+ console.log('[]');
450
+ return;
451
+ }
447
452
  console.log(chalk.gray('No repos to show.'));
448
453
  return;
449
454
  }
450
455
  const rows = await Promise.all(targets.map(renderRepoRow));
456
+ if (opts.json) {
457
+ // Structured dump of the same rows so agents can enumerate repos + sync state.
458
+ // `raw` (the missing / no-git-remote human label) is the only colored field —
459
+ // strip ANSI so the JSON stays clean; every other field is already structured.
460
+ const clean = rows.map((r) => (r.raw !== undefined ? { ...r, raw: stripAnsi(r.raw) } : r));
461
+ console.log(JSON.stringify(clean, null, 2));
462
+ return;
463
+ }
451
464
  // TTY width when interactive; fall back to $COLUMNS (honored when piped) then 80.
452
465
  const cols = process.stdout.columns || Number(process.env.COLUMNS) || 80;
453
466
  console.log('');
@@ -600,6 +613,7 @@ export function registerRepoCommands(program) {
600
613
  .command('add <source>')
601
614
  .description('Register an existing local repo or clone a remote repo into ~/.agents-<alias>/')
602
615
  .option('--as <alias>', 'Override the auto-derived alias (letters, digits, _ or -)')
616
+ .option('--adopt', 'If the target directory already holds a git checkout, register it in place instead of erroring (even if its origin differs)')
603
617
  .action(async (source, options) => {
604
618
  const meta = readMeta();
605
619
  const extras = { ...(meta.extraRepos || {}) };
@@ -637,7 +651,36 @@ export function registerRepoCommands(program) {
637
651
  ensureAgentsDir();
638
652
  const targetDir = getExtraRepoDir(alias);
639
653
  if (fs.existsSync(targetDir)) {
640
- console.log(chalk.red(`Directory already exists: ${targetDir}`));
654
+ // Adopt an existing checkout instead of hard-erroring. The common case:
655
+ // the user already cloned ~/.agents-<alias> by hand, then ran `repos add`
656
+ // and hit a dead end — which forced a second, inconsistent install method.
657
+ // If it's already a git repo whose origin matches the requested source,
658
+ // register it in place (no re-clone); adopt a mismatched/remoteless repo
659
+ // only with an explicit --adopt.
660
+ if (isGitRepo(targetDir)) {
661
+ const existingUrl = await getRemoteUrl(targetDir);
662
+ const matches = sameGitRemote(existingUrl, parsed.url);
663
+ if (matches || options.adopt) {
664
+ const log = await simpleGit(targetDir).log({ maxCount: 1 }).catch(() => null);
665
+ const commit = log?.latest?.hash.slice(0, 8) || 'unknown';
666
+ extras[alias] = { url: parsed.url, path: targetDir, enabled: true };
667
+ updateMeta({ extraRepos: extras });
668
+ syncExtraAliasAcrossVersions(alias, true);
669
+ syncMarketplacesForDefaults();
670
+ console.log(chalk.green(`Adopted existing repo "${alias}" -> ${targetDir} (${commit})`));
671
+ if (!matches) {
672
+ console.log(chalk.gray(` note: its origin is ${existingUrl ?? '(none)'}, not ${parsed.url} — adopted via --adopt.`));
673
+ }
674
+ console.log(chalk.gray(`Skills and commands from this repo will be picked up the next time you launch any agent.`));
675
+ return;
676
+ }
677
+ console.log(chalk.red(`Directory already exists and is a different repo: ${targetDir}`));
678
+ console.log(chalk.gray(` its origin is ${existingUrl ?? '(none)'}, not ${parsed.url}.`));
679
+ console.log(chalk.gray(' Adopt it anyway with --adopt, or pick a different alias with --as.'));
680
+ process.exitCode = 1;
681
+ return;
682
+ }
683
+ console.log(chalk.red(`Directory already exists (and is not a git repo): ${targetDir}`));
641
684
  console.log(chalk.gray('Remove it manually or pick a different alias with --as.'));
642
685
  process.exitCode = 1;
643
686
  return;
@@ -676,6 +719,7 @@ export function registerRepoCommands(program) {
676
719
  .alias('ls')
677
720
  .description('Show all repos with resource-level sync (skills/commands/plugins to pull or push) and local changes.')
678
721
  .option('-v, --verbose', 'Full per-resource detail in a table, regardless of terminal width')
722
+ .option('--json', 'machine-readable JSON output')
679
723
  .action(async (alias, options) => {
680
724
  await listRepos(alias, options);
681
725
  });
@@ -951,6 +995,7 @@ export function registerRepoCommands(program) {
951
995
  .command('status [alias]', { hidden: true })
952
996
  .description('Alias of `list` (kept for muscle memory).')
953
997
  .option('-v, --verbose', 'Full per-resource detail in a table, regardless of terminal width')
998
+ .option('--json', 'machine-readable JSON output')
954
999
  .action(async (alias, options) => {
955
1000
  await listRepos(alias, options);
956
1001
  });
@@ -0,0 +1,14 @@
1
+ import type { AgentId } from '../lib/types.js';
2
+ import { type RotateCandidate } from '../lib/rotate.js';
3
+ export interface RunAccountChoice {
4
+ name: string;
5
+ value: string;
6
+ disabled?: string;
7
+ ready: boolean;
8
+ }
9
+ /** Human-readable remaining capacity for every window the provider exposes. */
10
+ export declare function formatAccountLimits(candidate: RotateCandidate): string;
11
+ /** Build aligned picker rows with usable accounts first and unsafe rows disabled. */
12
+ export declare function buildRunAccountChoices(candidates: RotateCandidate[], globalDefault: string | null): RunAccountChoice[];
13
+ /** Prompt for one safe installed account/version. A cancelled picker launches nothing. */
14
+ export declare function pickRunAccountCandidate(agent: AgentId): Promise<RotateCandidate | null>;