@phnx-labs/agents-cli 1.22.26 → 1.22.28

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 (183) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +80 -27
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +71 -26
  8. package/dist/commands/hosts.js +4 -3
  9. package/dist/commands/insights.js +39 -5
  10. package/dist/commands/output.js +100 -25
  11. package/dist/commands/perf.d.ts +10 -0
  12. package/dist/commands/perf.js +14 -6
  13. package/dist/commands/projects.js +24 -10
  14. package/dist/commands/resume.d.ts +11 -0
  15. package/dist/commands/resume.js +51 -0
  16. package/dist/commands/routines.js +31 -4
  17. package/dist/commands/run-account-picker.d.ts +37 -0
  18. package/dist/commands/run-account-picker.js +101 -17
  19. package/dist/commands/secrets.js +86 -2
  20. package/dist/commands/sessions-picker.js +12 -7
  21. package/dist/commands/sessions.d.ts +73 -7
  22. package/dist/commands/sessions.js +391 -49
  23. package/dist/commands/ssh.js +184 -11
  24. package/dist/commands/teams.js +22 -11
  25. package/dist/index.js +16 -20
  26. package/dist/lib/activity.js +45 -99
  27. package/dist/lib/auth-health.d.ts +47 -3
  28. package/dist/lib/auth-health.js +74 -15
  29. package/dist/lib/bench/index.d.ts +4 -0
  30. package/dist/lib/bench/index.js +4 -0
  31. package/dist/lib/bench/runner.d.ts +16 -0
  32. package/dist/lib/bench/runner.js +111 -0
  33. package/dist/lib/bench/schema.d.ts +5 -0
  34. package/dist/lib/bench/schema.js +91 -0
  35. package/dist/lib/bench/storage.d.ts +5 -0
  36. package/dist/lib/bench/storage.js +32 -0
  37. package/dist/lib/bench/types.d.ts +40 -0
  38. package/dist/lib/bench/types.js +1 -0
  39. package/dist/lib/claude-account-token.d.ts +2 -0
  40. package/dist/lib/claude-account-token.js +56 -3
  41. package/dist/lib/cloud/host.js +1 -0
  42. package/dist/lib/crabbox/cli.d.ts +2 -0
  43. package/dist/lib/crabbox/cli.js +2 -0
  44. package/dist/lib/crabbox/lease.js +7 -1
  45. package/dist/lib/daemon.js +20 -0
  46. package/dist/lib/devices/connect.d.ts +2 -0
  47. package/dist/lib/devices/connect.js +7 -0
  48. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  49. package/dist/lib/devices/doctor-findings.js +15 -1
  50. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  51. package/dist/lib/devices/harness-inventory.js +0 -0
  52. package/dist/lib/devices/registry.d.ts +2 -0
  53. package/dist/lib/devices/resolve-target.d.ts +1 -0
  54. package/dist/lib/devices/resolve-target.js +9 -2
  55. package/dist/lib/devices/ssh-config.js +3 -0
  56. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  57. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  58. package/dist/lib/events.d.ts +1 -1
  59. package/dist/lib/events.js +2 -2
  60. package/dist/lib/exec.js +18 -8
  61. package/dist/lib/fleet/apply.js +9 -7
  62. package/dist/lib/fleet/remote-login.d.ts +4 -3
  63. package/dist/lib/fleet/remote-login.js +11 -9
  64. package/dist/lib/gemini-settings.d.ts +0 -1
  65. package/dist/lib/gemini-settings.js +12 -7
  66. package/dist/lib/git.d.ts +9 -3
  67. package/dist/lib/git.js +39 -22
  68. package/dist/lib/hooks/profile.d.ts +12 -1
  69. package/dist/lib/hooks/profile.js +6 -1
  70. package/dist/lib/hooks.d.ts +8 -5
  71. package/dist/lib/hooks.js +14 -7
  72. package/dist/lib/hosts/dispatch.d.ts +10 -0
  73. package/dist/lib/hosts/dispatch.js +85 -25
  74. package/dist/lib/hosts/logs.js +11 -1
  75. package/dist/lib/hosts/passthrough.d.ts +27 -0
  76. package/dist/lib/hosts/passthrough.js +19 -10
  77. package/dist/lib/hosts/progress.d.ts +12 -1
  78. package/dist/lib/hosts/progress.js +41 -8
  79. package/dist/lib/hosts/providers/devices.js +1 -0
  80. package/dist/lib/hosts/ready.d.ts +45 -4
  81. package/dist/lib/hosts/ready.js +124 -20
  82. package/dist/lib/hosts/reconcile.d.ts +2 -1
  83. package/dist/lib/hosts/reconcile.js +24 -7
  84. package/dist/lib/hosts/reconnect.d.ts +45 -12
  85. package/dist/lib/hosts/reconnect.js +94 -36
  86. package/dist/lib/hosts/registry.d.ts +2 -2
  87. package/dist/lib/hosts/registry.js +4 -5
  88. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  89. package/dist/lib/hosts/remote-cmd.js +29 -0
  90. package/dist/lib/hosts/tasks.d.ts +3 -0
  91. package/dist/lib/hosts/types.d.ts +1 -0
  92. package/dist/lib/hosts/types.js +3 -0
  93. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  94. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  95. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  96. package/dist/lib/menubar/install-menubar.js +20 -9
  97. package/dist/lib/menubar/snapshot.js +15 -2
  98. package/dist/lib/models.d.ts +42 -6
  99. package/dist/lib/models.js +55 -153
  100. package/dist/lib/perf/db.js +16 -3
  101. package/dist/lib/perf/types.d.ts +12 -1
  102. package/dist/lib/pricing/cost.d.ts +9 -0
  103. package/dist/lib/pricing/cost.js +24 -0
  104. package/dist/lib/pricing/index.d.ts +1 -1
  105. package/dist/lib/pricing/index.js +1 -1
  106. package/dist/lib/project-probe.d.ts +22 -3
  107. package/dist/lib/project-probe.js +105 -17
  108. package/dist/lib/project-status.d.ts +9 -0
  109. package/dist/lib/project-status.js +15 -0
  110. package/dist/lib/redact.js +8 -3
  111. package/dist/lib/refresh.js +22 -11
  112. package/dist/lib/remote-agents-json.d.ts +32 -0
  113. package/dist/lib/remote-agents-json.js +47 -16
  114. package/dist/lib/resource-profiles.js +1 -2
  115. package/dist/lib/rotate.d.ts +22 -0
  116. package/dist/lib/rotate.js +26 -0
  117. package/dist/lib/routine-notify-owner.d.ts +102 -0
  118. package/dist/lib/routine-notify-owner.js +232 -0
  119. package/dist/lib/routines.d.ts +10 -0
  120. package/dist/lib/runner.d.ts +9 -1
  121. package/dist/lib/runner.js +182 -17
  122. package/dist/lib/sandbox.d.ts +0 -2
  123. package/dist/lib/sandbox.js +2 -19
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  126. package/dist/lib/secrets/agent.d.ts +19 -3
  127. package/dist/lib/secrets/agent.js +61 -22
  128. package/dist/lib/secrets/audit.d.ts +1 -1
  129. package/dist/lib/secrets/audit.js +2 -0
  130. package/dist/lib/secrets/bundles.d.ts +3 -3
  131. package/dist/lib/secrets/bundles.js +15 -5
  132. package/dist/lib/secrets/filestore.d.ts +2 -0
  133. package/dist/lib/secrets/filestore.js +4 -0
  134. package/dist/lib/secrets/lease.d.ts +25 -0
  135. package/dist/lib/secrets/lease.js +44 -0
  136. package/dist/lib/secrets/session-store.d.ts +7 -0
  137. package/dist/lib/secrets/session-store.js +21 -0
  138. package/dist/lib/self-update.d.ts +20 -5
  139. package/dist/lib/self-update.js +93 -16
  140. package/dist/lib/session/active.d.ts +49 -0
  141. package/dist/lib/session/active.js +175 -11
  142. package/dist/lib/session/bash-command.d.ts +16 -0
  143. package/dist/lib/session/bash-command.js +65 -0
  144. package/dist/lib/session/db.d.ts +23 -2
  145. package/dist/lib/session/db.js +86 -20
  146. package/dist/lib/session/discover.d.ts +21 -1
  147. package/dist/lib/session/discover.js +124 -32
  148. package/dist/lib/session/insights.d.ts +19 -0
  149. package/dist/lib/session/insights.js +78 -10
  150. package/dist/lib/session/recovery.d.ts +17 -1
  151. package/dist/lib/session/recovery.js +111 -4
  152. package/dist/lib/session/remote-bundle.js +1 -1
  153. package/dist/lib/session/remote-list.d.ts +10 -9
  154. package/dist/lib/session/remote-list.js +25 -23
  155. package/dist/lib/session/resume-owner.d.ts +55 -0
  156. package/dist/lib/session/resume-owner.js +69 -0
  157. package/dist/lib/session/team-filter.d.ts +65 -0
  158. package/dist/lib/session/team-filter.js +98 -3
  159. package/dist/lib/session/types.d.ts +33 -0
  160. package/dist/lib/smart-launch.d.ts +31 -5
  161. package/dist/lib/smart-launch.js +43 -7
  162. package/dist/lib/ssh-exec.d.ts +27 -0
  163. package/dist/lib/ssh-exec.js +34 -1
  164. package/dist/lib/ssh-tunnel.d.ts +3 -2
  165. package/dist/lib/ssh-tunnel.js +25 -16
  166. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  167. package/dist/lib/staleness/detectors/skills.js +17 -2
  168. package/dist/lib/staleness/index.d.ts +10 -1
  169. package/dist/lib/staleness/index.js +28 -3
  170. package/dist/lib/startup/command-registry.d.ts +1 -0
  171. package/dist/lib/startup/command-registry.js +2 -0
  172. package/dist/lib/teams/agents.d.ts +1 -0
  173. package/dist/lib/teams/agents.js +38 -11
  174. package/dist/lib/teams/placement-probe.d.ts +1 -1
  175. package/dist/lib/teams/placement-probe.js +24 -24
  176. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  177. package/dist/lib/teams/remoteWorktree.js +29 -27
  178. package/dist/lib/tmux/session.d.ts +8 -0
  179. package/dist/lib/tmux/session.js +22 -0
  180. package/dist/lib/types.d.ts +2 -0
  181. package/dist/lib/versions.d.ts +1 -0
  182. package/dist/lib/versions.js +40 -22
  183. package/package.json +3 -1
