@phnx-labs/agents-cli 1.22.68 → 1.22.70

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 (132) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +17 -14
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +33 -23
  6. package/dist/commands/feed.js +20 -7
  7. package/dist/commands/import.js +2 -2
  8. package/dist/commands/models.js +2 -2
  9. package/dist/commands/monitors.js +3 -0
  10. package/dist/commands/permissions.js +2 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/rules.js +1 -1
  14. package/dist/commands/send.js +35 -2
  15. package/dist/commands/sessions-inject.d.ts +58 -0
  16. package/dist/commands/sessions-inject.js +143 -7
  17. package/dist/commands/sessions-picker.js +1 -0
  18. package/dist/commands/share.js +43 -14
  19. package/dist/commands/ssh.js +205 -2
  20. package/dist/commands/traces.js +1 -1
  21. package/dist/lib/account-capabilities.js +0 -2
  22. package/dist/lib/account-registry.d.ts +3 -3
  23. package/dist/lib/account-registry.js +25 -7
  24. package/dist/lib/accounting/usage.d.ts +7 -2
  25. package/dist/lib/accounting/usage.js +142 -10
  26. package/dist/lib/acp/client.d.ts +1 -1
  27. package/dist/lib/acp/client.js +12 -1
  28. package/dist/lib/acp/harnesses.js +1 -1
  29. package/dist/lib/add-dir.js +0 -2
  30. package/dist/lib/agent-cli-commands.js +0 -2
  31. package/dist/lib/agent-spec/agents.d.ts +1 -1
  32. package/dist/lib/agent-spec/agents.js +2 -83
  33. package/dist/lib/boot-profile.d.ts +14 -0
  34. package/dist/lib/boot-profile.js +66 -0
  35. package/dist/lib/browser/ipc.js +9 -1
  36. package/dist/lib/browser/remote-control.d.ts +6 -3
  37. package/dist/lib/browser/remote-control.js +6 -3
  38. package/dist/lib/browser/service.d.ts +4 -1
  39. package/dist/lib/browser/service.js +7 -1
  40. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  41. package/dist/lib/channels/providers/desktop.js +5 -4
  42. package/dist/lib/channels/registry.d.ts +6 -0
  43. package/dist/lib/channels/send.d.ts +11 -2
  44. package/dist/lib/channels/send.js +11 -2
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/cloud/rush.d.ts +10 -2
  47. package/dist/lib/cloud/rush.js +15 -9
  48. package/dist/lib/daemon/daemon.js +28 -8
  49. package/dist/lib/daemon/runner.js +6 -2
  50. package/dist/lib/devices/health.d.ts +38 -2
  51. package/dist/lib/devices/health.js +43 -5
  52. package/dist/lib/devices/worker-pick.d.ts +1 -1
  53. package/dist/lib/devices/worker-pick.js +4 -1
  54. package/dist/lib/exec.d.ts +2 -2
  55. package/dist/lib/exec.js +7 -34
  56. package/dist/lib/feed-broadcast.d.ts +47 -5
  57. package/dist/lib/feed-broadcast.js +127 -24
  58. package/dist/lib/hooks/install.js +0 -87
  59. package/dist/lib/installations/strategies.js +1 -1
  60. package/dist/lib/mcp-registry.js +0 -13
  61. package/dist/lib/mcp.js +2 -2
  62. package/dist/lib/model-tiers.js +1 -1
  63. package/dist/lib/models.js +0 -63
  64. package/dist/lib/monitors/engine.js +18 -0
  65. package/dist/lib/monitors/sources/command.js +13 -3
  66. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  67. package/dist/lib/monitors/sources/failure.js +52 -0
  68. package/dist/lib/monitors/sources/types.d.ts +9 -0
  69. package/dist/lib/notify.d.ts +11 -0
  70. package/dist/lib/notify.js +17 -4
  71. package/dist/lib/owner-message.d.ts +55 -0
  72. package/dist/lib/owner-message.js +64 -0
  73. package/dist/lib/permissions-registry.d.ts +0 -2
  74. package/dist/lib/permissions-registry.js +3 -50
  75. package/dist/lib/permissions.d.ts +3 -17
  76. package/dist/lib/permissions.js +4 -73
  77. package/dist/lib/run-trace-sync.d.ts +15 -0
  78. package/dist/lib/run-trace-sync.js +43 -21
  79. package/dist/lib/rush-session.d.ts +19 -0
  80. package/dist/lib/rush-session.js +24 -0
  81. package/dist/lib/secrets/drivers/rush.js +2 -1
  82. package/dist/lib/secrets/filestore.d.ts +4 -0
  83. package/dist/lib/secrets/filestore.js +164 -3
  84. package/dist/lib/session/active.d.ts +10 -0
  85. package/dist/lib/session/active.js +3 -0
  86. package/dist/lib/session/cloud.js +2 -1
  87. package/dist/lib/session/db.d.ts +12 -1
  88. package/dist/lib/session/db.js +20 -1
  89. package/dist/lib/session/discover.js +81 -1
  90. package/dist/lib/session/linear.d.ts +13 -0
  91. package/dist/lib/session/linear.js +44 -0
  92. package/dist/lib/session/live-metadata.js +1 -0
  93. package/dist/lib/session/parse.js +2 -3
  94. package/dist/lib/session/prompt.d.ts +7 -1
  95. package/dist/lib/session/prompt.js +12 -2
  96. package/dist/lib/session/recovery.d.ts +21 -12
  97. package/dist/lib/session/recovery.js +29 -11
  98. package/dist/lib/session/remote/watch.js +5 -2
  99. package/dist/lib/session/state.js +11 -13
  100. package/dist/lib/share/backend.d.ts +2 -2
  101. package/dist/lib/share/backend.js +20 -9
  102. package/dist/lib/share/delete.d.ts +5 -1
  103. package/dist/lib/share/delete.js +7 -2
  104. package/dist/lib/share/http-error.d.ts +52 -0
  105. package/dist/lib/share/http-error.js +65 -0
  106. package/dist/lib/share/publish.d.ts +13 -3
  107. package/dist/lib/share/publish.js +19 -15
  108. package/dist/lib/share/worker-template.js +5 -1
  109. package/dist/lib/sink-format.d.ts +34 -0
  110. package/dist/lib/sink-format.js +17 -0
  111. package/dist/lib/smart-launch.js +27 -4
  112. package/dist/lib/staleness/detectors/permissions.js +0 -20
  113. package/dist/lib/staleness/writers/commands.js +1 -1
  114. package/dist/lib/staleness/writers/hooks.js +2 -2
  115. package/dist/lib/storage/index.d.ts +14 -0
  116. package/dist/lib/storage/index.js +14 -0
  117. package/dist/lib/storage/selection.d.ts +48 -0
  118. package/dist/lib/storage/selection.js +39 -0
  119. package/dist/lib/storage/visibility.d.ts +82 -0
  120. package/dist/lib/storage/visibility.js +99 -0
  121. package/dist/lib/subagents-registry.js +2 -12
  122. package/dist/lib/subagents.d.ts +0 -10
  123. package/dist/lib/subagents.js +0 -37
  124. package/dist/lib/teams/agents.js +3 -1
  125. package/dist/lib/teams/placement-probe.js +1 -0
  126. package/dist/lib/teams/scheduler.d.ts +8 -1
  127. package/dist/lib/teams/scheduler.js +4 -1
  128. package/dist/lib/traces/backend.js +13 -2
  129. package/dist/lib/types.d.ts +1 -1
  130. package/dist/lib/worktree/held.d.ts +166 -0
  131. package/dist/lib/worktree/held.js +368 -0
  132. package/package.json +2 -2
