@phnx-labs/agents-cli 1.22.49 → 1.22.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +10 -0
  3. package/dist/bootstrap.js +1 -1
  4. package/dist/commands/accounts.js +1 -13
  5. package/dist/commands/browser.d.ts +10 -0
  6. package/dist/commands/browser.js +77 -2
  7. package/dist/commands/config.js +20 -0
  8. package/dist/commands/daemon-test-harness.d.ts +42 -0
  9. package/dist/commands/daemon-test-harness.js +90 -0
  10. package/dist/commands/device-config-test-harness.d.ts +11 -0
  11. package/dist/commands/device-config-test-harness.js +59 -0
  12. package/dist/commands/exec.js +24 -1
  13. package/dist/commands/feedback.js +1 -1
  14. package/dist/commands/menubar.js +6 -4
  15. package/dist/commands/share.js +9 -4
  16. package/dist/commands/teams.d.ts +12 -0
  17. package/dist/commands/teams.js +44 -67
  18. package/dist/commands/traces.js +36 -4
  19. package/dist/lib/account-provider-registry.d.ts +9 -0
  20. package/dist/lib/account-provider-registry.js +20 -0
  21. package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
  22. package/dist/lib/accounting/account-pool-collect.js +71 -0
  23. package/dist/lib/accounting/account-pool.d.ts +46 -0
  24. package/dist/lib/accounting/account-pool.js +33 -0
  25. package/dist/lib/accounting/capacity.d.ts +24 -0
  26. package/dist/lib/accounting/capacity.js +30 -0
  27. package/dist/lib/accounting/rotate.d.ts +11 -18
  28. package/dist/lib/accounting/rotate.js +6 -24
  29. package/dist/lib/claude-statusline.d.ts +9 -0
  30. package/dist/lib/claude-statusline.js +45 -4
  31. package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
  32. package/dist/lib/computer/ssh-tunnel.js +13 -8
  33. package/dist/lib/config-keys.d.ts +4 -3
  34. package/dist/lib/config-keys.js +9 -2
  35. package/dist/lib/device-config.js +23 -0
  36. package/dist/lib/factory/snapshot.d.ts +1 -1
  37. package/dist/lib/factory/snapshot.js +1 -1
  38. package/dist/lib/helper-download.d.ts +12 -2
  39. package/dist/lib/helper-download.js +12 -2
  40. package/dist/lib/installations/migrate.js +4 -4
  41. package/dist/lib/installations/store.js +6 -3
  42. package/dist/lib/installations/versions.js +48 -46
  43. package/dist/lib/menubar/install-menubar.d.ts +71 -8
  44. package/dist/lib/menubar/install-menubar.js +183 -24
  45. package/dist/lib/share/publish.d.ts +8 -3
  46. package/dist/lib/share/publish.js +6 -3
  47. package/dist/lib/share/worker-template.js +239 -22
  48. package/dist/lib/star-nudge.d.ts +2 -2
  49. package/dist/lib/star-nudge.js +2 -2
  50. package/dist/lib/state.d.ts +1 -1
  51. package/dist/lib/state.js +2 -2
  52. package/dist/lib/teams/agents.d.ts +14 -12
  53. package/dist/lib/teams/agents.js +188 -73
  54. package/dist/lib/teams/api.d.ts +6 -1
  55. package/dist/lib/teams/api.js +2 -0
  56. package/dist/lib/teams/scheduler.d.ts +7 -0
  57. package/dist/lib/teams/scheduler.js +26 -8
  58. package/dist/lib/traces/sync.d.ts +30 -0
  59. package/dist/lib/traces/sync.js +103 -10
  60. package/package.json +3 -3