@@ -31,7 +31,7 @@ import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-targe
31
31
  import { clearPendingSentinel } from '../lib/devices/pending.js';
32
32
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
33
33
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
34
- import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
34
+ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, deviceIdentityArgs, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
35
35
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
36
36
  import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
37
37
  import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
@@ -44,6 +44,7 @@ import { checkAllClis } from '../lib/teams/agents.js';
44
44
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
45
45
  import { sshExecAsync } from '../lib/ssh-exec.js';
46
46
  import { ALL_AGENT_IDS } from '../lib/agents.js';
47
+ import { collectLocalHarnessInventory, groupByAccount, renderAccountsMatrix, renderHarnessMatrix, } from '../lib/devices/harness-inventory.js';
47
48
  import { crabboxList, crabboxFind, crabboxSshArgv } from '../lib/crabbox/cli.js';
48
49
  import { boxAddress, boxStatus, fmtIdleShort, fmtExpiresShort } from './lease.js';
49
50
  import { authCellColor, formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictColor, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
@@ -351,7 +352,7 @@ async function probeRemoteFleetStatus(target) {
351
352
  const isWin = /^win/i.test((target.platform ?? '').trim());
352
353
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
353
354
  const cmd = buildRemoteAgentsInvocation(['devices', 'status', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
354
- const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true });
355
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: target.extraSshArgs });
355
356
  if (res.code !== 0) {
356
357
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
357
358
  }