@@ -37,7 +37,7 @@ import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
37
37
  import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, deviceIdentityArgs, fleetDialTarget, isAgentsBrowserDrive, markFleetRemote, writeAskpassShim, } from '../lib/devices/connect.js';
38
38
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
39
39
  import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
40
- import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
40
+ import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, runOnDevice, runLocalCommand, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
41
41
  import { isRolloutSuccess, verifyFleetRollout, } from '../lib/devices/rollout-verify.js';
42
42
  import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
43
43
  import { buildFleetHealthReport, renderFleetMatrix, renderFleetSummary, renderFleetWarnings, } from '../lib/devices/health-report.js';
@@ -61,6 +61,8 @@ import { runFleetLogin } from '../lib/fleet/remote-login.js';
61
61
  import { getConfigValue, listConfig, setConfigValue, unsetConfigValue, configKeySpec, autoPoolMode, configuredDeviceRole, listConfiguredDeviceRoles, setConfiguredDeviceRole, } from '../lib/device-config.js';
62
62
  import { filterAutoPool, listWorkerDevices } from '../lib/devices/pool.js';
63
63
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
64
+ import { isSelfHost } from '../lib/devices/self-host.js';
65
+ import { collectHeldWorktrees, collectHeldWorktreesUnder, summarizeHeld, aggregateHeld, pushStrandedBranch, } from '../lib/worktree/held.js';
64
66
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
65
67
  * command is running on so it stands out from the rest of the tailnet.
66
68
  * `isInteractive` marks the configured interactive host (`devices config <name> interactive.host`). */
@@ -1130,7 +1132,7 @@ function registerDevicesCommands(program) {
1130
1132
  { title: 'Inspect', names: ['list', 'show', 'status', 'ping', 'harnesses', 'accounts', 'snapshot'] },
1131
1133
  { title: 'Disposable devices', names: ['lease'] },
1132
1134
  { title: 'Configure a device', names: ['config', 'describe', 'render'] },
1133
- { title: 'Fleet operations', names: ['update', 'run', 'login', 'capture', 'apply'] },
1135
+ { title: 'Fleet operations', names: ['update', 'run', 'login', 'capture', 'apply', 'worktrees'] },
1134
1136
  ]);
1135
1137
  devicesCmd
1136
1138
  .command('sync')
