@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -15,6 +15,7 @@ import chalk from 'chalk';
15
15
  import { setHelpSections } from '../lib/help.js';
16
16
  import { machineId } from '../lib/session/sync/config.js';
17
17
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
18
+ import { isHostPinned, managedKnownHostsPath } from '../lib/devices/known-hosts.js';
18
19
  import { ensureDevicesRegistered } from '../lib/devices/sync.js';
19
20
  import { readFleetFile, resolveDesired } from '../lib/fleet/manifest.js';
20
21
  import { snapshotAuth, materializeAuth, parseAuthBundle, KEYCHAIN_BOUND_ON_MAC, isCredentialSafeToPropagate } from '../lib/fleet/auth-sync.js';
@@ -82,7 +83,10 @@ function renderPlan(plan) {
82
83
  }
83
84
  return chalk.cyan('↑ ' + acts.map((a) => a.agent ?? a.kind.replace('-cli', '')).join(','));
84
85
  };
85
- const header = ` ${'device'.padEnd(nameWidth)} ${'agents-cli'.padEnd(12)}${'agents'.padEnd(20)}${'config'.padEnd(10)}login`;
86
+ // Only show the secrets column when the manifest declares any — an all-`-`
87
+ // column on every fleet that uses no bundles is noise.
88
+ const anySecrets = rows.some((r) => r.actions.some((a) => a.kind === 'push-secret' || a.kind === 'needs-secret'));
89
+ const header = ` ${'device'.padEnd(nameWidth)} ${'agents-cli'.padEnd(12)}${'agents'.padEnd(20)}${'config'.padEnd(10)}${anySecrets ? 'login'.padEnd(18) + 'secrets' : 'login'}`;
86
90
  console.log(chalk.gray(header));