@@ -361,10 +362,10 @@ async function probeRemoteHealth(target) {
361
362
  const isWin = /^win/i.test((target.platform ?? '').trim());
362
363
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
363
364
  const versionCmd = buildRemoteAgentsInvocation(['--version'], undefined, isWin ? 'windows' : undefined, env);
364
- const versionRes = await sshExecAsync(target.dialTarget, versionCmd, { timeoutMs: 15000, multiplex: true });
365
+ const versionRes = await sshExecAsync(target.dialTarget, versionCmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: target.extraSshArgs });
365
366
  const version = versionRes.code === 0 ? versionRes.stdout.trim().split(/\s+/)[0] || null : null;
366
367
  const doctorCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, env);
367
- const doctorRes = await sshExecAsync(target.dialTarget, doctorCmd, { timeoutMs: 30000, multiplex: true });
368
+ const doctorRes = await sshExecAsync(target.dialTarget, doctorCmd, { timeoutMs: 30000, multiplex: true, extraSshArgs: target.extraSshArgs });
368
369
  if (doctorRes.code !== 0) {
369
370
  throw new Error(doctorRes.timedOut ? 'timed out' : (doctorRes.stderr.trim() || `exit ${doctorRes.code ?? 'unknown'}`));
370
371
  }
@@ -425,6 +426,7 @@ async function runFleetStatus(opts) {
425
426
  // this is trusted on the default path, not just under `--refresh`.
426
427
  skip: fleetHealthSkip(t.skip, statsMap.get(t.device.name)),
427
428
  dialTarget: fleetDialTarget(t.device),
429
+ extraSshArgs: deviceIdentityArgs(t.device),
428
430
  }));
429
431
  const remote = await fanOutDevices(remoteTargets, probeRemoteHealth);
430
432
  for (const result of remote) {
@@ -533,7 +535,7 @@ async function probeRemoteAuth(target) {
533
535
  const isWin = /^win/i.test((target.platform ?? '').trim());
534
536
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
535
537
  const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
536
- const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true });
538
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: target.extraSshArgs });
537
539
  if (res.code !== 0) {
538
540
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
539
541
  }
@@ -601,6 +603,7 @@ async function runFleetPing(opts) {
601
603
  platform: t.device.platform,
602
604
  skip: t.skip,
603
605
  dialTarget: fleetDialTarget(t.device),
606
+ extraSshArgs: deviceIdentityArgs(t.device),
604
607
  }));
605
608
  const probeable = remoteTargets.filter((t) => !t.skip).length;
606
609
  const spinner = isInteractiveTerminal() && !opts.json
@@ -645,6 +648,118 @@ async function runFleetPing(opts) {
645
648
  if (opts.strict && anyBad)
646
649
  process.exitCode = 1;
647
650
  }
651
+ /** SSH into a host and read its raw harness rows (the `--local --json` worker). */
652
+ async function probeRemoteHarnesses(target, refresh) {
653
+ const isWin = /^win/i.test((target.platform ?? '').trim());
654
+ const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
655
+ const args = ['devices', 'harnesses', '--local', '--json'];
656
+ if (refresh)
657
+ args.push('--refresh');
658
+ const cmd = buildRemoteAgentsInvocation(args, undefined, isWin ? 'windows' : undefined, env);
659
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: target.extraSshArgs });
660
+ if (res.code !== 0) {
661
+ throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
662
+ }
663
+ const parsed = JSON.parse(res.stdout);
664
+ return parsed.rows ?? [];
665
+ }
666
+ /**
667
+ * Gather harness rows across the fleet: THIS host in process, every reachable
668
+ * peer over SSH. Shared by both `harnesses` and `accounts` (they differ only in
669
+ * how the rows are rendered). Honors an optional `--device` allowlist on both
670
+ * the local and remote rows. Bounded by the same per-device + overall deadlines
671
+ * as `fleet ping`, so one unreachable box can never stall the glance.
672
+ */
673
+ async function collectFleetHarnesses(opts) {
674
+ const self = machineId();
675
+ const want = opts.devices?.length ? new Set(opts.devices) : null;
676
+ const results = [];
677
+ if (!want || want.has(self)) {
678
+ const localRows = await collectLocalHarnessInventory({ agents: opts.agents, refresh: opts.refresh });
679
+ results.push({ host: self, rows: localRows });
680
+ }
681
+ const reg = await loadDevices();
682
+ const planned = planFleetTargets(reg);
683
+ let remoteTargets = remoteFleetTargets(planned, self).map((t) => ({
684
+ name: t.device.name,
685
+ platform: t.device.platform,
686
+ skip: t.skip,
687
+ dialTarget: fleetDialTarget(t.device),
688
+ extraSshArgs: deviceIdentityArgs(t.device),
689
+ }));
690
+ if (want)
691
+ remoteTargets = remoteTargets.filter((t) => want.has(t.name));
692
+ if (remoteTargets.length > 0) {
693
+ const probeable = remoteTargets.filter((t) => !t.skip).length;
694
+ const spinner = isInteractiveTerminal() && !opts.json
695
+ ? ora(`Probing ${probeable} device${probeable === 1 ? '' : 's'}…`).start()
696
+ : undefined;
697
+ let remote;
698
+ try {
699
+ const fanOut = fanOutDevices(remoteTargets, (t) => probeRemoteHarnesses(t, !!opts.refresh), { perDeviceTimeoutMs: 15_000 });
700
+ remote = await raceFleetPingDeadline(fanOut, remoteTargets, 30_000);
701
+ }
702
+ finally {
703
+ spinner?.stop();
704
+ }
705
+ for (const r of remote) {
706
+ if (r.status === 'ok' && r.value) {
707
+ results.push({ host: r.name, rows: r.value });
708
+ }
709
+ else {
710
+ results.push({
711
+ host: r.name,
712
+ rows: [],
713
+ error: r.error,
714
+ skipped: r.reason ? String(r.reason) : undefined,
715
+ });
716
+ }
717
+ }
718
+ }
719
+ return results;
720
+ }
721
+ async function runDevicesHarnesses(opts) {
722
+ if (opts.local) {
723
+ const rows = await collectLocalHarnessInventory({ agents: opts.agents, refresh: opts.refresh });
724
+ if (opts.json)
725
+ console.log(JSON.stringify({ host: machineId(), rows }));
726
+ else
727
+ for (const line of renderHarnessMatrix([{ host: machineId(), rows }]))
728
+ console.log(line);
729
+ return;
730
+ }
731
+ const results = await collectFleetHarnesses(opts);
732
+ if (opts.json)
733
+ console.log(JSON.stringify(results, null, 2));
734
+ else
735
+ for (const line of renderHarnessMatrix(results))
736
+ console.log(line);
737
+ }
738
+ async function runDevicesAccounts(opts) {
739
+ if (opts.local) {
740
+ const rows = await collectLocalHarnessInventory({ agents: opts.agents, refresh: opts.refresh });
741
+ if (opts.json)
742
+ console.log(JSON.stringify({ host: machineId(), accounts: groupByAccount(rows) }));
743
+ else
744
+ for (const line of renderAccountsMatrix([{ host: machineId(), rows }]))
745
+ console.log(line);
746
+ return;
747
+ }
748
+ const results = await collectFleetHarnesses(opts);
749
+ if (opts.json) {
750
+ const grouped = results.map((r) => ({
751
+ host: r.host,
752
+ error: r.error,
753
+ skipped: r.skipped,
754
+ accounts: groupByAccount(r.rows),
755
+ }));
756
+ console.log(JSON.stringify(grouped, null, 2));
757
+ }
758
+ else {
759
+ for (const line of renderAccountsMatrix(results))
760
+ console.log(line);
761
+ }
762
+ }
648
763
  /** Resolve an {@link AuthCellColor} to a chalk painter. Single map for cells + labels. */