@@ -2363,6 +2365,207 @@ is reported \`failed\` (exit non-zero), never a stranded \`ok\`.
2363
2365
  .alias('kill')
2364
2366
  .description('Terminate a running dispatched task from this machine (SIGTERM the remote process group; marks it failed/143).')
2365
2367
  .action((id) => doDeviceTaskStop(id));
2368
+ const worktreesCmd = devicesCmd
2369
+ .command('worktrees')
2370
+ .description("Surface the held set of agent worktrees the sweep only counts, broken into buckets: unmerged-commits (real stranded work), uncommitted-changes, undeterminable. Read-only; --push publishes stranded branches.")
2371
+ .option('--json', 'emit the structured held set (device, repo, worktree, branch, reason, age, size) for machine callers / fleet aggregation')
2372
+ .option('--home <dir>', 'search root to discover repos under (default: $HOME)')
2373
+ .option('--repo <path>', 'scope to a single repo root instead of discovering')
2374
+ .option('--bucket <name>', 'show only one bucket: unmerged-commits | uncommitted-changes | undeterminable')
2375
+ .option('--fleet', 'fan out across every online device and aggregate the held set fleet-wide')
2376
+ .option('--push', 'PUBLISH each unmerged-commits branch that is on no remote (the safe recovery action); never deletes')
2377
+ .option('--yes', 'skip the confirmation prompt for --push')
2378
+ .action((opts) => runWorktreesHeld(opts));
2379
+ setHelpSections(worktreesCmd, {
2380
+ examples: `
2381
+ Inspect the residue:
2382
+ agents fleet worktrees # this box, grouped by bucket
2383
+ agents fleet worktrees --bucket unmerged-commits
2384
+ agents fleet worktrees --json # structured, for scripts
2385
+
2386
+ Fleet-wide:
2387
+ agents fleet worktrees --fleet # aggregate every online device
2388
+ agents fleet run 'agents fleet worktrees --json' # per-device composition
2389
+
2390
+ Recover stranded work (never deletes):
2391
+ agents fleet worktrees --push # publish on-no-remote branches
2392
+ `,
2393
+ notes: 'The nightly worktree-sweep (phnx-labs/.agents) reclaims merged worktrees and HOLDS the rest, reporting only a count. This surfaces that held set. unmerged-commits is the one that matters — a branch with commits on no remote is the PHNX-2951/PHNX-2732 stranded-work class; --push makes it visible. --push and --fleet are mutually exclusive (recovery is per-device on purpose).',
2394
+ });
2395
+ }
2396
+ const BUCKET_ORDER = ['unmerged-commits', 'uncommitted-changes', 'undeterminable'];
2397
+ const BUCKET_LABEL = {
2398
+ 'unmerged-commits': 'unmerged-commits (stranded work — push or open a PR)',
2399
+ 'uncommitted-changes': 'uncommitted-changes (dirty tree — needs review)',
2400
+ 'undeterminable': 'undeterminable (broken/locked — re-examine)',
2401
+ };
2402
+ function isHeldBucket(v) {
2403
+ return BUCKET_ORDER.includes(v);
2404
+ }
2405
+ function fmtWtSize(n) {
2406
+ if (n < 0)
2407
+ return '?';
2408
+ if (n < 1024)
2409
+ return `${n}B`;
2410
+ const units = ['K', 'M', 'G', 'T'];
2411
+ let v = n / 1024;
2412
+ let i = 0;
2413
+ while (v >= 1024 && i < units.length - 1) {
2414
+ v /= 1024;
2415
+ i++;
2416
+ }
2417
+ return `${v.toFixed(v < 10 ? 1 : 0)}${units[i]}`;
2418
+ }
2419
+ /**
2420
+ * `agents fleet worktrees` — surface the held set the sweep only counts. Never
2421
+ * removes anything; `--push` publishes on-no-remote stranded branches. This is
2422
+ * the CLI-native surfacing half of PHNX-3520 (the sweep itself lives in
2423
+ * phnx-labs/.agents; it still emits a bare count until a sibling change teaches
2424
+ * it to consume this).
2425
+ */
2426
+ async function runWorktreesHeld(opts) {
2427
+ if (opts.bucket && !isHeldBucket(opts.bucket)) {
2428
+ console.error(chalk.red(`Unknown bucket '${opts.bucket}'. Use one of: ${BUCKET_ORDER.join(', ')}`));
2429
+ process.exitCode = 1;
2430
+ return;
2431
+ }
2432
+ if (opts.fleet && opts.push) {
2433
+ console.error(chalk.red('--push and --fleet are mutually exclusive; run --push on the device that holds the work.'));
2434
+ process.exitCode = 1;
2435
+ return;
2436
+ }
2437
+ if (opts.fleet && opts.repo) {
2438
+ console.error(chalk.red('--repo scopes a single local repo and cannot combine with --fleet; drop one. To scope on each device, run `agents fleet run \'agents fleet worktrees --repo <path> --json\'`.'));
2439
+ process.exitCode = 1;
2440
+ return;
2441
+ }
2442
+ if (opts.fleet) {
2443
+ await runWorktreesHeldFleet(opts);
2444
+ return;
2445
+ }
2446
+ const searchHome = opts.home ?? os.homedir();
2447
+ const held = opts.repo
2448
+ ? await collectHeldWorktrees(path.resolve(opts.repo))
2449
+ : await collectHeldWorktreesUnder(searchHome);
2450
+ if (opts.push) {
2451
+ await runWorktreesPush(held, opts);
2452
+ return;
2453
+ }
2454
+ const shown = opts.bucket ? held.filter((w) => w.bucket === opts.bucket) : held;
2455
+ if (opts.json) {
2456
+ console.log(JSON.stringify(shown, null, 2));
2457
+ return;
2458
+ }
2459
+ const summary = summarizeHeld(shown);
2460
+ if (summary.total === 0) {
2461
+ console.log(chalk.green('No held worktrees — nothing stranded, dirty, or undeterminable.'));
2462
+ return;
2463
+ }
2464
+ for (const bucket of BUCKET_ORDER) {
2465
+ const items = summary.buckets[bucket];
2466
+ if (items.length === 0)
2467
+ continue;
2468
+ const color = bucket === 'unmerged-commits' ? chalk.yellow : chalk.gray;
2469
+ console.log('\n' + color(chalk.bold(`${BUCKET_LABEL[bucket]} — ${items.length}`)));
2470
+ for (const w of items.sort((a, b) => b.unmergedCommits - a.unmergedCommits)) {
2471
+ const detail = bucket === 'unmerged-commits'
2472
+ ? `${w.unmergedCommits} commit${w.unmergedCommits === 1 ? '' : 's'}, ${w.hasRemoteBranch ? 'on remote' : chalk.red('NO remote')}`
2473
+ : bucket === 'uncommitted-changes'
2474
+ ? `${w.dirtyFiles} dirty file${w.dirtyFiles === 1 ? '' : 's'}`
2475
+ : w.reason;
2476
+ console.log(` ${chalk.cyan(w.repoName + '/' + w.name).padEnd(48)} ${chalk.gray((w.branch ?? 'detached').padEnd(28))} ${detail} ${chalk.dim(`${w.ageDays}d · ${fmtWtSize(w.sizeBytes)}`)}`);
2477
+ }
2478
+ }
2479
+ const stranded = summary.buckets['unmerged-commits'].filter((w) => !w.hasRemoteBranch).length;
2480
+ if (stranded > 0) {
2481
+ console.log('\n' + chalk.yellow(`${stranded} branch${stranded === 1 ? '' : 'es'} with unmerged commits on no remote. Recover: agents fleet worktrees --push`));
2482
+ }
2483
+ }
2484
+ /** `--push`: publish every on-no-remote stranded branch. Never deletes. */
2485
+ async function runWorktreesPush(held, opts) {
2486
+ const candidates = held.filter((w) => w.bucket === 'unmerged-commits' && !w.hasRemoteBranch && w.branch);
2487
+ if (candidates.length === 0) {
2488
+ console.log(chalk.green('No stranded branches to publish (nothing with unmerged commits on no remote).'));
2489
+ return;
2490
+ }
2491
+ if (isInteractiveTerminal() && !opts.yes) {
2492
+ console.log(chalk.yellow(`About to push ${candidates.length} stranded branch${candidates.length === 1 ? '' : 'es'} to origin:`));
2493
+ for (const w of candidates)
2494
+ console.log(` ${chalk.cyan(w.repoName + '/' + w.name)} → ${w.branch}`);
2495
+ const { confirm } = await import('@inquirer/prompts');
2496
+ const go = await confirm({ message: 'Push these branches?', default: true }).catch(() => false);
2497
+ if (!go) {
2498
+ console.log(chalk.gray('Cancelled — nothing pushed.'));
2499
+ return;
2500
+ }
2501
+ }
2502
+ let pushed = 0;
2503
+ for (const w of candidates) {
2504
+ const res = await pushStrandedBranch(w.repo, w);
2505
+ if (res.pushed) {
2506
+ pushed++;
2507
+ console.log(chalk.green(` pushed ${w.repoName}/${w.name} (${res.branch})`));
2508
+ }
2509
+ else {
2510
+ console.log(chalk.yellow(` skipped ${w.repoName}/${w.name}: ${res.reason}`));
2511
+ }
2512
+ }
2513
+ console.log('\n' + chalk.green(`Published ${pushed}/${candidates.length} stranded branch${candidates.length === 1 ? '' : 'es'}.`));
2514
+ }
2515
+ /** `--fleet`: run this same read-only surface on every online device, aggregate. */
2516
+ async function runWorktreesHeldFleet(opts) {
2517
+ const reg = await loadDevices();
2518
+ const targets = planFleetTargets(reg);
2519
+ const online = targets.filter((t) => !t.skip);
2520
+ if (online.length === 0) {
2521
+ console.log(chalk.gray("No online devices. Run 'agents devices sync' first."));
2522
+ return;
2523
+ }
2524
+ const self = machineId();
2525
+ const remoteCmd = ['agents', 'fleet', 'worktrees', '--json'];
2526
+ if (opts.home)
2527
+ remoteCmd.push('--home', opts.home);
2528
+ const perDevice = [];
2529
+ for (const t of online) {
2530
+ const name = t.device.name;
2531
+ const isSelf = name === self || isSelfHost(name);
2532
+ const res = isSelf ? runLocalCommand(remoteCmd) : runOnDevice(t.device, remoteCmd);
2533
+ if (res.code !== 0) {
2534
+ console.error(chalk.gray(` ${name}: ${(res.stderr || 'failed').trim().slice(0, 120)}`));
2535
+ continue;
2536
+ }
2537
+ try {
2538
+ perDevice.push({ device: name, held: JSON.parse(res.stdout) });
2539
+ }
2540
+ catch {
2541
+ console.error(chalk.gray(` ${name}: unparseable output (older CLI?)`));
2542
+ }
2543
+ }
2544
+ // Apply the bucket filter to the per-device inputs BEFORE aggregating, so
2545
+ // `total`, per-`devices` totals, and `buckets` all describe the same filtered
2546
+ // set — zeroing buckets after the sum leaves `total` counting rows the output
2547
+ // no longer lists.
2548
+ const scoped = opts.bucket
2549
+ ? perDevice.map((d) => ({ device: d.device, held: d.held.filter((w) => w.bucket === opts.bucket) }))
2550
+ : perDevice;
2551
+ const agg = aggregateHeld(scoped);
2552
+ if (opts.json) {
2553
+ console.log(JSON.stringify(agg, null, 2));
2554
+ return;
2555
+ }
2556
+ console.log(chalk.bold(`Held worktrees across ${perDevice.length} device(s): ${agg.total}`));
2557
+ for (const bucket of BUCKET_ORDER) {
2558
+ const items = agg.buckets[bucket];
2559
+ if (items.length === 0)
2560
+ continue;
2561
+ const color = bucket === 'unmerged-commits' ? chalk.yellow : chalk.gray;
2562
+ console.log('\n' + color(chalk.bold(`${BUCKET_LABEL[bucket]} — ${items.length}`)));
2563
+ for (const w of items) {
2564
+ const dev = w.device ?? '?';
2565
+ const detail = bucket === 'unmerged-commits' ? `${w.unmergedCommits} commits, ${w.hasRemoteBranch ? 'on remote' : chalk.red('NO remote')}` : w.reason;
2566
+ console.log(` ${chalk.magenta(dev.padEnd(14))} ${chalk.cyan((w.repoName + '/' + w.name).padEnd(40))} ${chalk.gray((w.branch ?? 'detached').padEnd(24))} ${detail}`);
2567
+ }
2568
+ }
2366
2569
  }