@@ -0,0 +1,59 @@
1
+ import { afterEach, expect } from 'vitest';
2
+ import { spawnSync } from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ // End-to-end tests for the unified `agents devices config` surface and the
8
+ // retired-subcommand tombstones (configure / note / set / set-interactive /
9
+ // enable / disable / prefer / unprefer). Spawns the REAL CLI against a
10
+ // throwaway HOME (same pattern as ssh.test.ts) — no mocking; the assertions
11
+ // read the actual files the commands wrote: per-device docs under
12
+ // devices/<name>/agents.yaml (device layer) and central agents.yaml
13
+ // fleet.defaults.config (fleet layer).
14
+ export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
15
+ export const INDEX = path.join(REPO_ROOT, 'src', 'index.ts');
16
+ let testHome = '';
17
+ afterEach(() => {
18
+ if (testHome)
19
+ fs.rmSync(testHome, { recursive: true, force: true });
20
+ testHome = '';
21
+ });
22
+ export function guardedHome() {
23
+ testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-devices-config-home-'));
24
+ const systemDir = path.join(testHome, '.agents', '.system');
25
+ fs.mkdirSync(path.join(systemDir, '.git'), { recursive: true });
26
+ fs.writeFileSync(path.join(systemDir, '.update-check'), JSON.stringify({ lastCheck: 4102444800000, latestVersion: '0.0.0' }));
27
+ }
28
+ export function run(args, extraEnv = {}) {
29
+ const r = spawnSync('bun', [INDEX, ...args], {
30
+ encoding: 'utf-8',
31
+ env: {
32
+ ...process.env,
33
+ HOME: testHome,
34
+ // os.homedir() reads USERPROFILE on Windows, so HOME alone leaves the
35
+ // spawned CLI resolving the real profile ('agents-cli is not set up').
36
+ USERPROFILE: testHome,
37
+ AGENTS_NO_UPDATE_CHECK: '1',
38
+ AGENTS_NO_USAGE_TRACK: '1',
39
+ // Default identity is mac-mini so machine-local keys (scheduler, tmux,
40
+ // browser consent) can be set in these tests. Override per-call to act
41
+ // as a different box.
42
+ AGENTS_SYNC_MACHINE_ID: 'mac-mini',
43
+ ...extraEnv,
44
+ },
45
+ });
46
+ return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status };
47
+ }
48
+ export function centralDoc() {
49
+ const p = path.join(testHome, '.agents', 'agents.yaml');
50
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
51
+ }
52
+ export function deviceDoc(name) {
53
+ const p = path.join(testHome, '.agents', 'devices', name, 'agents.yaml');
54
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
55
+ }
56
+ export function addDevice(name, target = 'muqsit@192.0.2.1') {
57
+ const r = run(['devices', 'add', name, target]);
58
+ expect(r.status, r.stderr).toBe(0);
59
+ }
@@ -2377,7 +2377,13 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2377
2377
  }