649
764
  const CELL_PAINT = {
650
765
  green: chalk.green,
@@ -777,6 +892,7 @@ Typical workflow:
777
892
  agents devices configure mac-mini --max-agents 4 --scheduler off
778
893
  agents devices note mac-mini "runs the releases — don't reboot"
779
894
  agents devices set win-mini --auth password --bundle muqsit
895
+ agents devices set worker --auth key --identity-file ~/.ssh/worker_ed25519
780
896
  agents devices render --write # write ~/.ssh/config.d/agents include
781
897
  agents fleet update # roll out latest agents-cli to every online device
782
898
  agents fleet run uname -a # run a command on every online device
@@ -1219,6 +1335,59 @@ Typical workflow:
1219
1335
  const verbose = opts.verbose ?? Boolean(cmd.optsWithGlobals().verbose);
1220
1336
  await runFleetPing({ ...opts, verbose });
1221
1337
  });
1338
+ const csvList = (s) => s ? s.split(',').map((x) => x.trim()).filter(Boolean) : undefined;
1339
+ const harnessInvOpts = (opts) => ({
1340
+ agents: csvList(opts.agents),
1341
+ devices: csvList(opts.device ?? opts.devices),
1342
+ refresh: opts.refresh || opts.live,
1343
+ json: opts.json,
1344
+ local: opts.local,
1345
+ });
1346
+ const harnessesCmd = devicesCmd
1347
+ .command('harnesses')
1348
+ .description('Per device, one row per installed agent@version: account, signed-in, quota, and a single ready verdict. SSH-probes each online box.')
1349
+ .option('--json', 'output machine-readable JSON (per-host rows)')
1350
+ .option('--agents <csv>', 'only these agents (comma-separated)')
1351
+ .option('--device <csv>', 'only these devices (comma-separated); default: every online box')
1352
+ .option('--refresh', 'fetch live quota instead of the cached snapshot (slower)')
1353
+ .option('--live', 'alias of --refresh')
1354
+ .option('--local', "this host only: emit THIS box's rows (the per-host worker the fan-out reads over ssh)")
1355
+ .action(async (opts) => {
1356
+ await runDevicesHarnesses(harnessInvOpts(opts));
1357
+ });
1358
+ setHelpSections(harnessesCmd, {
1359
+ examples: `
1360
+ agents devices harnesses # every box: agent@version · account · signed · quota · ready
1361
+ agents devices harnesses --agents claude,codex # just these harnesses
1362
+ agents devices harnesses --device zion # one box
1363
+ agents devices harnesses --refresh # live quota (bypass the cached snapshot)
1364
+ agents devices harnesses --json # machine-readable, per-host rows`,
1365
+ notes: `
1366
+ "ready" = signed in AND not rate-limited — usable for a run right now.
1367
+ Quota is the cached usage snapshot (the daemon warms it); --refresh fetches live.
1368
+ Use \`agents devices accounts\` for the same data grouped by account.`,
1369
+ });
1370
+ const accountsCmd = devicesCmd
1371
+ .command('accounts')
1372
+ .description('Per device, one row per account: which harnesses share it, signed-in, quota, and ready. The identity lens on `agents devices harnesses`.')
1373
+ .option('--json', 'output machine-readable JSON (per-host account groups)')
1374
+ .option('--agents <csv>', 'only these agents (comma-separated)')
1375
+ .option('--device <csv>', 'only these devices (comma-separated); default: every online box')
1376
+ .option('--refresh', 'fetch live quota instead of the cached snapshot (slower)')
1377
+ .option('--live', 'alias of --refresh')
1378
+ .option('--local', "this host only: emit THIS box's account groups")
1379
+ .action(async (opts) => {
1380
+ await runDevicesAccounts(harnessInvOpts(opts));
1381
+ });
1382
+ setHelpSections(accountsCmd, {
1383
+ examples: `
1384
+ agents devices accounts # every box: account · agents · signed · quota · ready
1385
+ agents devices accounts --device mac-mini
1386
+ agents devices accounts --json # machine-readable, per-host account groups`,
1387
+ notes: `
1388
+ Collapses the installs that share one account (e.g. five claude versions on one
1389
+ email) into a single row. Use \`agents devices harnesses\` for the per-install view.`,
1390
+ });
1222
1391
  devicesCmd