2367
2570
  /**
2368
2571
  * `agents devices ps` — list tasks dispatched to devices (`agents run --device
@@ -182,7 +182,7 @@ export function registerTracesCommands(program) {
182
182
  .description("Sync this device's derived, redacted trajectories to your Phoenix account");
183
183
  const setupCmd = tracesCmd
184
184
  .command('setup')
185
- .description('Provision the private Cloudflare Worker and R2 bucket for traces')
185
+ .description('(advanced) Provision a self-hosted Cloudflare Worker + R2 bucket for traces — NOT required; `agents auth login` syncs to the managed endpoint with zero setup')
186
186
  .option('--bundle <name>', 'secrets bundle holding the Cloudflare API token', DEFAULT_CF_BUNDLE)
187
187
  .option('--worker <name>', 'Worker name', DEFAULT_WORKER_NAME)
188
188
  .option('--bucket <name>', 'R2 bucket name', DEFAULT_BUCKET_NAME)
@@ -35,10 +35,8 @@ export const NATIVE_ACCOUNT_CAPABILITIES = {
35
35
  copilot: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
36
36
  openclaw: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
37
37
  amp: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
38
- kiro: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
39
38
  goose: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
40
39
  hermes: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
41
- pi: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
42
40
  warp: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
43
41
  };
44
42
  export function nativeAccountCapability(agent) {
@@ -54,12 +54,12 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'device
54
54
  * running before the body, so callers that only need a native lookup must be
55
55
  * able to omit it.
56
56
  */