87
91
  for (const row of rows) {
88
92
  const cli = row.probe.reachable
@@ -100,10 +104,33 @@ function renderPlan(plan) {
100
104
  ? (row.actions.some((a) => a.kind === 'sync-config') ? chalk.cyan('↑ sync') : chalk.green('ok'))
101
105
  : chalk.gray('-');
102
106
  const loginCell = cell(row, ['push-login', 'needs-login'], `${row.desired.agents.length}/${row.desired.agents.length}`);
103
- console.log(` ${row.device.padEnd(nameWidth)} ${stripPad(cli, 12)}${stripPad(agentsCell, 20)}${stripPad(configCell, 10)}${loginCell}`);
107
+ const secretsCell = (() => {
108
+ if (!anySecrets)
109
+ return '';
110
+ if (!row.probe.reachable)
111
+ return chalk.gray('- offline');
112
+ const push = row.actions.filter((a) => a.kind === 'push-secret').length;
113
+ const blocked = row.actions.filter((a) => a.kind === 'needs-secret').length;
114
+ if (push === 0 && blocked === 0)
115
+ return chalk.green('ok');
116
+ if (push > 0 && blocked === 0)
117
+ return chalk.cyan(`↑ push ${push}`);
118
+ if (push === 0)
119
+ return chalk.yellow(`blocked ${blocked}`);
120
+ return chalk.yellow(`↑ push ${push} · blocked ${blocked}`);
121
+ })();
122
+ const loginPart = anySecrets ? stripPad(loginCell, 18) + secretsCell : loginCell;
123
+ console.log(` ${row.device.padEnd(nameWidth)} ${stripPad(cli, 12)}${stripPad(agentsCell, 20)}${stripPad(configCell, 10)}${loginPart}`);
104
124
  }
105
125
  console.log();
106
126
  console.log(chalk.gray(` ${plan.actions.length} action(s) across ${rows.filter((r) => r.probe.reachable).length} reachable device(s)`));
127
+ // The capability is opt-in, so when it is OFF and the manifest declares
128
+ // bundles, say that it exists. Otherwise an operator reads "manual recreate"
129
+ // and concludes there is no supported path — which is exactly the conclusion
130
+ // that led to a master key being hand-exported across the fleet (RUSH-1968).
131
+ if (anySecrets && !rows.some((r) => r.actions.some((a) => a.kind === 'push-secret'))) {
132
+ console.log(chalk.gray(' secrets: not pushed. `--provision-secrets` pushes declared bundles to devices whose host key is pinned.'));
133
+ }
107
134
  // Distinguish *why* a login can't be propagated: macOS keychain-bound,
108
135
  // single-use rotating refresh token (never copied), or the source simply not
109
136
  // being signed in to that agent (no portable file).
@@ -130,14 +157,15 @@ function renderPlan(plan) {
130
157
  if (noToken.length > 0) {
131
158
  console.log(chalk.yellow(` manual login needed (no portable token on source): ${noToken.join(', ')}`));
132
159
  }
133
- // Secrets bundles are declared once for the fleet; surface the distinct set to
134
- // recreate on any device missing them (values are keychain-local, never pushed).
160
+ // Secrets bundles are declared once for the fleet; surface the distinct set the
161
+ // gate did NOT push, so a refusal is never silent. "never pushed" used to be
162
+ // literally true here and no longer is — `--provision-secrets` pushes them.
135
163
  const bundles = [...new Set(rows.flatMap((r) => r.secretsNeeded))];
136
164
  if (bundles.length > 0) {
137
165
  const shown = bundles.slice(0, 12);
138
166
  const more = bundles.length - shown.length;
139
167
  const list = shown.join(', ') + (more > 0 ? `, +${more} more` : '');
140
- console.log(chalk.yellow(` ${bundles.length} secrets bundle(s) to recreate where missing (keychain-local, never pushed): ${list}`));
168
+ console.log(chalk.yellow(` ${bundles.length} secrets bundle(s) not pushed — recreate where missing: ${list}`));
141
169
  }
142
170
  }
143
171
  /** padEnd on the visible width, ignoring chalk color codes. Exported for tests. */
@@ -218,11 +246,24 @@ async function runApply(opts) {
218
246
  // Probe every target device in parallel.
219
247
  const nameToProfile = new Map(desired.map((d) => [d.device, registry[d.device]]));
220
248
  const withVersions = rosterNeedsVersions(desired);
249
+ // One extra `secrets list --json` per device, and only when it can change the
250
+ // plan: the manifest declares bundles AND provisioning is on. Same cost
251
+ // discipline as `withVersions` — a fleet that uses no bundles never pays it.
252
+ const withSecrets = opts.provisionSecrets === true && (manifest.secrets?.bundles?.length ?? 0) > 0;
221
253
  console.log(chalk.gray(`Probing ${desired.length} device(s)…`));
222
- const probeList = await pool(desired, 6, async (d) => probeDevice(nameToProfile.get(d.device), { withVersions }));
254
+ const probeList = await pool(desired, 6, async (d) => probeDevice(nameToProfile.get(d.device), { withVersions, withSecrets }));
223
255
  const probes = new Map(probeList.map((p) => [p.device, p]));
224
256
  const targetCliVersion = localCliVersion();
225
- let plan = diffFleet(desired, probes, { targetCliVersion, sourceAuth, secretsBundles: manifest.secrets?.bundles });
257
+ let plan = diffFleet(desired, probes, {
258
+ targetCliVersion,
259
+ sourceAuth,
260
+ secretsBundles: manifest.secrets?.bundles,
261
+ provisionSecrets: opts.provisionSecrets === true,
262
+ forceSecrets: opts.force === true,
263
+ // Same bar as `exec --copy-creds` (EXEC-34): credential values only ever go
264
+ // to a host whose key we already pinned.
265
+ isHostPinned: (device) => isHostPinned(device, managedKnownHostsPath()),
266
+ });
226
267
  // --only filter.
227
268
  if (opts.only) {
228
269
  const keep = new Set();
@@ -242,7 +283,8 @@ async function runApply(opts) {
242
283
  return;
243
284
  // `needs-login`/`needs-secret` are surfaced manual reminders, not executable
244
285
  // mutations — exclude them so an otherwise-converged fleet still says "nothing
245
- // to do" instead of looping forever on un-actionable surfacing.
286
+ // to do" instead of looping forever on un-actionable surfacing. `push-secret`
287
+ // IS executable and deliberately stays counted.
246
288
  if (plan.actions.filter((a) => a.kind !== 'needs-login' && a.kind !== 'needs-secret').length === 0) {
247
289
  console.log(chalk.green('\nNothing to do — fleet already matches the profile.'));
248
290
  return;
@@ -301,6 +343,8 @@ export function configureApplyCommand(cmd) {
301
343
  .option('--agent <specs...>', 'Override the roster for targeted device(s): install these specs instead of the manifest\'s. Use `claude@all` to replicate every version installed on this machine.')
302
344
  .option('--only <dims>', 'Limit to dimensions: comma list of agents,config,login')
303
345
  .option('--no-login', 'Do not propagate logins')
346
+ .option('--provision-secrets', "Push the manifest's declared secrets bundles to each device (OFF by default; moves credential values over SSH, and only to a device whose host key is already pinned)")
347
+ .option('--force', 'With --provision-secrets: re-push a bundle the device already has')
304
348
  .addOption(new Option('--recv-auth', 'internal: receive an auth bundle on stdin').hideHelp())
305
349
  .action(async (opts) => {
306
350
  try {
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerBenchCommand(program: Command): void;
@@ -0,0 +1,101 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { detectSignedInRuntimes } from "../lib/crabbox/runtimes.js";
5
+ import { listRuns, loadRun, loadTask, runCells, saveRun, } from "../lib/bench/index.js";
6
+ import { setHelpSections } from "../lib/help.js";
7
+ const TASKS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../bench/tasks");
8
+ function csv(value) {
9
+ return (value
10
+ ?.split(",")
11
+ .map((item) => item.trim())
12
+ .filter(Boolean) ?? []);
13
+ }
14
+ function taskIds(root = TASKS_ROOT) {
15
+ if (!fs.existsSync(root))
16
+ return [];
17
+ return fs
18
+ .readdirSync(root, { withFileTypes: true })
19
+ .filter((entry) => entry.isDirectory() &&
20
+ fs.existsSync(path.join(root, entry.name, "task.json")))
21
+ .map((entry) => entry.name)
22
+ .sort();
23
+ }
24
+ function renderResult(result) {
25
+ console.log(`Run ${result.run_id}${result.task_id ? ` · ${result.task_id}` : ""}`);
26
+ for (const cell of result.cells)
27
+ console.log(`${cell.status === "passed" ? "PASS" : "FAIL"} ${cell.agent}${cell.model ? `/${cell.model}` : ""} ${cell.wall_ms} ms exit ${cell.exit ?? "spawn-error"}`);
28
+ }
29
+ export function registerBenchCommand(program) {
30
+ const bench = program
31
+ .command("bench")
32
+ .description("Run the same task across agent and model cells, with isolated fixtures and durable JSON results.");
33
+ setHelpSections(bench, {
34
+ examples: `agents bench list\nagents bench run hello-repo --agent claude,codex --model cheap,default\nagents bench results --json`,
35
+ notes: `Task definitions live under apps/cli/bench/tasks/<id>/task.json. Custom harness names accepted by agents run are valid --agent values.`,
36
+ });
37
+ bench
38
+ .command("list")
39
+ .description("List available benchmark tasks.")
40
+ .option("--json", "Emit JSON.")
41
+ .action((options) => {
42
+ const tasks = taskIds();
43
+ if (options.json)
44
+ console.log(JSON.stringify(tasks, null, 2));
45
+ else if (tasks.length === 0)
46
+ console.log("No benchmark tasks installed.");
47
+ else
48
+ tasks.forEach((id) => console.log(id));
49
+ });
50
+ bench
51
+ .command("results [run-id]")
52
+ .description("Show one saved run, or list saved runs newest first.")
53
+ .option("--json", "Emit JSON.")
54
+ .action((runId, options) => {
55
+ const value = runId ? loadRun(runId) : listRuns();
56
+ if (options.json)
57
+ console.log(JSON.stringify(value, null, 2));
58
+ else if (Array.isArray(value)) {
59
+ if (value.length === 0)
60
+ console.log("No benchmark results yet.");
61
+ else
62
+ value.forEach(renderResult);
63
+ }
64
+ else
65
+ renderResult(value);
66
+ });
67
+ bench
68
+ .command("run [task-id]")
69
+ .description("Run one task or prompt across an agent × model matrix.")
70
+ .option("--prompt <text>", "Prompt to benchmark instead of a house task.")
71
+ .option("--agent <names>", "Comma-separated native agents or custom harness names. Defaults to signed-in native agents.")
72
+ .option("--model <models>", "Comma-separated model tiers or concrete model ids.")
73
+ .option("--concurrency <n>", "Maximum cells running at once.", "3")
74
+ .option("--json", "Emit the saved JSON result.")
75
+ .action(async (taskId, options) => {
76
+ if (!!taskId === !!options.prompt)
77
+ throw new Error("Pass exactly one of <task-id> or --prompt.");
78
+ const task = taskId ? loadTask(taskId, TASKS_ROOT) : undefined;
79
+ const prompt = options.prompt ?? task.prompt;
80
+ let agents = csv(options.agent);
81
+ if (agents.length === 0)
82
+ agents = (await detectSignedInRuntimes())
83
+ .filter((runtime) => runtime.signedIn)
84
+ .map((runtime) => runtime.id);
85
+ if (agents.length === 0)
86
+ throw new Error("No signed-in native agents found. Pass --agent <name>.");
87
+ const models = csv(options.model);
88
+ const cells = agents.flatMap((agent) => models.length > 0
89
+ ? models.map((model) => ({ agent, model }))
90
+ : [{ agent }]);
91
+ const concurrency = Number(options.concurrency);
92
+ const result = await runCells({ task, prompt, cells, concurrency });
93
+ saveRun(result);
94
+ if (options.json)
95
+ console.log(JSON.stringify(result, null, 2));
96
+ else
97
+ renderResult(result);
98
+ if (result.cells.some((cell) => cell.status === "failed"))
99
+ process.exitCode = 1;
100
+ });
101
+ }
@@ -20,6 +20,7 @@ import { isInteractiveTerminal } from './utils.js';
20
20
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
21
21
  import { buildHar } from '../lib/browser/har.js';
22
22
  import { getCliVersion } from '../lib/version.js';
23
+ import { runBrowserIPCStream } from '../lib/browser/stream.js';
23
24
  /**
24
25
  * Resolve which browser task a command targets. Order:
25
26
  * 1. `--task <name>` flag (explicit per-command override)
@@ -48,6 +49,7 @@ const TASK_OPTION_DESC = 'Task name (defaults to $AGENTS_BROWSER_TASK)';
48
49
  // trailing "Other commands" section automatically.
49
50
  const BROWSER_HELP_GROUPS = [
50
51
  { title: 'Session lifecycle', names: ['start', 'done', 'status'] },
52
+ { title: 'Fast action loop', names: ['stream'] },
51
53
  {
52
54
  title: 'Drive the page',
53
55
  names: ['navigate', 'tabs', 'screenshot', 'evaluate', 'click', 'type', 'press', 'wait'],
@@ -79,6 +81,9 @@ export function registerBrowserCommand(program) {
79
81
  agents browser navigate https://example.com
80
82
  agents browser screenshot
81
83
 
84
+ # Keep one process and daemon socket warm for repeated actions
85
+ agents browser stream --task "$AGENTS_BROWSER_TASK"
86
+
82
87
  # Drive another machine's browser (needs its consent — see remote-control)
83
88
  agents browser start --host zion
84
89
 
@@ -654,6 +659,36 @@ function registerTaskCommands(browser) {
654
659
  ? 'Other fleet machines can now drive this browser via `browser --host <this-device>`.'
655
660
  : 'Cross-machine `browser --host` drives to this machine are refused.');
656
661
  });
662
+ const stream = browser
663
+ .command('stream')
664
+ .description('Keep one process and daemon IPC socket open; read NDJSON requests from stdin and write NDJSON responses')
665
+ .option(TASK_OPTION_FLAG, 'Default task for requests that omit `task` (defaults to $AGENTS_BROWSER_TASK)')
666
+ .action(async (opts) => {
667
+ await runBrowserIPCStream({
668
+ input: process.stdin,
669
+ output: process.stdout,
670
+ task: opts.task ?? process.env.AGENTS_BROWSER_TASK,
671
+ actor: resolveActor().id,
672
+ launchId: process.env.AGENT_LAUNCH_ID,
673
+ });
674
+ });
675
+ setHelpSections(stream, {
676
+ examples: `
677
+ # Batch two warm actions through one process and one daemon connection
678
+ printf '%s\\n' \\
679
+ '{"action":"screenshot","path":"/tmp/page.jpg"}' \\
680
+ '{"action":"click","atX":320,"atY":540}' \\
681
+ | agents browser stream --task "$AGENTS_BROWSER_TASK"
682
+
683
+ # Keep the command open and send one JSON object per line from a long-lived shell
684
+ agents browser stream --task "$AGENTS_BROWSER_TASK"
685
+ `,
686
+ notes: `
687
+ stdout is protocol-only: one compact JSON response for each non-empty input line.
688
+ Malformed JSON returns an error response without closing the stream.
689
+ The first start response becomes the default task for later lines in the same stream.
690
+ `,
691
+ });
657
692
  browser
658
693
  .command('start')
659
694
  .description('Start a browser task. Pass --profile <name>; omit to use your configured default (`agents browser profiles set-default`), else auto-pick an installed Chromium-family browser.')
@@ -12,7 +12,8 @@ import { buildLocalFindings, fleetDivergenceToFindings, signInToFindings, render
12
12
  import { getCliVersion } from '../lib/version.js';
13
13
  import { resolveHost } from '../lib/hosts/registry.js';
14
14
  import { sshExecAsync } from '../lib/ssh-exec.js';
15
- import { sshTargetFor } from '../lib/hosts/types.js';
15
+ import { hostIdentityArgs, sshTargetFor } from '../lib/hosts/types.js';
16
+ import { deviceIdentityArgs } from '../lib/devices/connect.js';
16
17
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
17
18
  import { findAmbiguousDevicePins } from '../lib/routines.js';
18
19
  import chalk from 'chalk';
@@ -26,11 +27,14 @@ import { checkVersionHookWiring, inspectDuplicateVersionHooks, registerHooksToSe
26
27
  import { isVersionIsolated } from '../lib/versions.js';
27
28
  import { computeDrift, checkSyncStatus, countOrphans, computeSourceBehind } from '../lib/drift.js';
28
29
  import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
30
+ import { readMeta } from '../lib/state.js';
31
+ import { probeOwnerSink } from '../lib/channels/owner-sink.js';
29
32
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
30
33
  import { listCliStatus, listCliStatusAsync } from '../lib/cli-resources.js';
31
34
  import { setHelpSections } from '../lib/help.js';
32
35
  import { heal, healChangedAnything } from '../lib/heal.js';
33
36
  import { getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
37
+ import { auditWindowsSshEnrollment, diagnoseWindowsSshFailure } from '../lib/devices/windows-ssh-enrollment.js';
34
38
  import { scanUserRcFiles, masterPassphraseInEnv } from '../lib/secrets/rc-hygiene.js';
35
39
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
36
40
  import { readRepoBehindMarkers } from '../lib/auto-pull.js';
@@ -81,14 +85,14 @@ function printWrappedLine(prefix, text) {
81
85
  console.log(chalk.gray(line));
82
86
  }
83
87
  /**
84
- * Narrow a remote `findings` array to the secret-hygiene rows, or `[]`.
88
+ * Narrow a remote `findings` array to rows only that device can observe.
85
89
  *
86
90
  * Deliberately NOT "forward every remote finding". The aggregator already
87
91
  * rebuilds a remote's sign-in rows from its inventory and its divergence rows
88
92
  * from the comparator, so forwarding wholesale would double them; and pulling a
89
93
  * remote's orphan/drift rows into a fleet readout is a much larger UX change
90
- * than this fix. The two secret kinds are the ones that are BOTH unrecomputable
91
- * centrally and security-relevant, which is exactly why they were being lost.
94
+ * than this fix. These kinds are unrecomputable centrally: shell/process secret
95
+ * hygiene and the Windows host's effective OpenSSH key path/content/ACL.
92
96
  *
93
97
  * **The remote contributes exactly one thing: the KIND.** Severity, message and
94
98
  * remediation are all generated HERE. That is not defensiveness for its own
@@ -109,13 +113,15 @@ function printWrappedLine(prefix, text) {
109
113
  * file and line. Run `agents doctor` on that box for the specifics — the
110
114
  * message says so.
111
115
  */
112
- const REMOTE_FORWARDED_KINDS = ['rc-secret-export', 'env-secret-export'];
116
+ const REMOTE_FORWARDED_KINDS = ['rc-secret-export', 'env-secret-export', 'ssh-key-enrollment'];
113
117
  /** Canonical, locally-authored text for a forwarded kind. Never the remote's. */
114
118
  const REMOTE_SECRET_MESSAGE = {
115
119
  'rc-secret-export': 'a credential-shaped export was found in this box\'s shell rc files'
116
120
  + ' — run `agents doctor` there for the file and line',
117
121
  'env-secret-export': 'AGENTS_SECRETS_PASSPHRASE is set in this box\'s process environment'
118
122
  + ' — run `agents doctor` there for detail',
123
+ 'ssh-key-enrollment': 'Windows OpenSSH key enrollment is invalid'
124
+ + ' — run `agents doctor` on this box for the effective path or ACL failure',
119
125
  };
120
126
  export function asRemoteSecretFindings(raw, device) {
121
127
  if (!Array.isArray(raw))
@@ -156,11 +162,12 @@ async function resolveFleetTargets(opts) {
156
162
  name: deviceProfile.name,
157
163
  sshTarget: deviceProfile.name,
158
164
  os: deviceProfile.platform !== 'unknown' ? deviceProfile.platform : undefined,
165
+ extraSshArgs: deviceIdentityArgs(deviceProfile),
159
166
  }];
160
167
  }
161
168
  const host = await resolveHost(singleName);
162
169
  if (host) {
163
- return [{ name: singleName, sshTarget: sshTargetFor(host), os: host.os }];
170
+ return [{ name: singleName, sshTarget: sshTargetFor(host), os: host.os, extraSshArgs: hostIdentityArgs(host) }];
164
171
  }
165
172
  console.error(chalk.red(`Unknown host or device '${singleName}'.`));
166
173
  process.exit(1);
@@ -178,6 +185,7 @@ async function resolveFleetTargets(opts) {
178
185
  name: d.name,
179
186
  sshTarget: d.name,
180
187
  os: d.platform !== 'unknown' ? d.platform : undefined,
188
+ extraSshArgs: deviceIdentityArgs(d),
181
189
  }));
182
190
  }
183
191
  async function probeFleetTarget(target) {
@@ -188,12 +196,14 @@ async function probeFleetTarget(target) {
188
196
  // has it via the install profile, and our single-quote escaping would
189
197
  // prevent $HOME expansion there, so skip the bootstrap on Windows.
190
198
  isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
191
- const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
199
+ const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: 30000, multiplex: true, extraSshArgs: target.extraSshArgs });
192
200
  if (res.code !== 0) {
193
201
  return {
194
202
  name: target.name,
195
203
  online: false,
196
- error: res.timedOut ? 'timed out' : (res.stderr || `exit ${res.code ?? 'unknown'}`),
204
+ error: isWin
205
+ ? diagnoseWindowsSshFailure(res.stderr, res.timedOut)
206
+ : res.timedOut ? 'timed out' : (res.stderr || `exit ${res.code ?? 'unknown'}`),
197
207
  agents: {},
198
208
  };
199
209
  }
@@ -236,7 +246,11 @@ export const FLEET_INVENTORY_TIMEOUT_MS = 180_000;
236
246
  async function probeFleetInventory(target) {
237
247
  const isWin = /^win/i.test((target.os ?? '').trim());
238
248
  const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
239
- const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: FLEET_INVENTORY_TIMEOUT_MS, multiplex: true });
249
+ const res = await sshExecAsync(target.sshTarget, remoteCmd, {
250
+ timeoutMs: FLEET_INVENTORY_TIMEOUT_MS,
251
+ multiplex: true,
252
+ extraSshArgs: target.extraSshArgs,
253
+ });
240
254
  if (res.code !== 0)
241
255
  return null;
242
256
  try {
@@ -402,9 +416,13 @@ async function runDevicesDoctor(opts) {
402
416
  execPolicy: process.platform === 'win32'
403
417
  ? { platform: process.platform, policy: getEffectiveExecutionPolicy() }
404
418
  : undefined,
419
+ windowsSshEnrollment: auditWindowsSshEnrollment(),
405
420
  isolatedVersions: localReports
406
421
  .filter((rep) => isVersionIsolated(rep.agent, rep.version))
407
422
  .map((rep) => `${rep.agent}@${rep.version}`),
423
+ // Can the owner-delivery lane escalate a block from THIS box? Local only —
424
+ // remote boxes self-report it in their own `agents doctor --json`.
425
+ ownerSink: await probeOwnerSink(readMeta()),
408
426
  }));
409
427
  accounts[localName] = r.inventory?.signIn ?? {};
410
428
  continue;
@@ -1177,7 +1195,7 @@ function checkPayload(device, drift) {
1177
1195
  async function probeDeviceCheck(target) {
1178
1196
  const isWin = /^win/i.test((target.platform ?? '').trim());
1179
1197
  const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--check', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
1180
- const res = await sshExecAsync(target.dialTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
1198
+ const res = await sshExecAsync(target.dialTarget, remoteCmd, { timeoutMs: 30000, multiplex: true, extraSshArgs: target.extraSshArgs });
1181
1199
  if (res.code !== 0 && !res.stdout.trim()) {
1182
1200
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
1183
1201
  }
@@ -1206,6 +1224,7 @@ async function runDevicesCheck(opts, cwd) {
1206
1224
  platform: t.device.platform,
1207
1225
  skip: t.skip,
1208
1226
  dialTarget: fleetDialTarget(t.device),
1227
+ extraSshArgs: deviceIdentityArgs(t.device),
1209
1228
  }));
1210
1229
  const remote = await fanOutDevices(remoteTargets, probeDeviceCheck);
1211
1230
  const devices = [local];
@@ -1486,6 +1505,9 @@ export function registerDoctorCommand(program) {
1486
1505
  ? { platform: process.platform, policy: getEffectiveExecutionPolicy() }
1487
1506
  : undefined,
1488
1507
  isolatedVersions,
1508
+ // Can the owner-delivery lane (feed/notify) escalate a block from this
1509
+ // box? A factory that cannot escalate is not healthy (RUSH-2262).
1510
+ ownerSink: await probeOwnerSink(readMeta()),
1489
1511
  });
1490
1512
  if (opts.json) {
1491
1513
  const overviewPayload = {
@@ -554,7 +554,7 @@ export function registerRunCommand(program) {
554
554
  .option('--until <signal>', 'Loop stop condition. `signal` reads <runDir>/loop-signal.json {continue,reason} each iteration; absent or continue:false stops (fail-closed). Loop only.')
555
555
  .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.')
556
556
  .option('--where <spec>', 'Where this run\'s body executes (one placement door): local | device:<name> | auto | lease[:backend] | cloud[:provider]. Expands to --host/--lease/--cloud. Do not combine with those flags. See docs/00-concepts.md#placement.')
557
- .option('--host <name>', 'Offload this run onto another machine over SSH — a device name, registered host, or user@host. Pass "auto" to pick from 14d usage affinity (most-used online device has highest probability). Same as --where device:<name>. See `agents devices`.')
557
+ .option('--host <name>', 'Offload this run onto another machine over SSH — a device name, registered host, or user@host. Pass "auto" to pick the least-loaded reachable device where the requested agent is installed and signed in, keeping the run local when no remote is better. Same as --where device:<name>. See `agents devices`.')
558
558
  .option('--device <name>', 'Alias of --host. Pass "auto" for affinity-based device pick (same as --where auto).')
559
559
  .option('--remote-cwd <dir>', "Explicit host working directory for --host runs, used VERBATIM (overrides --cwd; usually --cwd suffices — it re-roots a local-home path onto the remote home). Pass a single-quoted '$HOME/…' or a valid remote absolute path; a local ~ expands here and won't exist there (/Users/you vs /home/you).")
560
560
  .option('--no-follow', 'With --host, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
@@ -947,13 +947,18 @@ export function registerRunCommand(program) {
947
947
  if (!resolvedResumeSource && runAutoDefaultsToAffinity(options))
948
948
  options.device = 'auto';
949
949
  }
950
- // --device auto / --host auto (and deprecated --smart): affinity-pick host.
950
+ // --device auto / --host auto (and deprecated --smart): live fleet pick.
951
951
  // Harness is always the agent the user typed — never auto-picked.
952
952
  // Affinity failure degrades to local (does not kill the run).
953
953
  {
954
954
  const { applyDeviceAutoToOptions } = await import('../lib/smart-launch.js');
955
- const result = applyDeviceAutoToOptions(options, {
955
+ const result = await applyDeviceAutoToOptions(options, {
956
956
  accountPickerRequested,
957
+ // `run auto` selects its harness after placement, so do not filter
958
+ // candidates against an arbitrary proxy harness at this stage.
959
+ agent: normalizedAgentSpec.split('@')[0] === RUN_AUTO_KEYWORD
960
+ ? undefined
961
+ : (resolveAgentName(normalizedAgentSpec.split('@')[0]) ?? undefined),
957
962
  });
958
963
  if (!options.quiet && result.deprecationSmart) {
959
964
  process.stderr.write(chalk.yellow('[agents] --smart is deprecated; use --device auto\n'));
@@ -964,7 +969,7 @@ export function registerRunCommand(program) {
964
969
  if (!options.quiet && result.banner) {
965
970
  const { hostLabel, deviceHint, acctNote } = result.banner;
966
971
  process.stderr.write(chalk.gray(`[agents] device=auto → ${hostLabel}` +
967
- (deviceHint ? ` (affinity ${deviceHint})` : '') +
972
+ (deviceHint ? ` (load ${deviceHint})` : '') +
968
973
  ` · ${acctNote}\n`));
969
974
  }
970
975
  }
@@ -2268,22 +2273,18 @@ export function registerRunCommand(program) {
2268
2273
  process.exit(1);
2269
2274
  }
2270
2275
  version = resolvedRecoveryTarget.version;
2271
- const canResumeNatively = resolvedRecoveryTarget.mode === 'native';
2272
- if (canResumeNatively) {
2276
+ if (resolvedRecoveryTarget.mode === 'native') {
2273
2277
  version = session.version;
2274
2278
  resumeNative = true;
2275
2279
  resumeSessionId = session.id;
2276
- // Native `--resume` (claude/codex) resolves the transcript relative to the
2277
- // working directory (projects/<cwd-hash>/). The session may have been started
2278
- // in a different directory than we're standing in now — most importantly when a
2279
- // routine daemon fires `agents run --resume` from its own cwd. Spawn from the
2280
- // session's ORIGIN cwd so the resume actually finds it; otherwise the agent
2281
- // exits "No conversation found with session ID". Honor an explicit --cwd only if
2282
- // the caller passed one (they're overriding on purpose).
2283
- if (!options.cwd && session.cwd)
2284
- options.cwd = session.cwd;
2280
+ // The centralized recovery decision proves the transcript belongs to
2281
+ // this exact isolated home and resolves any harness-specific launch cwd.
2282
+ // Claude's indexed `session.cwd` is the first user-turn cwd, which may
2283
+ // differ from the earlier cwd that selected projects/<cwd-key>.
2284
+ if (!options.cwd && resolvedRecoveryTarget.cwd)
2285
+ options.cwd = resolvedRecoveryTarget.cwd;
2285
2286
  if (!options.quiet)
2286
- process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''}${!options.cwd || options.cwd === session.cwd ? ` in ${session.cwd ?? cwd}` : ''}\n`));
2287
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''} in ${options.cwd ?? cwd}\n`));
2287
2288
  }
2288
2289
  else {
2289
2290
  // Tier-2: launch fresh with a /continue <id> first message; the agent
@@ -1,23 +1,26 @@
1
1
  /**
2
- * Insights command — how you actually work, split by the account that did the work.
3
- *
4
- * The behavioural sibling of the existing rollups, and deliberately not a duplicate of
5
- * any of them:
6
- *
7
- * agents cost what you spent ($ and duration)
8
- * agents output what shipped (burn vs PRs and commits)
9
- * agents usage live quota headroom (rate-limit windows, right now)
10
- * agents trends aggregate distributions (harness mix, tools-per-session, token ratios)
11
- * agents sessions browse individual work (search, resume, render)
12
- * agents insights HOW you work (tools, friction, rhythm, per account)
13
- *
14
- * The closest neighbour is `agents trends`, and the boundary is the data path: trends
15
- * reads counters `tool_scan_ledger` call counts and the analytics warehouse — to
16
- * produce distributions ("how many tool calls per session, by harness"). This reads
17
- * transcript CONTENT through `parseSession` to produce behaviour ("which tools, which
18
- * languages, where it went wrong, when you were working"), and splits all of it by
19
- * account, a dimension trends does not have. They overlap in spirit on tool and model
20
- * mix; they do not read the same store or answer the same question.
2
+ * Insights command — one observe verb for "how work looks".
3
+ *
4
+ * Two data paths under one name (do not re-split into peer top-level commands):
5
+ *
6
+ * agents insights HOW you work (transcript content: tools, friction,
7
+ * rhythm, edits) split by Claude account by default
8
+ * agents insights mix COUNTERS (sessions index + usage.db recipes:
9
+ * harness/model mix, token ratios, secrets, browser)
10
+ * agents insights <recipe> One baked mix recipe (harness-mix, tools-per-session, )
11
+ * agents insights query Raw usage.db rows
12
+ *
13
+ * Sibling observe verbs (stay separate — different questions):
14
+ *
15
+ * agents cost what you spent ($ and duration)
16
+ * agents output what shipped (burn vs PRs and commits)
17
+ * agents usage live quota headroom
18
+ * agents perf latency (hooks, CLI commands, agent.run) not popularity
19
+ * agents sessions stats which skills/slash-commands were explicitly invoked
20
+ *
21
+ * Why mix lives here (not a second top-level `trends`): two abstract "analytics"
22
+ * nouns taught agents and humans to guess. One verb, two engines — cheap SQL mix
23
+ * vs transcript facets. Latency stays on `perf` so it is never confused with mix.
21
24
  *
22
25
  * Modelled on Claude Code's `/insights`, with the difference that motivated it: that
23
26
  * command reads one account's directory, while `balanced` rotation sprays sessions
@@ -27,6 +30,9 @@
27
30
  * The deterministic report makes zero network calls. `--narrative` is opt-in and adds
28
31
  * the coaching prose by piping the AGGREGATE (never raw transcripts) through a headless
29
32
  * `claude -p`.
33
+ *
34
+ * `agents trends` is a thin deprecated alias of the mix tree only (see commands/trends.ts).
30
35
  */
31
36
  import type { Command } from 'commander';
32
37
  export declare function registerInsightsCommand(program: Command): void;
38
+ export declare function registerSessionsInsightsCommand(sessions: Command): void;