1223
1392
  .command('login')
1224
1393
  .description('Log agent CLIs into fleet boxes over SSH: drive each box\'s device-code OAuth, scrape the URL + code, and surface every pending login in one local browser page. Default drives all codes at once; --interactive walks one box at a time (codes requested just-in-time so they don\'t expire).')
@@ -1281,15 +1450,19 @@ Typical workflow:
1281
1450
  .option('--auth <method>', 'key | password')
1282
1451
  .option('--bundle <bundle>', 'secrets bundle holding the password (for --auth password)')
1283
1452
  .option('--bundle-key <key>', "key within the bundle (default 'password')")
1453
+ .option('--identity-file <path>', 'private-key path for --auth key')
1454
+ .option('--clear-identity-file', 'return key auth to ssh-agent/default-key discovery')
1284
1455
  .action(async (name, opts) => {
1285
1456
  try {
1286
1457
  const existing = await mustGetDevice(name);
1287
- const auth = opts.auth || opts.bundle || opts.bundleKey
1288
- ? {
1289
- method: opts.auth ?? existing.auth.method,
1290
- bundle: opts.bundle ?? existing.auth.bundle,
1291
- bundleKey: opts.bundleKey ?? existing.auth.bundleKey,
1292
- }
1458
+ const nextMethod = opts.auth ?? existing.auth.method;
1459
+ if (opts.identityFile && nextMethod !== 'key') {
1460
+ throw new Error('--identity-file requires key auth; pass --auth key in the same command.');
1461
+ }
1462
+ const auth = opts.auth || opts.bundle || opts.bundleKey || opts.identityFile || opts.clearIdentityFile
1463
+ ? nextMethod === 'key'
1464
+ ? { method: nextMethod, identityFile: opts.clearIdentityFile ? undefined : (opts.identityFile ?? existing.auth.identityFile) }
1465
+ : { method: nextMethod, bundle: opts.bundle ?? existing.auth.bundle, bundleKey: opts.bundleKey ?? existing.auth.bundleKey }
1293
1466
  : undefined;
1294
1467
  const d = await upsertDevice(name, {
1295
1468
  platform: opts.platform ?? undefined,
@@ -17,7 +17,7 @@ import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } f
17
17
  import { setHelpSections } from '../lib/help.js';
18
18
  import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
19
19
  import { resolveHost } from '../lib/hosts/registry.js';
20
- import { isDeviceAuto, resolveDeviceAffinity } from '../lib/smart-launch.js';
20
+ import { isDeviceAuto, resolveDeviceAuto } from '../lib/smart-launch.js';
21
21
  import { sshTargetFor } from '../lib/hosts/types.js';
22
22
  import { ensureHostReady } from '../lib/hosts/ready.js';
23
23
  import { remoteShellFor } from '../lib/hosts/remote-cmd.js';
@@ -1354,12 +1354,12 @@ export function registerTeamsCommands(program) {
1354
1354
  }
1355
1355
  return h ?? d ?? null;
1356
1356
  })();
1357
- // `auto` is the same affinity sentinel `agents run --device auto` resolves
1357
+ // `auto` is the same live fleet sentinel `agents run --device auto` resolves
1358
1358
  // (RUSH-2185) — pick the concrete device name up front so the local-machine
1359
1359
  // check right below (and every dieFriction message further down) sees the
1360
1360
  // real target instead of the literal string "auto".
1361
1361
  if (explicitDevice && isDeviceAuto(explicitDevice)) {
1362
- const plan = resolveDeviceAffinity({});
1362
+ const plan = await resolveDeviceAuto(parseTeammate(teammate).agent);
1363
1363
  const picked = plan.host ?? machineId();
1364
1364
  process.stderr.write(chalk.gray(`[teams] device=auto → ${picked === machineId() ? 'local' : picked}\n`));
1365
1365
  explicitDevice = picked;
@@ -1395,10 +1395,17 @@ export function registerTeamsCommands(program) {
1395
1395
  catch (err) {
1396
1396
  dieFriction('teams', 'ssh-target-unresolvable', `Can't resolve an ssh target for "${host.name}": ${err.message}`);
1397
1397
  }
1398
- // Ensure agents-cli is present + version-matched on the host; surface
1399
- // (not fail on) an agent-not-installed warning, like the run --host path.
1398
+ // Ensure agents-cli is present + the pin is installed on the host.
1399
+ // Bare agent names still only warn; a concrete pin fails loud so
1400
+ // `teams add --device` never stages a teammate the box cannot run
1401
+ // (RUSH-2313). parseTeammate already resolves @latest/etc. to a concrete
1402
+ // version when one is pinned on the teammate spec.
1400
1403
  try {
1401
- const { warnings } = ensureHostReady(host, { agent: parseTeammate(teammate).agent });
1404
+ const parsed = parseTeammate(teammate);
1405
+ const { warnings } = ensureHostReady(host, {
1406
+ agent: parsed.agent,
1407
+ version: parsed.version ?? undefined,
1408
+ });
1402
1409
  for (const w of warnings)
1403
1410
  process.stderr.write(chalk.yellow(`[teams] warning: ${w}\n`));
1404
1411
  }
@@ -1417,7 +1424,9 @@ export function registerTeamsCommands(program) {
1417
1424
  effectiveRepo = (await getRemoteUrl(process.cwd())) ?? '';
1418
1425
  }
1419
1426
  try {
1420
- hostRepoPath = ensureRemoteRepo(hostTarget, effectiveRepo, team);
1427
+ hostRepoPath = ensureRemoteRepo(hostTarget, effectiveRepo, team, {
1428
+ extraSshArgs: host.identityFile ? ['-i', host.identityFile, '-o', 'IdentitiesOnly=yes'] : [],
1429
+ });
1421
1430
  }
1422
1431
  catch (err) {
1423
1432
  dieFriction('teams', 'repo-provision-failed', `Couldn't provision the repo on "${host.name}": ${err.message}\n` +
@@ -2018,11 +2027,12 @@ export function registerTeamsCommands(program) {
2018
2027
  try {
2019
2028
  if (agent.hostName && agent.hostTarget && agent.repoPath) {
2020
2029
  // Distributed teammate: guard + remove the worktree ON THE HOST.
2021
- if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath)) {
2030
+ const ssh = { extraSshArgs: agent.hostIdentityFile ? ['-i', agent.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [] };
2031
+ if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath, ssh)) {
2022
2032
  worktreeKept = true;
2023
2033
  }
2024
2034
  else {
2025
- removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
2035
+ removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName, true, ssh);
2026
2036
  }
2027
2037
  }
2028
2038
  else {
@@ -2231,11 +2241,12 @@ export function registerTeamsCommands(program) {
2231
2241
  try {
2232
2242
  if (agent.hostName && agent.hostTarget && agent.repoPath) {
2233
2243
  // Distributed teammate: guard + remove the worktree ON THE HOST.
2234
- if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath)) {
2244
+ const ssh = { extraSshArgs: agent.hostIdentityFile ? ['-i', agent.hostIdentityFile, '-o', 'IdentitiesOnly=yes'] : [] };
2245
+ if (remoteWorktreeDirty(agent.hostTarget, agent.worktreePath, ssh)) {
2235
2246
  keptWorktrees.push(agent.worktreeName);
2236
2247
  }
2237
2248
  else {
2238
- removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
2249
+ removeRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName, true, ssh);
2239
2250
  }
2240
2251
  }
2241
2252
  else {
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -392,15 +392,15 @@ async function showWhatsNew(fromVersion, toVersion) {
392
392
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
393
393
  import { getUpdateCheckPath, getMigratedSentinelPath, getUserAgentsDir, getRuntimeStateDir } from './lib/state.js';
394
394
  import { resolveBrandName, disabledCommandsForActiveBrand } from './lib/brand.js';
395
- import { readUpdateCache, saveUpdateCheck, dismissUpdateVersion, shouldPromptUpgrade, findAgentsCliInstalls, resolveRunningPackageRoot, } from './lib/self-update.js';
395
+ import { readUpdateCache, saveUpdateCheck, dismissUpdateVersion, shouldPromptUpgrade, buildMultiInstallInventory, findAgentsCliInstalls, resolveRunningPackageRoot, } from './lib/self-update.js';
396
396
  const UPDATE_CHECK_FILE = getUpdateCheckPath();
397
397
  /**
398
- * Warn once when PATH resolves `agents` to a different agents-cli install
399
- * than the copy that is currently running (or to several). Divergent installs
398
+ * Warn once when this machine contains a different agents-cli install than the
399
+ * copy that is currently running (or several). Divergent installs
400
400
  * are how self-updates "succeed" without changing the command the user types.
401
- * The warning re-fires only when the set of install roots changes; dev builds
402
- * (0.0.0-dev) are ignored because side-by-side dev installs are a supported
403
- * workflow.
401
+ * The warning re-fires only when the set of install roots or their helper-copy
402
+ * safety changes. Dev builds are included because old dev copies can still
403
+ * overwrite the shared macOS helper bundle non-atomically.
404
404
  */
405
405
  function maybeWarnMultiInstall() {
406
406
  const sentinel = path.join(getRuntimeStateDir(), 'multi-install-warned');
@@ -414,31 +414,26 @@ function maybeWarnMultiInstall() {
414
414
  // "/$bunfs" install. This warning is advisory — stay silent instead.
415
415
  return;
416
416
  }
417
- const byRoot = new Map();
418
- byRoot.set(runningRoot, { version: VERSION, note: 'running' });
419
- for (const install of findAgentsCliInstalls(process.env.PATH || '')) {
420
- if (install.version.startsWith('0.0.0-dev'))
421
- continue;
422
- if (!byRoot.has(install.packageRoot)) {
423
- byRoot.set(install.packageRoot, { version: install.version, note: `agents on PATH: ${install.binPath}` });
424
- }
425
- }
426
- if (byRoot.size < 2) {
417
+ const inventory = buildMultiInstallInventory(runningRoot, VERSION, findAgentsCliInstalls(process.env.PATH || ''));
418
+ if (inventory.length < 2) {
427
419
  try {
428
420
  fs.unlinkSync(sentinel);
429
421
  }
430
422
  catch { /* nothing recorded */ }
431
423
  return;
432
424
  }
433
- const key = [...byRoot.keys()].sort().join('\n');
425
+ const key = inventory
426
+ .map((info) => `${info.packageRoot}\t${info.version}\t${info.note}`)
427
+ .sort()
428
+ .join('\n');
434
429
  try {
435
430
  if (fs.readFileSync(sentinel, 'utf-8') === key)
436
431
  return;
437
432
  }
438
433
  catch { /* not warned for this set yet */ }
439
434
  console.error(chalk.yellow('Multiple agents-cli installs detected:'));
440
- for (const [root, info] of byRoot) {
441
- console.error(chalk.gray(` ${root} ${info.version} (${info.note})`));
435
+ for (const info of inventory) {
436
+ console.error(chalk.gray(` ${info.packageRoot} ${info.version} (${info.note})`));
442
437
  }
443
438
  console.error(chalk.gray('Upgrades apply to the running copy. Remove a stale copy with: npm uninstall -g --prefix <prefix> @phnx-labs/agents-cli'));
444
439
  try {
@@ -967,6 +962,7 @@ async function registerAllEagerCommands() {
967
962
  await reg(loadCost);
968
963
  await reg(loadInsights);
969
964
  await reg(loadPerf);
965
+ await reg(loadBench);
970
966
  await reg(loadTrends);
971
967
  await reg(loadOutput);
972
968
  await reg(loadBudget);
@@ -26,6 +26,7 @@ import { getActivityDir, getUserAgentsDir } from './state.js';
26
26
  import { normalizeHost } from './machine-id.js';
27
27
  import { projectKeyFromCwd } from './project-key.js';
28
28
  import { stampProvenance } from './event-provenance.js';
29
+ import { pythonToolRegistryLiteral, pythonValueFlagsLiteral, } from './session/bash-command.js';
29
30
  /** The set of milestone events, for tier classification and ordering. */
30
31
  export const MILESTONE_EVENTS = [
31
32
  'plan.created',
@@ -730,115 +731,30 @@ def first_line(text, limit=140):
730
731
  return ""
731
732
 
732
733
 
733
- # Canonical command taxonomy for Bash tool calls. Mirrors the TypeScript
734
- # registry in lib/session/bash-command.ts; keep them in sync.
734
+ # Canonical command taxonomy for Bash tool calls, generated from the single
735
+ # source of truth in lib/session/bash-command.ts's TOOL_REGISTRY (#1889) so the
736
+ # two can no longer drift — this hook runs standalone under python3 and can't
737
+ # import that module, so the literal below is generated, not hand-duplicated.
735
738
  BASH_TOOL_REGISTRY = {
736
- "git": {"category": "vcs", "action": "working in git"},
737
- "gh": {"category": "vcs", "action": "using GitHub CLI"},
738
- "bun": {"category": "build-test", "action": "running bun"},
739
- "npm": {"category": "build-test", "action": "running npm"},
740
- "pnpm": {"category": "build-test", "action": "running pnpm"},
741
- "yarn": {"category": "build-test", "action": "running yarn"},
742
- "vitest": {"category": "build-test", "action": "running vitest"},
743
- "jest": {"category": "build-test", "action": "running jest"},
744
- "mocha": {"category": "build-test", "action": "running mocha"},
745
- "pytest": {"category": "build-test", "action": "running pytest"},
746
- "cargo": {"category": "build-test", "action": "running cargo"},
747
- "go": {"category": "build-test", "action": "running go"},
748
- "tsc": {"category": "build-test", "action": "running tsc"},
749
- "tsx": {"category": "build-test", "action": "running tsx"},
750
- "node": {"category": "build-test", "action": "running node"},
751
- "python": {"category": "build-test", "action": "running python"},
752
- "python3": {"category": "build-test", "action": "running python"},
753
- "make": {"category": "build-test", "action": "running make"},
754
- "brew": {"category": "install", "action": "installing with brew"},
755
- "pip": {"category": "install", "action": "installing with pip"},
756
- "pip3": {"category": "install", "action": "installing with pip"},
757
- "apt": {"category": "install", "action": "installing with apt"},
758
- "apk": {"category": "install", "action": "installing with apk"},
759
- "ssh": {"category": "remote", "action": "using ssh"},
760
- "scp": {"category": "remote", "action": "using scp"},
761
- "rsync": {"category": "remote", "action": "using rsync"},
762
- "curl": {"category": "http", "action": "fetching with curl"},
763
- "wget": {"category": "http", "action": "fetching with wget"},
764
- "ffmpeg": {"category": "media", "action": "using ffmpeg"},
765
- "ffprobe": {"category": "media", "action": "probing media"},
766
- "magick": {"category": "media", "action": "using ImageMagick"},
767
- "convert": {"category": "media", "action": "converting images"},
768
- "composite": {"category": "media", "action": "compositing images"},
769
- "montage": {"category": "media", "action": "montaging images"},
770
- "identify": {"category": "media", "action": "identifying images"},
771
- "realesrgan": {"category": "upscaling", "action": "upscaling with realesrgan"},
772
- "realesrgan-ncnn-vulkan": {"category": "upscaling", "action": "upscaling with realesrgan"},
773
- "waifu2x": {"category": "upscaling", "action": "upscaling with waifu2x"},
774
- "waifu2x-caffe": {"category": "upscaling", "action": "upscaling with waifu2x"},
775
- "waifu2x-converter-cpp": {"category": "upscaling", "action": "upscaling with waifu2x"},
776
- "swin2sr": {"category": "upscaling", "action": "upscaling with swin2sr"},
777
- "resdet": {"category": "upscaling", "action": "detecting upscale"},
778
- "id3v2": {"category": "metadata", "action": "editing id3 tags"},
779
- "exiftool": {"category": "metadata", "action": "editing exif metadata"},
780
- "metaflac": {"category": "metadata", "action": "editing flac metadata"},
781
- "vorbiscomment": {"category": "metadata", "action": "editing vorbis comments"},
782
- # Shell
783
- "rm": {"category": "shell", "action": "removing files"},
784
- "mv": {"category": "shell", "action": "moving files"},
785
- "cp": {"category": "shell", "action": "copying files"},
786
- "mkdir": {"category": "shell", "action": "making directories"},
787
- "touch": {"category": "shell", "action": "touching files"},
788
- "echo": {"category": "shell", "action": "echoing"},
789
- "printf": {"category": "shell", "action": "printing"},
790
- "chmod": {"category": "shell", "action": "changing permissions"},
791
- "ln": {"category": "shell", "action": "linking files"},
792
- "awk": {"category": "shell", "action": "running awk"},
793
- "sed": {"category": "shell", "action": "running sed"},
794
- "tee": {"category": "shell", "action": "teeing output"},
795
- "xargs": {"category": "shell", "action": "running xargs"},
796
- # Probes
797
- "ls": {"category": "probe", "action": "listing files"},
798
- "cat": {"category": "probe", "action": "reading files"},
799
- "head": {"category": "probe", "action": "reading files"},
800
- "tail": {"category": "probe", "action": "reading files"},
801
- "wc": {"category": "probe", "action": "counting"},
802
- "stat": {"category": "probe", "action": "statting files"},
803
- "file": {"category": "probe", "action": "inspecting files"},
804
- "which": {"category": "probe", "action": "locating binaries"},
805
- "tree": {"category": "probe", "action": "listing files"},
806
- "pwd": {"category": "probe", "action": "printing pwd"},
807
- # Search
808
- "grep": {"category": "search", "action": "searching"},
809
- "rg": {"category": "search", "action": "searching with ripgrep"},
810
- "ag": {"category": "search", "action": "searching with the silver searcher"},
811
- "fd": {"category": "search", "action": "searching files"},
812
- "find": {"category": "search", "action": "finding files"},
813
- # Wait
814
- "sleep": {"category": "wait", "action": "sleeping"},
815
- "wait": {"category": "wait", "action": "waiting"},
739
+ ${pythonToolRegistryLiteral()}
816
740
  }
817
741
 
818
742
  # Two-level tools mapped to the flags that consume the following token as their
819
- # value, per tool; the subcommand scan skips both. Missing a value flag mis-reads
820
- # the value as the subcommand; over-listing only drops the subcommand (safe). A
821
- # tool with no such leading flags maps to an empty set. Mirrors VALUE_FLAGS in
822
- # lib/session/bash-command.ts; TWO_LEVEL_TOOLS is derived from its keys.
743
+ # value, per tool; the subcommand scan skips both. Generated from VALUE_FLAGS in
744
+ # lib/session/bash-command.ts (#1889) TWO_LEVEL_TOOLS is derived from its keys.
823
745
  VALUE_FLAGS = {
824
- "git": {"-C", "-c", "--git-dir", "--work-tree"},
825
- "gh": {"-R", "--repo"},
826
- "bun": {"--cwd"},
827
- "npm": {"--prefix", "-w", "--workspace"},
828
- "pnpm": {"--filter", "-C", "--dir"},
829
- "yarn": {"--cwd"},
830
- "cargo": {"--manifest-path"},
831
- "docker": {"-H", "--host", "-c", "--context", "--config", "-l", "--log-level"},
832
- "kubectl": {"-n", "--namespace", "--kubeconfig", "--context", "--cluster", "--user", "-s", "--server", "--as", "--token", "--cache-dir", "--request-timeout"},
833
- "rush": set(),
834
- "openclaw": set(),
746
+ ${pythonValueFlagsLiteral()}
835
747
  }
836
748
 
837
749
  TWO_LEVEL_TOOLS = set(VALUE_FLAGS.keys())
838
750
 
839
751
 
840
752
  def _unwrap_command(cmd):
841
- """Strip wrapper prefixes so the real executable is classified."""
753
+ """Strip wrapper prefixes so the real executable is classified.
754
+
755
+ Mirrors unwrapCommand() in lib/session/bash-command.ts — keep the shapes
756
+ aligned when extending either side (#1889).
757
+ """
842
758
  s = (cmd or "").strip()
843
759
  ssh = re.match(r'^ssh\s+\S+\s+["\']?(.+?)["\']?\s*(?:\|.*)?$', s)
844
760
  if ssh:
@@ -847,16 +763,46 @@ def _unwrap_command(cmd):
847
763
  env = re.match(r'^([A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|' + r"'[^']*'" + r'|\S+)\s+)+(.+)$', s)
848
764
  if env:
849
765
  return _unwrap_command(env.group(2))
766
+ # export VAR=value && command / export A=1 B=2; command
767
+ export_prefix = re.match(
768
+ r'^export\s+(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|' + r"'[^']*'" + r'|\S+)\s+)*'
769
+ r'[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|' + r"'[^']*'" + r'|\S+)\s*(?:&&|;|\n)\s*([\s\S]+)$',
770
+ s,
771
+ )
772
+ if export_prefix:
773
+ return _unwrap_command(export_prefix.group(1))
850
774
  # sudo / time prefix (value-taking flags such as -u user consume their argument)
851
775
  prefix = re.match(r'^(?:sudo|time)(?:\s+(?:-[uUgGhpCrtDR]\s+\S+|-\S+))*\s+(.+)$', s)
852
776
  if prefix:
853
777
  return _unwrap_command(prefix.group(1))
854
- cd = re.match(r'^cd\s+\S+\s*&&\s*(.+)$', s)
778
+ # set -euo pipefail && command / set -x; command
779
+ set_prefix = re.match(r'^set\s+[^&;\n]+?(?:&&|;|\n)\s*([\s\S]+)$', s)
780
+ if set_prefix:
781
+ return _unwrap_command(set_prefix.group(1))
782
+ # cd foo && command (also ; and newline separators)
783
+ cd = re.match(r'^cd\s+\S+\s*(?:&&|;|\n)\s*([\s\S]+)$', s)
855
784
  if cd:
856
785
  return _unwrap_command(cd.group(1))
857
786
  npx = re.match(r'^(?:npx|bunx)\s+(?:-\S+\s+)*(.+)$', s)
858
787
  if npx:
859
788
  return _unwrap_command(npx.group(1))
789
+ for_loop = re.match(r'^for\s+[\s\S]+?\bdo\b\s*([\s\S]+?)\s*;?\s*done\b[\s\S]*$', s)
790
+ if for_loop:
791
+ return _unwrap_command(for_loop.group(1))
792
+ until_loop = re.match(r'^until\s+[\s\S]+?\bdo\b\s*([\s\S]+?)\s*;?\s*done\b[\s\S]*$', s)
793
+ if until_loop:
794
+ return _unwrap_command(until_loop.group(1))
795
+ if_cond = re.match(
796
+ r'^if\s+[\s\S]+?\bthen\b\s*([\s\S]+?)\s*;?\s*(?:elif\b|else\b|fi\b)[\s\S]*$',
797
+ s,
798
+ )
799
+ if if_cond:
800
+ return _unwrap_command(if_cond.group(1))
801
+ subshell = re.match(r'^\(\s*([\s\S]+?)\s*\)\s*(?:&&|;|\|\|)[\s\S]*$', s) or re.match(
802
+ r'^\(\s*([\s\S]+?)\s*\)\s*$', s
803
+ )
804
+ if subshell:
805
+ return _unwrap_command(subshell.group(1))
860
806
  return s
861
807
 
862
808