57
- export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
57
+ export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument, preferAgent?: AgentId): UnifiedAccount | null;
58
58
  export declare function addNativeAccount(name: string, agent: AgentId, identityKey: string, identityLabel: string | undefined, scope: 'version' | 'device'): NativeAccount;
59
59
  /** Create or replace the version-independent label for one native identity. */
60
60
  export declare function labelNativeAccount(agent: AgentId, identityKey: string, identityLabel: string | undefined, label: string | undefined, scope: 'version' | 'device'): NativeAccount;
61
- export declare function bindAccount(nameOrId: string, target: string): UnifiedAccount;
62
- export declare function unbindAccount(nameOrId: string, target: string): void;
61
+ export declare function bindAccount(nameOrId: string, target: string, preferAgent?: AgentId): UnifiedAccount;
62
+ export declare function unbindAccount(nameOrId: string, target: string, preferAgent?: AgentId): void;
63
63
  /** Every target bound to `accountId`: this box's device-doc bindings merged over
64
64
  * the fleet-shared central bindings (PHNX-3315). */
65
65
  export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
@@ -159,9 +159,18 @@ export function listNativeAccounts(meta) {
159
159
  * running before the body, so callers that only need a native lookup must be
160
160
  * able to omit it.
161
161
  */
162
- export function findUnifiedAccount(nameOrId, meta, doc) {
162
+ export function findUnifiedAccount(nameOrId, meta, doc, preferAgent) {
163
163
  const needle = nameOrId.toLowerCase();
164
- const native = listNativeAccounts(meta).find(account => account.id === nameOrId || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
164
+ const matches = listNativeAccounts(meta).filter(account => account.id === nameOrId || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
165
+ // `identityLabel` defaults to the login's own identifier (the email), so ONE
166
+ // selector legitimately matches several harnesses: `muqsitnawaz@gmail.com` is a
167
+ // claude login AND a codex login. Un-scoped, `.find()` returned whichever row the
168
+ // merged store happened to order first, so `agents run claude#<email>` died with
169
+ // "Account 'personal' is a codex login and cannot authenticate the claude harness"
170
+ // while that identity's own claude login sat right there. Prefer the harness being
171
+ // launched; fall back to the first match so a management lookup with no harness in
172
+ // hand (rename/remove/view) behaves exactly as before.
173
+ const native = matches.find(account => account.agent === preferAgent) ?? matches[0];
165
174
  if (native)
166
175
  return native;
167
176
  const provider = findAccount(nameOrId, doc ?? readAccountRegistry());
@@ -253,9 +262,13 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
253
262
  });
254
263
  return { ...matches[0], name: resolvedLabel, identityLabel, scope: rowScope };
255
264
  }
256
- export function bindAccount(nameOrId, target) {
265
+ export function bindAccount(nameOrId, target, preferAgent) {
257
266
  const meta = readMeta();
258
- const account = findUnifiedAccount(nameOrId, meta);
267
+ // Scope to the harness being bound to: a bare identity selector matches every
268
+ // harness it is signed into, so without this the binding could persist against
269
+ // a different harness's row than the caller validated (the attach command
270
+ // resolves `targetAgent` and passes it here).
271
+ const account = findUnifiedAccount(nameOrId, meta, undefined, preferAgent);
259
272
  if (!account)
260
273
  throw new Error(`Unknown account '${nameOrId}'.`);
261
274
  // A binding follows its account: one that targets a device-scoped native login
@@ -275,9 +288,11 @@ export function bindAccount(nameOrId, target) {
275
288
  }
276
289
  return account;
277
290
  }
278
- export function unbindAccount(nameOrId, target) {
291
+ export function unbindAccount(nameOrId, target, preferAgent) {
279
292
  const meta = readMeta();
280
- const account = findUnifiedAccount(nameOrId, meta);
293
+ // Same scoping as bindAccount: detach the row on the harness the caller means,
294
+ // not whichever the store happened to order first for a colliding identity.
295
+ const account = findUnifiedAccount(nameOrId, meta, undefined, preferAgent);
281
296
  if (!account)
282
297
  throw new Error(`Unknown account '${nameOrId}'.`);
283
298
  const inCentral = meta.accounts?.bindings?.[target] === account.id;
@@ -527,7 +542,10 @@ export function resolveSpawnAccount(explicit, agent, version, meta, opts = {}) {
527
542
  const selection = resolveAccountSelection(explicit, agent, meta, { useDefault: opts.useDefault, target });
528
543
  if (!selection)
529
544
  return null;
530
- const unified = findUnifiedAccount(selection.id, meta);
545
+ // Scope the lookup to the harness being launched: a bare identity selector
546
+ // (`claude#muqsitnawaz@gmail.com`) matches every harness that identity is signed
547
+ // into, and only this one can authenticate the spawn.
548
+ const unified = findUnifiedAccount(selection.id, meta, undefined, agent);
531
549
  if (!unified) {
532
550
  // A stale per-harness default is a preference, not a hard requirement: the
533
551
  // machine stays runnable by falling back to balanced rotation. Bindings and
@@ -180,8 +180,13 @@ export interface UsageSnapshot {
180
180
  * MUST NEVER read this field — `isUsageVerified`/`hasStaleUsage`/
181
181
  * `hasUsageAvailable`/`deriveUsageStatusFromSnapshot` consult only `windows`,
182
182
  * so a stale number rendered here can never make a stale account read as
183
- * verified or eligible (the RUSH-2858 property). Never serialized to the
184
- * on-disk cache or `--json` (both project `windows` explicitly).
183
+ * verified or eligible (the RUSH-2858 property). Not a serialized key of its
184
+ * own, and dropped from `--json` (which projects `windows` explicitly) — but
185
+ * the READINGS it holds do round-trip through the on-disk cache:
186
+ * `serializeClaudeUsageSnapshot` persists the union of `windows` and
187
+ * `staleWindows`, and `deserializeClaudeUsageSnapshot` re-runs the freshness
188
+ * gate on read to re-partition them (so a collector like Grok that pre-splits
189
+ * an ended-period reading onto `staleWindows` still survives the round-trip).
185
190
  */
186
191
  staleWindows?: UsageWindow[];
187
192
  plan?: string | null;
@@ -1900,6 +1900,17 @@ function writeClaudeUsageCacheFile(cache, cachePath) {
1900
1900
  }
1901
1901
  /** Convert a live UsageSnapshot to its JSON-serializable cached form. */
1902
1902
  function serializeClaudeUsageSnapshot(snapshot) {
1903
+ // Persist the union of fresh `windows` and last-known `staleWindows`.
1904
+ // `deserializeClaudeUsageSnapshot` re-runs the freshness gate on read and
1905
+ // re-partitions the serialized windows into fresh vs. stale, so what matters
1906
+ // is that every last-known reading reaches disk. Claude's collector returns
1907
+ // raw windows (no `staleWindows`) and relies on that read-side partition. But
1908
+ // Grok's collector pre-partitions in the fetch, moving an ended-period reading
1909
+ // onto `staleWindows` — serializing only `windows` dropped it, so the very
1910
+ // number a daemon `--refresh` just captured was gone from the next cached
1911
+ // `agents view grok`, which rendered the plan alone (no bar). Include the
1912
+ // stale windows here so the round-trip preserves them for any collector.
1913
+ const persistedWindows = [...snapshot.windows, ...(snapshot.staleWindows ?? [])];
1903
1914
  return {
1904
1915
  capturedAt: snapshot.capturedAt?.toISOString() || null,
1905
1916
  plan: snapshot.plan ?? null,
@@ -1910,7 +1921,7 @@ function serializeClaudeUsageSnapshot(snapshot) {
1910
1921
  resetsAt: snapshot.unavailable.resetsAt?.toISOString(),
1911
1922
  }
1912
1923
  : undefined,
1913
- windows: snapshot.windows.map((window) => ({
1924
+ windows: persistedWindows.map((window) => ({
1914
1925
  key: window.key,
1915
1926
  label: window.label,
1916
1927
  shortLabel: window.shortLabel,
@@ -2400,17 +2411,131 @@ function safeStatSync(filePath) {
2400
2411
  return null;
2401
2412
  }
2402
2413
  }
2414
+ /**
2415
+ * Resolve the Grok billing log to read usage from.
2416
+ *
2417
+ * `agents view grok` reads usage per INSTALLED VERSION, passing each version's
2418
+ * isolated home (`~/.agents/.history/versions/grok/<ver>`). Grok writes
2419
+ * `unified.jsonl` only to the shared real home `~/.grok/logs/unified.jsonl`
2420
+ * even though GROK_HOME isolates auth/config/models per version. A per-version
2421
+ * log, if one ever appears, still wins; otherwise we return the shared path
2422
+ * marked `shared: true` so the caller can attribute it to at most one identity.
2423
+ * Grok accounts are version-scoped (`NATIVE_ACCOUNT_CAPABILITIES.grok.scope ===
2424
+ * 'version'`) — the shared file has no owner, so it must not be stamped onto
2425
+ * every version home.
2426
+ */
2427
+ function resolveGrokBillingLogPath(home) {
2428
+ const rel = ['.grok', 'logs', 'unified.jsonl'];
2429
+ const perVersion = path.join(home || os.homedir(), ...rel);
2430
+ try {
2431
+ if (fs.existsSync(perVersion))
2432
+ return { logPath: perVersion, shared: false };
2433
+ }
2434
+ catch { /* unreadable */ }
2435
+ // `AGENTS_REAL_HOME` is the seam every version-home consumer honors: a
2436
+ // daemon/service-manager child's HOME can be baked to something other than
2437
+ // the account's real home, so os.homedir() alone is not a reliable stand-in.
2438
+ const shared = path.join(process.env.AGENTS_REAL_HOME || os.homedir(), ...rel);
2439
+ if (shared !== perVersion) {
2440
+ try {
2441
+ if (fs.existsSync(shared))
2442
+ return { logPath: shared, shared: true };
2443
+ }
2444
+ catch { /* unreadable */ }
2445
+ }
2446
+ return null;
2447
+ }
2448
+ /** This home's own `.grok/auth.json` only — never the shared-home fallback. */
2449
+ function readGrokAuthIdentity(home) {
2450
+ const authPath = path.join(home, '.grok', 'auth.json');
2451
+ try {
2452
+ if (!fs.existsSync(authPath))
2453
+ return null;
2454
+ const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
2455
+ const records = (data && typeof data === 'object' ? [data, ...Object.values(data)] : [])
2456
+ .filter((r) => !!r && typeof r === 'object' && !Array.isArray(r));
2457
+ const account = records
2458
+ .filter((r) => typeof r.refresh_token === 'string' || typeof r.email === 'string' || typeof r.user_id === 'string')
2459
+ .sort((a, b) => String(b.create_time || '').localeCompare(String(a.create_time || '')))[0];
2460
+ if (!account)
2461
+ return null;
2462
+ const userRaw = account.user_id ?? account.principal_id;
2463
+ const userId = typeof userRaw === 'string' && userRaw.trim() ? userRaw.trim() : null;
2464
+ const email = typeof account.email === 'string' && account.email.trim()
2465
+ ? account.email.trim().toLowerCase()
2466
+ : null;
2467
+ if (!userId && !email)
2468
+ return null;
2469
+ return { userId, email };
2470
+ }
2471
+ catch {
2472
+ return null;
2473
+ }
2474
+ }
2475
+ function grokIdentitiesMatch(a, b) {
2476
+ if (!a || !b)
2477
+ return false;
2478
+ if (a.userId && b.userId)
2479
+ return a.userId === b.userId;
2480
+ if (a.email && b.email)
2481
+ return a.email === b.email;
2482
+ return false;
2483
+ }
2484
+ function grokIdentityFromBillingPayload(parsed) {
2485
+ const ctx = parsed.ctx && typeof parsed.ctx === 'object' && !Array.isArray(parsed.ctx)
2486
+ ? parsed.ctx
2487
+ : null;
2488
+ const config = ctx?.config && typeof ctx.config === 'object' && !Array.isArray(ctx.config)
2489
+ ? ctx.config
2490
+ : null;
2491
+ const pick = (...cands) => {
2492
+ for (const c of cands) {
2493
+ if (typeof c === 'string' && c.trim())
2494
+ return c.trim();
2495
+ }
2496
+ return null;
2497
+ };
2498
+ const userId = pick(ctx?.user_id, ctx?.userId, ctx?.principal_id, config?.user_id, parsed.user_id);
2499
+ const emailRaw = pick(ctx?.email, config?.email, parsed.email);
2500
+ if (!userId && !emailRaw)
2501
+ return null;
2502
+ return { userId, email: emailRaw ? emailRaw.toLowerCase() : null };
2503
+ }
2504
+ function sameHomePath(a, b) {
2505
+ return (safeRealpathSync(a) ?? path.resolve(a)) === (safeRealpathSync(b) ?? path.resolve(b));
2506
+ }
2507
+ /**
2508
+ * Whether the shared `~/.grok` billing log may be attached to this home.
2509
+ * Grok logins are per version home; the shared last line is one account's
2510
+ * meter. Fail loud: never copy it onto every installed identity.
2511
+ */
2512
+ function sharedGrokLogAppliesToHome(home, match) {
2513
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
2514
+ const requestedHome = home || os.homedir();
2515
+ const thisId = readGrokAuthIdentity(requestedHome);
2516
+ if (match.identity) {
2517
+ return grokIdentitiesMatch(match.identity, thisId);
2518
+ }
2519
+ // No identity on the line (the live Grok shape). Attribute the reading to
2520
+ // exactly one canonical identity: the real home itself, or the version home
2521
+ // whose own auth.json matches the shared `~/.grok/auth.json`.
2522
+ if (sameHomePath(requestedHome, realHome))
2523
+ return true;
2524
+ return grokIdentitiesMatch(thisId, readGrokAuthIdentity(realHome));
2525
+ }
2403
2526
  /** Parse the latest billing info from Grok's unified log. */
2404
2527
  async function getGrokUsageInfo(options) {
2405
2528
  try {
2406
- const base = options?.home || os.homedir();
2407
- const logPath = path.join(base, '.grok', 'logs', 'unified.jsonl');
2529
+ const resolved = resolveGrokBillingLogPath(options?.home);
2408
2530
  // No log yet: a benign "nothing recorded here", not a failure (RUSH-3040).
2409
- if (!fs.existsSync(logPath))
2531
+ if (!resolved)
2410
2532
  return usageNoRecentUsageInfo();
2411
- const match = await readLatestGrokBilling(logPath);
2533
+ const match = await readLatestGrokBilling(resolved.logPath);
2412
2534
  if (!match)
2413
2535
  return usageNoRecentUsageInfo();
2536
+ if (resolved.shared && !sharedGrokLogAppliesToHome(options?.home, match)) {
2537
+ return usageNoRecentUsageInfo();
2538
+ }
2414
2539
  // Grok has no live usage API (`network: false`) — bars are last-seen from
2415
2540
  // this machine's unified.jsonl only. Drop windows whose billing period has
2416
2541
  // already ended so a stale 100% does not paint "rate-limited" after reset,
@@ -2703,10 +2828,16 @@ async function readLatestGrokBilling(filePath) {
2703
2828
  return;
2704
2829
  try {
2705
2830
  const parsed = JSON.parse(line);
2706
- if (parsed.msg === 'billing: fetched credits config' && parsed.ctx?.config) {
2707
- const config = parsed.ctx.config;
2831
+ const ctx = parsed.ctx && typeof parsed.ctx === 'object' && !Array.isArray(parsed.ctx)
2832
+ ? parsed.ctx
2833
+ : null;
2834
+ if (parsed.msg === 'billing: fetched credits config' && ctx?.config) {
2835
+ const config = ctx.config;
2708
2836
  const windows = [];
2709
- if (config.currentPeriod?.end && typeof config.creditUsagePercent === 'number') {
2837
+ const currentPeriod = config.currentPeriod && typeof config.currentPeriod === 'object'
2838
+ ? config.currentPeriod
2839
+ : null;
2840
+ if (currentPeriod?.end && typeof config.creditUsagePercent === 'number') {
2710
2841
  // `creditUsagePercent` is Grok's weekly credit consumption (0-100);
2711
2842
  // the billing period's `end` is when that window resets.
2712
2843
  // Do NOT coerce a missing percent to 0 — a new period often lands a
@@ -2718,14 +2849,15 @@ async function readLatestGrokBilling(filePath) {
2718
2849
  label: 'Current week',
2719
2850
  shortLabel: 'W',
2720
2851
  usedPercent: Math.max(0, Math.min(100, rawPercent)),
2721
- resetsAt: parseDateValue(config.currentPeriod.end),
2852
+ resetsAt: parseDateValue(currentPeriod.end),
2722
2853
  windowMinutes: inferWindowMinutes('week'),
2723
2854
  });
2724
2855
  }
2725
2856
  latest = {
2726
2857
  capturedAt: parseDateValue(parsed.ts),
2727
- subscriptionTier: parsed.ctx.subscriptionTier || null,
2858
+ subscriptionTier: typeof ctx.subscriptionTier === 'string' ? ctx.subscriptionTier : null,
2728
2859
  windows,
2860
+ identity: grokIdentityFromBillingPayload(parsed),
2729
2861
  };
2730
2862
  }
2731
2863
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { type SessionNotification } from '@zed-industries/agent-client-protocol';
13
13
  import type { AgentId } from '../types.js';
14
- import type { ExecMode } from '../exec.js';
14
+ import { type ExecMode } from '../exec.js';
15
15
  export interface AcpRunOptions {
16
16
  agent: AgentId;
17
17
  prompt: string;
@@ -15,6 +15,7 @@ import { promises as fs } from 'fs';
15
15
  import * as path from 'path';
16
16
  import { ClientSideConnection, ndJsonStream, } from '@zed-industries/agent-client-protocol';
17
17
  import { getAcpSpec, supportsAcp } from './harnesses.js';
18
+ import { buildExecEnv } from '../exec.js';
18
19
  const PROTOCOL_VERSION = 1;
19
20
  /**
20
21
  * Runs a single prompt turn against an ACP-capable agent and streams updates
@@ -25,10 +26,20 @@ export async function runAcp(opts) {
25
26
  throw new Error(`Agent '${opts.agent}' does not support ACP. Use direct exec instead.`);
26
27
  }
27
28
  const spec = getAcpSpec(opts.agent);
29
+ // Build the harness's exec env the SAME way `agents run` does, instead of
30
+ // handing it a raw process.env. buildExecEnv is what injects the per-account
31
+ // `CLAUDE_CODE_OAUTH_TOKEN` on a worker device (gated by device role: a
32
+ // headed/personal box still defers to its native login). Without this, an ACP
33
+ // launch inherited no setup-token and fell through to the version home's
34
+ // copied native `.credentials.json` — which expires in ~15h and 401s once its
35
+ // refresh chain dies, exactly the "logged out on a worker" report (PHNX-3681).
36
+ // A verified precedence test shows the env token wins over a present
37
+ // `.credentials.json`, so injecting here is sufficient — the stale file no
38
+ // longer decides the session.
28
39
  const child = spawn(spec.command, spec.args, {
29
40
  cwd: opts.cwd,
30
41
  stdio: ['pipe', 'pipe', 'inherit'],
31
- env: process.env,
42
+ env: buildExecEnv({ agent: opts.agent, cwd: opts.cwd, mode: opts.mode, effort: 'auto', interactive: true }),
32
43
  });
33
44
  const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
34
45
  const client = buildClient(opts);
@@ -52,7 +52,7 @@ export const ACP_HARNESSES = {
52
52
  },
53
53
  // antigravity: no documented ACP support (May 2026).
54
54
  // goose: ACP over HTTP via `goosed`, not a clean stdio subcommand.
55
- // copilot, kiro: excluded for now (not installed in the reference environment,
55
+ // copilot: excluded for now (not installed in the reference environment,
56
56
  // no local verification possible).
57
57
  // amp: not on the ACP agents list.
58
58
  };
@@ -33,11 +33,9 @@ export const ADD_DIR_STRATEGY = {
33
33
  // No multi-root CLI surface today (single --dir / project path).
34
34
  opencode: 'none',
35
35
  gemini: 'none',
36
- pi: 'none',
37
36
  openclaw: 'none',
38
37
  copilot: 'none',
39
38
  amp: 'none',
40
- kiro: 'none',
41
39
  goose: 'none',
42
40
  antigravity: 'none',
43
41
  droid: 'none',
@@ -14,11 +14,9 @@ export const AGENT_CLI_COMMANDS = [
14
14
  'gemini',
15
15
  'cursor-agent',
16
16
  'opencode',
17
- 'omp',
18
17
  'openclaw',
19
18
  'copilot',
20
19
  'amp',
21
- 'kiro-cli',
22
20
  'goose',
23
21
  'agy',
24
22
  'grok',
@@ -104,7 +104,7 @@ export declare const MANAGED_AGENT_IDS: AgentId[];
104
104
  * `curl … | sh` / `brew install` script that carries NO version token — the
105
105
  * installer can only ever fetch the *current* release, and the binary then keeps
106
106
  * itself up to date in place (droid, grok, antigravity, cursor, hermes,
107
- * kiro, goose). There is no semver to pin, so agents-cli must not model these as
107
+ * goose). There is no semver to pin, so agents-cli must not model these as
108
108
  * having multiple installable version-homes the way it does for npm-packaged
109
109
  * agents (claude, codex, kimi, …).
110
110
  *