2378
2378
  else {
2379
2379
  try {
2380
- const resolved = await resolveRunVersion(agent, strategy, cwd);
2380
+ // Account-centric candidate list: native version-home logins PLUS
2381
+ // provider accounts (setup-token / API-key) that can auth this agent,
2382
+ // so `--strategy balanced` spreads across ALL accounts, not just the
2383
+ // ones sitting in a version home (RUSH-3182). Run-path only — the
2384
+ // picker's other callers keep the native-only collector.
2385
+ const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2386
+ const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun);
2381
2387
  if (resolved.exhausted) {
2382
2388
  // Zero healthy accounts splits two ways, and conflating them is what
2383
2389
  // stranded a logged-out harness with no way in at all (RUSH-2334):
@@ -2428,6 +2434,23 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2428
2434
  else if (resolved.version) {
2429
2435
  version = resolved.version;
2430
2436
  rotationResult = resolved.rotation;
2437
+ // A balanced/available pick of a PROVIDER account (setup-token /
2438
+ // API-key) carries `providerAccount`. Resolve its env through the
2439
+ // same `resolveSpawnAccount` path an explicit `--account` uses, so
2440
+ // exec injects the credential; a native pick has no providerAccount
2441
+ // and runs from its own version home unchanged (RUSH-3182).
2442
+ const pickedProviderAccount = resolved.rotation?.picked.providerAccount;
2443
+ if (pickedProviderAccount) {
2444
+ try {
2445
+ const picked = resolveSpawnAccount(pickedProviderAccount, agent, resolved.version, readMeta(), { useDefault: false });
2446
+ if (picked?.kind === 'provider')
2447
+ accountEnv = picked.env;
2448
+ }
2449
+ catch (err) {
2450
+ console.error(chalk.red(err.message));
2451
+ process.exit(1);
2452
+ }
2453
+ }
2431
2454
  if (resolved.rotation && !options.quiet) {
2432
2455
  const banner = formatRotationBanner(resolved.rotation, strategy);
2433
2456
  process.stderr.write(chalk.gray(banner + '\n'));
@@ -7,7 +7,7 @@ import { arch, platform, release } from 'node:os';
7
7
  import { createRequire } from 'node:module';
8
8
  import chalk from 'chalk';
9
9
  import { showUrl } from '../lib/open-url.js';
10
- const REPO = 'phnx-labs/agents-cli';
10
+ const REPO = 'phnx-labs/agi-cli';
11
11
  const DISCUSSION_BASE = `https://github.com/${REPO}/discussions/new`;
12
12
  const ISSUE_BASE = `https://github.com/${REPO}/issues/new`;
13
13
  function readCliVersion() {
@@ -27,8 +27,9 @@ function printStatus(s, opts = {}) {
27
27
  return;
28
28
  }
29
29
  console.log(` app installed ${s.installedApp ? chalk.gray(s.installedApp) : chalk.gray('no')}`);
30
- console.log(` installed version ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
31
- console.log(` current version ${chalk.gray(s.currentVersion)}`);
30
+ console.log(` helper installed ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
31
+ console.log(` helper available ${chalk.gray(s.currentVersion)}`);
32
+ console.log(` CLI version ${chalk.gray(s.cliVersion)}`);
32
33
  console.log(` bundle source ${s.source ? chalk.gray(s.source) : chalk.red('missing (cannot enable)')}`);
33
34
  console.log(` disabled by user ${yn(s.disabledByUser)}`);
34
35
  // Two copies of the INSTALLED bundle is the duplicate the user sees as two
@@ -80,8 +81,9 @@ function printSetupResult(r) {
80
81
  function printDoctorReport(r) {
81
82
  console.log(chalk.bold('AGI Menu doctor\n'));
82
83
  console.log(` install path ${r.installPath ? chalk.gray(r.installPath) : chalk.red('not installed')}`);
83
- console.log(` installed version ${r.installedVersion ? chalk.gray(r.installedVersion) : chalk.gray('unknown')}`);
84
- console.log(` CLI version ${chalk.gray(r.currentVersion)}${r.versionMatches ? '' : chalk.yellow(' (mismatch — `agents menubar setup` updates it)')}`);
84
+ console.log(` helper installed ${r.installedVersion ? chalk.gray(r.installedVersion) : chalk.gray('unknown')}`);
85
+ console.log(` helper available ${chalk.gray(r.currentVersion)}${r.versionMatches ? '' : chalk.yellow(' (newer helper available — `agents menubar setup` installs it)')}`);
86
+ console.log(` CLI version ${chalk.gray(r.cliVersion)}`);
85
87
  const identity = r.signingIdentity === 'developer-id'
86
88
  ? chalk.green('Developer ID (update-stable)')
87
89
  : r.signingIdentity === 'ad-hoc'
@@ -36,6 +36,9 @@ export function formatSharePublishResult(result, json = false) {
36
36
  if (visibility === 'unlisted') {
37
37
  lines.push(chalk.dim(' visibility: unlisted (noindex, hidden from gallery)'));
38
38
  }
39
+ else if (visibility === 'me' || visibility === 'org') {
40
+ lines.push(chalk.dim(` visibility: ${visibility} (login required, hidden from gallery)`));
41
+ }
39
42
  else {
40
43
  lines.push(chalk.dim(` visibility: ${visibility}`));
41
44
  }
@@ -436,8 +439,8 @@ export function registerShareCommands(artifactsCmd) {
436
439
  .option('--slug <slug>', 'custom URL slug under your namespace (default: <feature>-<16hex>)')
437
440
  .option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config; ignored on the managed endpoint)')
438
441
  .option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
439
- .addOption(new Option('--visibility <level>', 'public | unlisted (default public). unlisted is a capability URL: GET still works, hidden from the gallery, X-Robots-Tag: noindex')
440
- .choices(['public', 'unlisted'])
442
+ .addOption(new Option('--visibility <level>', 'public | unlisted | me | org (default public). unlisted is a capability URL; me/org require a Phoenix session and are hidden from the gallery')
443
+ .choices(['public', 'unlisted', 'me', 'org'])
441
444
  .default('public'))
442
445
  .addOption(new Option('--unlisted', 'hidden alias of --visibility unlisted').hideHelp())
443
446
  .addOption(new Option('--private', 'hidden alias of --visibility unlisted').hideHelp())
@@ -525,8 +528,10 @@ ${SHARE_DELETE_EXAMPLES}
525
528
  a permanent link. --visibility unlisted (hidden aliases: --unlisted / --private)
526
529
  hides the page from the public gallery and agents artifacts share list; the
527
530
  direct URL is still world-readable (unlisted, not secret) and GET sends
528
- X-Robots-Tag: noindex. A pre-publish scan refuses emails and credential-shaped
529
- strings unless --force is passed.
531
+ X-Robots-Tag: noindex. --visibility me requires a Phoenix session and is only
532
+ visible to the signed-in owner; --visibility org requires the same and is
533
+ visible to members of the same Phoenix organization. A pre-publish scan
534
+ refuses emails and credential-shaped strings unless --force is passed.
530
535
 
531
536
  Every publish carries provenance auto-captured from the exec env/git/clock
532
537
  (agent, session, host, repo, date) — never invented, only sent when present —
@@ -7,9 +7,11 @@
7
7
  */
8
8
  import type { Command } from 'commander';
9
9
  import { AgentManager, AgentStatus, type TaskType } from '../lib/teams/agents.js';
10
+ import type { DispatchOptions } from '../lib/cloud/types.js';
10
11
  import { type TaskInfo } from '../lib/teams/api.js';
11
12
  import { type TeamRow } from './teams-picker.js';
12
13
  import { type TeamSpawner } from '../lib/session/db.js';
14
+ import type { AgentProcess } from '../lib/teams/agents.js';
13
15
  type TeamRegistry = Record<string, {
14
16
  created_at: string;
15
17
  description?: string;
@@ -106,6 +108,16 @@ export declare function staleRepoError(params: {
106
108
  * the teammate itself so we don't need the original --cloud CLI args.
107
109
  */
108
110
  export declare function wireCloudDispatcher(mgr: AgentManager): void;
111
+ export declare function cloudDispatchOptions(agent: Pick<AgentProcess, 'prompt' | 'agentType' | 'cloudRepo' | 'cloudBranch' | 'model'>): DispatchOptions;
112
+ /**
113
+ * Single-wave start used by `teams start` without --watch. startReady()
114
+ * terminalizes a placement/spawn/cloud/dependency failure to FAILED with
115
+ * evidence rather than leaving it PENDING, so a teammate that failed this wave
116
+ * appears in neither `launched` nor `still_pending` — it must be diffed against
117
+ * the pre-wave roster and reported explicitly, or the wave reads as a success.
118
+ * A wave that only produced failures sets a non-zero exit code.
119
+ */
120
+ export declare function runOneWave(mgr: AgentManager, team: string, json: boolean): Promise<void>;
109
121
  export declare function buildTeamRowsFromSnapshots(registry: TeamRegistry, agents: TeamListAgentSnapshot[],
110
122
  /** team name -> the session that spawned it (see teamSpawners). */
111
123
  spawners?: Map<string, TeamSpawner>): {
@@ -364,17 +364,21 @@ export function wireCloudDispatcher(mgr) {
364
364
  throw new Error(`Teammate ${a.agentId} has no cloud provider set`);
365
365
  }
366
366
  const prov = resolveProvider(a.cloudProvider);
367
- const dispatchOpts = {
368
- prompt: a.prompt,
369
- agent: a.agentType,
370
- repo: a.cloudRepo ?? undefined,
371
- branch: a.cloudBranch ?? undefined,
372
- model: a.model ?? undefined,
373
- };
367
+ const dispatchOpts = cloudDispatchOptions(a);
374
368
  const cloudTask = await prov.dispatch(dispatchOpts);
375
369
  return { cloudSessionId: cloudTask.id };
376
370
  });
377
371
  }
372
+ export function cloudDispatchOptions(agent) {
373
+ return {
374
+ prompt: agent.prompt,
375
+ agent: agent.agentType,
376
+ repo: agent.cloudRepo ?? undefined,
377
+ branch: agent.cloudBranch ?? undefined,
378
+ model: agent.model ?? undefined,
379
+ env: shareRuntimeEnv(),
380
+ };
381
+ }
378
382
  /**
379
383
  * Advisory: warn once per agent type of any still-pending (staged `--after`)
380
384
  * teammate whose CLI may not be signed in. Warn-only — never blocks `start`.
@@ -446,20 +450,32 @@ async function warnUnsignedTeammates(mgr, team) {
446
450
  }
447
451
  }
448
452
  }
449
- /** Single-wave start used by `teams start` without --watch. */
450
- async function runOneWave(mgr, team, json) {
453
+ /**
454
+ * Single-wave start used by `teams start` without --watch. startReady()
455
+ * terminalizes a placement/spawn/cloud/dependency failure to FAILED with
456
+ * evidence rather than leaving it PENDING, so a teammate that failed this wave
457
+ * appears in neither `launched` nor `still_pending` — it must be diffed against
458
+ * the pre-wave roster and reported explicitly, or the wave reads as a success.
459
+ * A wave that only produced failures sets a non-zero exit code.
460
+ */
461
+ export async function runOneWave(mgr, team, json) {
462
+ const preWavePending = new Set((await mgr.listByTask(team)).filter((a) => a.status === 'pending').map((a) => a.agentId));
451
463
  const launched = await mgr.startReady(team);
452
464
  const all = await mgr.listByTask(team);
453
465
  const stillPending = all.filter((a) => a.status === 'pending');
466
+ const failed = all.filter((a) => a.status === 'failed' && preWavePending.has(a.agentId));
467
+ if (failed.length > 0 && launched.length === 0)
468
+ process.exitCode = 1;
454
469
  if (json) {
455
470
  console.log(JSON.stringify({
456
471
  team,
457
472
  launched: launched.map((a) => ({ agent_id: a.agentId, name: a.name, after: a.after })),
458
473
  still_pending: stillPending.map((a) => ({ agent_id: a.agentId, name: a.name, after: a.after })),
474
+ failed: failed.map((a) => ({ agent_id: a.agentId, name: a.name, after: a.after, failure: a.failure })),
459
475
  }, null, 2));
460
476
  return;
461
477
  }
462
- if (launched.length === 0 && stillPending.length === 0) {
478
+ if (launched.length === 0 && stillPending.length === 0 && failed.length === 0) {
463
479
  console.log(chalk.gray(`No pending teammates in team ${team}.`));
464
480
  return;
465
481
  }
@@ -471,6 +487,16 @@ async function runOneWave(mgr, team, json) {
471
487
  console.log(` ${chalk.cyan(h)} ${who}`);
472
488
  }
473
489
  }
490
+ if (failed.length > 0) {
491
+ if (launched.length > 0)
492
+ console.log();
493
+ console.log(chalk.red(`Failed this wave (${failed.length}):`));
494
+ for (const a of failed) {
495
+ const h = a.name || shortId(a.agentId);
496
+ const why = a.failure ? `${a.failure.code}: ${a.failure.message}` : 'no failure evidence recorded';
497
+ console.log(` ${chalk.red(h)} ${why}`);
498
+ }
499
+ }
474
500
  if (stillPending.length > 0) {
475
501
  console.log();
476
502
  console.log(chalk.gray(`Still pending (${stillPending.length}):`));
@@ -710,6 +736,9 @@ function printAgentDetail(a, session) {
710
736
  if (a.host) {
711
737
  console.log(` ${chalk.gray('host ')} ${chalk.cyan(a.host)}`);
712
738
  }
739
+ if (a.failure) {
740
+ console.log(` ${chalk.red('failure ')} ${a.failure.code}: ${a.failure.message}`);
741
+ }
713
742
  // If the agent's internal session id differs from ours (non-Claude), show
714
743
  // it as a hint for `agents sessions <id>`.
715
744
  if (a.remote_session_id && a.remote_session_id !== a.agent_id) {
@@ -798,6 +827,9 @@ function printAgentSummary(s) {
798
827
  const tools = chalk.gray(` · ${s.tool_count} tools`);
799
828
  const hostBadge = s.host ? chalk.gray(' · on ') + chalk.cyan(s.host) : '';
800
829
  console.log(` ${chalk.cyan(handle.padEnd(14))} ${ident.padEnd(11)} ${label}${duration}${tools}${hostBadge}${errBadge}`);
830
+ if (s.failure) {
831
+ console.log(` ${chalk.red('failure ')} ${s.failure.code}: ${s.failure.message}`);
832
+ }
801
833
  // Files: counts + basenames. Read is count only.
802
834
  const fileLines = [];
803
835
  const renderCat = (label, cat) => {
@@ -1856,36 +1888,8 @@ export function registerTeamsCommands(program) {
1856
1888
  return { cloudSessionId: cloudTask.id };
1857
1889
  });
1858
1890
  }
1859
- let cloudSessionId = null;
1891
+ const cloudSessionId = null;
1860
1892
  const isStaged = after.length > 0;
1861
- if (cloudProviderId && !isStaged) {
1862
- // Ready to run now: dispatch to the cloud provider before registering
1863
- // the teammate so we have the remote session id up front.
1864
- //
1865
- // resolveProvider() and shareRuntimeEnv() are INSIDE the try on purpose:
1866
- // both read config (the cloud registry; `agents.yaml`'s share block) and
1867
- // both throw on a malformed one, and by this point the worktree already
1868
- // exists — leaving them outside stranded the branch with no catch at all.
1869
- try {
1870
- const prov = resolveProvider(cloudProviderId);
1871
- const dispatchOpts = {
1872
- prompt: effectiveTask,
1873
- agent,
1874
- repo: opts.repo,
1875
- branch: opts.branch,
1876
- model: opts.model,
1877
- env: shareRuntimeEnv(),
1878
- };
1879
- const cloudTask = await prov.dispatch(dispatchOpts);
1880
- cloudSessionId = cloudTask.id;
1881
- }
1882
- catch (err) {
1883
- // Same orphan class as a failed spawn: this add already created its
1884
- // worktree, so a dispatch failure must not strand the branch either.
1885
- await tearDownCreatedWorktree();
1886
- dieFriction('teams', 'cloud-dispatch-failed', `Cloud dispatch failed: ${err.message}`);
1887
- }
1888
- }
1889
1893
  try {
1890
1894
  const result = await handleSpawn(mgr, team, agent, effectiveTask, cwd, opts.mode, opts.effort, null, cwd, version, opts.name ?? null, after, opts.model ?? null, envOverrides ?? null, taskType, cloudProviderId, cloudSessionId, opts.repo ?? null, opts.branch ?? null, worktreeName, worktreePath, profileName, hostName, hostTarget, hostRepoPath, teamMeta?.project ?? null);
1891
1895
  emit('teams.add', { module: 'teams', team, agent, name: result.name, agent_id: result.agent_id, status: result.status });
@@ -2057,7 +2061,7 @@ export function registerTeamsCommands(program) {
2057
2061
  });
2058
2062
  // start — fire any staged teammates whose --after deps have all completed
2059
2063
  addHostOption(teams.command('start [team]'))
2060
- .description('Launch any pending teammates whose --after dependencies are satisfied. Use --watch to keep draining the DAG as teammates finish and as new tasks are added mid-flight.')
2064
+ .description('Launch ready teammates independently. Failed launch/placement and blocked --after dependencies persist in status; --watch keeps draining viable DAG branches.')
2061
2065
  .option('--json', 'Output machine-readable JSON')
2062
2066
  .option('--watch', 'Keep running: poll every --interval seconds, fire new waves, exit when the DAG drains.')
2063
2067
  .option('--interval <seconds>', 'Seconds between waves in --watch mode (default 8)', '8')
@@ -2094,33 +2098,6 @@ export function registerTeamsCommands(program) {
2094
2098
  await warnUnsignedTeammates(mgr, team);
2095
2099
  await warnThrottledTeammates(mgr, team);
2096
2100
  }
2097
- // Health-/harness-aware pre-flight (RUSH-2002): if the team declares a
2098
- // device pool and no device in it can run a pending teammate's agent, fail
2099
- // loud here rather than launching a wave that strands teammates pending.
2100
- // --force downgrades it to a warning so the user can still try.
2101
- const placementError = await mgr.preflightPlacement(team);
2102
- if (placementError) {
2103
- if (isJsonMode(opts)) {
2104
- console.log(JSON.stringify({
2105
- team,
2106
- error: 'no-viable-device',
2107
- message: placementError.message,
2108
- excluded: placementError.excluded,
2109
- }));
2110
- if (!opts.force) {
2111
- process.exitCode = 1;
2112
- return;
2113
- }
2114
- }
2115
- else if (opts.force) {
2116
- console.error(chalk.yellow(`⚠ ${placementError.message}\n Continuing anyway (--force).`));
2117
- }
2118
- else {
2119
- console.error(chalk.red(placementError.message));
2120
- process.exitCode = 1;
2121
- return;
2122
- }
2123
- }
2124
2101
  emit('teams.start', { module: 'teams', team, watch: Boolean(opts.watch) });
2125
2102
  if (!opts.watch) {
2126
2103
  await runOneWave(mgr, team, Boolean(opts.json));
@@ -1,7 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { setHelpSections } from '../lib/help.js';
3
- import { syncTraces } from '../lib/traces/sync.js';
4
- import { readSyncLedger } from '../lib/traces/sync.js';
3
+ import { readSyncLedger, syncTraces } from '../lib/traces/sync.js';
5
4
  import { managedTracesBaseUrl, resolveTracesBackend } from '../lib/traces/backend.js';
6
5
  import { showUrl } from '../lib/open-url.js';
7
6
  import { DEFAULT_CF_BUNDLE, readCloudflareCreds } from '../lib/share/config.js';
@@ -32,6 +31,10 @@ const SYNC_NOTES = `
32
31
  An incremental gate (traces-sync.json ledger) means re-runs only upload
33
32
  sessions whose file_mtime_ms changed since the last sync.
34
33
 
34
+ On a real (non-dry-run) sync with the managed Phoenix backend, also
35
+ fire-and-forget registers this session with Prix (api.prix.dev) so the
36
+ console can serve live trajectories — a link failure never blocks sync.
37
+
35
38
  Sign in first with: agents auth login
36
39
  `.trimStart();
37
40
  const STATUS_EXAMPLES = `
@@ -89,7 +92,18 @@ async function handleSync(opts) {
89
92
  parts.push(chalk.dim(`${result.skipped} skipped`));
90
93
  }
91
94
  if (result.errors > 0) {
92
- parts.push(chalk.yellow(`${result.errors} errors`));
95
+ // Distinguish expected history (transcripts cleaned off disk) from genuine
96
+ // failures that will be retried, so the operator knows which need attention.
97
+ const detail = [];
98
+ if (result.transcriptUnavailable > 0) {
99
+ detail.push(`${result.transcriptUnavailable} transcripts no longer on disk`);
100
+ }
101
+ const retryable = result.parseFailed + result.uploadFailed;
102
+ if (retryable > 0) {
103
+ detail.push(`${retryable} parse/upload failures — will retry`);
104
+ }
105
+ parts.push(chalk.yellow(`${result.errors} errors`) +
106
+ (detail.length ? chalk.dim(` (${detail.join(' · ')})`) : ''));
93
107
  }
94
108
  if (parts.length === 0) {
95
109
  parts.push(chalk.dim('nothing new'));
@@ -104,7 +118,25 @@ async function handleStatus() {
104
118
  }
105
119
  const when = new Date(ledger.lastSyncMtime).toLocaleString();
106
120
  console.log(`Last sync: ${chalk.bold(when)}`);
107
- console.log(chalk.dim('Run `agents traces sync` to push new sessions.'));
121
+ const failures = ledger.failures ?? [];
122
+ if (failures.length > 0) {
123
+ const byKind = new Map();
124
+ for (const f of failures) {
125
+ const list = byKind.get(f.kind) ?? [];
126
+ list.push(f);
127
+ byKind.set(f.kind, list);
128
+ }
129
+ console.log(chalk.yellow(`\n${failures.length} unresolved failure${failures.length === 1 ? '' : 's'}:`));
130
+ for (const [kind, list] of byKind) {
131
+ const retry = kind === 'transcript-unavailable' ? chalk.dim(' (not retried)') : chalk.dim(' (retried each sync)');
132
+ console.log(` ${chalk.bold(list.length)} ${kind}${retry}`);
133
+ const example = list[0];
134
+ if (example?.detail) {
135
+ console.log(chalk.dim(` e.g. ${example.id.slice(0, 8)} · ${example.detail}`));
136
+ }
137
+ }
138
+ }
139
+ console.log(chalk.dim('\nRun `agents traces sync` to push new sessions.'));
108
140
  }
109
141
  async function handleOpen() {
110
142
  // M2 console — for now open the base URL which will route to the console once deployed.
@@ -15,4 +15,13 @@ export interface AccountProviderAdapter {
15
15
  validate(kind: AccountAuthKind, value: string): void;
16
16
  }
17
17
  export declare function getAccountProvider(provider: string): AccountProviderAdapter;
18
+ /**
19
+ * Whether a provider account of kind `auth` can authenticate `agent` — i.e. the
20
+ * adapter has an `envFor(agent, auth)` mapping. Used both by the account UI and by
21
+ * the run-candidate pool so a Cursor key never enters Claude's pool and a harness
22
+ * with no provider adapter at all (e.g. kimi, native-login only) yields nothing.
23
+ * A "cannot authenticate" is a clean false; any other error (unknown provider,
24
+ * unsupported kind) is a real bug and re-throws rather than being swallowed.
25
+ */
26
+ export declare function providerAuthenticatesHarness(provider: string, auth: AccountAuthKind, agent: AgentId): boolean;
18
27
  export declare function listAccountProviders(): string[];
@@ -54,6 +54,26 @@ export function getAccountProvider(provider) {
54
54
  throw new Error(`Unknown account provider '${provider}'. Supported: ${listAccountProviders().join(', ')}.`);
55
55
  return adapter;
56
56
  }
57
+ /**
58
+ * Whether a provider account of kind `auth` can authenticate `agent` — i.e. the
59
+ * adapter has an `envFor(agent, auth)` mapping. Used both by the account UI and by
60
+ * the run-candidate pool so a Cursor key never enters Claude's pool and a harness
61
+ * with no provider adapter at all (e.g. kimi, native-login only) yields nothing.
62
+ * A "cannot authenticate" is a clean false; any other error (unknown provider,
63
+ * unsupported kind) is a real bug and re-throws rather than being swallowed.
64
+ */
65
+ export function providerAuthenticatesHarness(provider, auth, agent) {
66
+ try {
67
+ getAccountProvider(provider).envFor(agent, auth);
68
+ return true;
69
+ }
70
+ catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ if (message.includes('cannot authenticate'))
73
+ return false;
74
+ throw err;
75
+ }
76
+ }
57
77
  export function listAccountProviders() {
58
78
  return [...ADAPTERS.keys()].sort();
59
79
  }
@@ -0,0 +1,34 @@
1
+ import type { AgentId } from '../types.js';
2
+ import { type RotateCandidate } from './rotate.js';
3
+ import { type RegistryAccountRecord } from './account-pool.js';
4
+ /** Inputs to {@link foldRegistryCandidates} — injectable so the fold is unit-tested. */
5
+ export interface RunCandidateInputs {
6
+ native: RotateCandidate[];
7
+ records: RegistryAccountRecord[];
8
+ runVersion: string | undefined;
9
+ }
10
+ /**
11
+ * PURE: fold provider-account candidates into the native version-home list.
12
+ *
13
+ * A registry account runs in `runVersion` (the agent's default installed home);
14
+ * the token/key overrides that home's own login at exec, so any home works. It
15
+ * carries `providerAccount` so the run path routes it through the existing
16
+ * `--account` injection (`resolveSpawnAccount` → `accountEnv`). An account already
17
+ * covered by a native login is skipped by accountKey. With no installed version
18
+ * there is nowhere to execute a registry account, so the native list passes
19
+ * through unchanged.
20
+ */
21
+ export declare function foldRegistryCandidates(agent: AgentId, inputs: RunCandidateInputs): RotateCandidate[];
22
+ /**
23
+ * Candidate list for the `agents run` strategy pick, account-centric: the usual
24
+ * native version-home candidates PLUS one candidate per provider account whose
25
+ * provider can authenticate `agent` and whose credential is present locally
26
+ * (RUSH-3182). This is what makes a setup-token / API-key account balance-eligible.
27
+ *
28
+ * Passed as the `collect` argument to {@link resolveRunVersion} on the RUN path
29
+ * ONLY — the other consumers of {@link collectRunCandidates} (watchdog, session
30
+ * recovery, teams placement) keep calling it directly and are unaffected. The
31
+ * existing {@link pickBalancedCandidate} selects over the combined list, so the
32
+ * revoked / rate-limit / exhausted logic is unchanged.
33
+ */
34
+ export declare function collectRunCandidatesForRun(agent: AgentId): Promise<RotateCandidate[]>;
@@ -0,0 +1,71 @@
1
+ import { readAccountRegistry } from '../account-registry.js';
2
+ import { hasKeychainToken } from '../secrets/index.js';
3
+ import { getGlobalDefault, listInstalledVersions } from '../installations/versions.js';
4
+ import { collectRunCandidates } from './rotate.js';
5
+ import { registryPoolCandidates } from './account-pool.js';
6
+ /**
7
+ * Provider accounts whose credential is present on THIS device, as capability
8
+ * records. A registry entry with no local secret is skipped: including it would
9
+ * let `--strategy balanced` pick an account that then fails at spawn and exits the
10
+ * whole run (`resolveSpawnAccount` throws on a missing credential) instead of
11
+ * rotating to a healthy one.
12
+ */
13
+ function localRegistryRecords() {
14
+ return Object.values(readAccountRegistry().accounts)
15
+ .filter((a) => hasKeychainToken(a.secretRef))
16
+ .map((a) => ({ name: a.name, provider: a.provider, auth: a.auth }));
17
+ }
18
+ /**
19
+ * PURE: fold provider-account candidates into the native version-home list.
20
+ *
21
+ * A registry account runs in `runVersion` (the agent's default installed home);
22
+ * the token/key overrides that home's own login at exec, so any home works. It
23
+ * carries `providerAccount` so the run path routes it through the existing
24
+ * `--account` injection (`resolveSpawnAccount` → `accountEnv`). An account already
25
+ * covered by a native login is skipped by accountKey. With no installed version
26
+ * there is nowhere to execute a registry account, so the native list passes
27
+ * through unchanged.
28
+ */
29
+ export function foldRegistryCandidates(agent, inputs) {
30
+ const { native, records, runVersion } = inputs;
31
+ if (!runVersion)
32
+ return native;
33
+ const seen = new Set(native.filter((c) => c.accountKey).map((c) => c.accountKey));
34
+ const extra = registryPoolCandidates(records, agent)
35
+ .filter((r) => !seen.has(r.accountKey))
36
+ .map((r) => ({
37
+ agent,
38
+ version: runVersion,
39
+ accountKey: r.accountKey,
40
+ accountLabel: r.name,
41
+ email: null,
42
+ usageKey: null,
43
+ usageStatus: null,
44
+ usageSnapshot: null,
45
+ usageError: null,
46
+ usageMinutesToLimit: null,
47
+ plan: null,
48
+ signedIn: true,
49
+ authVerdict: null,
50
+ lastActive: null,
51
+ providerAccount: r.name,
52
+ }));
53
+ return [...native, ...extra];
54
+ }
55
+ /**
56
+ * Candidate list for the `agents run` strategy pick, account-centric: the usual
57
+ * native version-home candidates PLUS one candidate per provider account whose
58
+ * provider can authenticate `agent` and whose credential is present locally
59
+ * (RUSH-3182). This is what makes a setup-token / API-key account balance-eligible.
60
+ *
61
+ * Passed as the `collect` argument to {@link resolveRunVersion} on the RUN path
62
+ * ONLY — the other consumers of {@link collectRunCandidates} (watchdog, session
63
+ * recovery, teams placement) keep calling it directly and are unaffected. The
64
+ * existing {@link pickBalancedCandidate} selects over the combined list, so the
65
+ * revoked / rate-limit / exhausted logic is unchanged.
66
+ */
67
+ export async function collectRunCandidatesForRun(agent) {
68
+ const native = await collectRunCandidates(agent);
69
+ const runVersion = getGlobalDefault(agent) ?? listInstalledVersions(agent)[0];
70
+ return foldRegistryCandidates(agent, { native, records: localRegistryRecords(), runVersion });
71
+ }
@@ -0,0 +1,46 @@
1
+ import type { AgentId } from '../types.js';
2
+ import type { AccountAuthKind } from '../account-provider-registry.js';
3
+ /**
4
+ * The provider-account side of the run-candidate pool (RUSH-3182).
5
+ *
6
+ * `--strategy balanced` used to enumerate only version-home native logins, so a
7
+ * setup-token / API-key account added via `agents accounts add` never balanced.
8
+ * This module turns the account registry into the extra candidates the run path
9
+ * folds in — see `collectRunCandidatesForRun` in `account-pool-collect.ts`.
10
+ *
11
+ * Pure + dependency-light so the harness-capability filter is unit-tested in
12
+ * isolation with fixtures.
13
+ */
14
+ /** A provider account record as stored in the registry (identity captured separately). */
15
+ export interface RegistryAccountRecord {
16
+ name: string;
17
+ provider: string;
18
+ auth: AccountAuthKind;
19
+ }
20
+ /** A registry account eligible to run one harness, ready to map to a candidate. */
21
+ export interface RegistryAccountInput {
22
+ /** Agent-scoped key so `(claude, X)` and `(codex, X)` stay distinct. */
23
+ accountKey: string;
24
+ email: string | null;
25
+ name: string;
26
+ provider: string;
27
+ auth: AccountAuthKind;
28
+ }
29
+ /**
30
+ * Which provider accounts can authenticate `agent`. An account is a candidate for
31
+ * harness H only when its provider adapter has an `envFor(H, kind)` mapping
32
+ * (`providerAuthenticatesHarness`, the same check the account UI uses) — so a
33
+ * Cursor key never enters Claude's pool and a Claude setup-token never enters
34
+ * Codex's. This is registry-driven, no per-harness `else if`: a harness gets a
35
+ * provider-account pool exactly when the adapter table maps a provider to it —
36
+ * today claude, codex, grok, cursor, opencode (plus gemini/antigravity). A harness
37
+ * with only a native OAuth login and no provider adapter — kimi — returns `[]`
38
+ * here and keeps balancing its native logins only.
39
+ *
40
+ * `accountKey` is synthetic here (`${agent}:name=${name}`) — a stable per-account
41
+ * key so balancing can include it immediately; the real agent-scoped identity
42
+ * (email / org uuid) is backfilled by identity capture and replaces it. `email`
43
+ * is null until then, which routes the account as usage-unverified but still a
44
+ * candidate, never excluded.
45
+ */
46
+ export declare function registryPoolCandidates(records: RegistryAccountRecord[], agent: AgentId): RegistryAccountInput[];