@phnx-labs/agents-cli 1.22.26 → 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 (131) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +18 -1
  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 +17 -16
  8. package/dist/commands/insights.js +39 -5
  9. package/dist/commands/output.js +100 -25
  10. package/dist/commands/resume.d.ts +11 -0
  11. package/dist/commands/resume.js +51 -0
  12. package/dist/commands/sessions-picker.js +12 -7
  13. package/dist/commands/sessions.d.ts +47 -1
  14. package/dist/commands/sessions.js +345 -37
  15. package/dist/commands/ssh.js +184 -11
  16. package/dist/commands/teams.js +3 -3
  17. package/dist/index.js +16 -20
  18. package/dist/lib/auth-health.d.ts +47 -3
  19. package/dist/lib/auth-health.js +74 -15
  20. package/dist/lib/bench/index.d.ts +4 -0
  21. package/dist/lib/bench/index.js +4 -0
  22. package/dist/lib/bench/runner.d.ts +16 -0
  23. package/dist/lib/bench/runner.js +111 -0
  24. package/dist/lib/bench/schema.d.ts +5 -0
  25. package/dist/lib/bench/schema.js +91 -0
  26. package/dist/lib/bench/storage.d.ts +5 -0
  27. package/dist/lib/bench/storage.js +32 -0
  28. package/dist/lib/bench/types.d.ts +40 -0
  29. package/dist/lib/bench/types.js +1 -0
  30. package/dist/lib/claude-account-token.d.ts +2 -0
  31. package/dist/lib/claude-account-token.js +27 -1
  32. package/dist/lib/crabbox/cli.d.ts +2 -0
  33. package/dist/lib/crabbox/cli.js +2 -0
  34. package/dist/lib/crabbox/lease.js +7 -1
  35. package/dist/lib/daemon.js +20 -0
  36. package/dist/lib/devices/connect.d.ts +2 -0
  37. package/dist/lib/devices/connect.js +7 -0
  38. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  39. package/dist/lib/devices/doctor-findings.js +15 -1
  40. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  41. package/dist/lib/devices/harness-inventory.js +0 -0
  42. package/dist/lib/devices/registry.d.ts +2 -0
  43. package/dist/lib/devices/ssh-config.js +3 -0
  44. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  45. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  46. package/dist/lib/exec.bench.d.ts +1 -0
  47. package/dist/lib/exec.bench.js +186 -0
  48. package/dist/lib/exec.js +18 -8
  49. package/dist/lib/fleet/apply.js +9 -7
  50. package/dist/lib/fleet/remote-login.d.ts +4 -3
  51. package/dist/lib/fleet/remote-login.js +11 -9
  52. package/dist/lib/gemini-settings.d.ts +0 -1
  53. package/dist/lib/gemini-settings.js +12 -7
  54. package/dist/lib/hooks.d.ts +8 -5
  55. package/dist/lib/hooks.js +10 -6
  56. package/dist/lib/hosts/dispatch.js +7 -3
  57. package/dist/lib/hosts/passthrough.d.ts +22 -0
  58. package/dist/lib/hosts/passthrough.js +14 -8
  59. package/dist/lib/hosts/progress.d.ts +2 -1
  60. package/dist/lib/hosts/progress.js +4 -3
  61. package/dist/lib/hosts/providers/devices.js +1 -0
  62. package/dist/lib/hosts/ready.d.ts +4 -2
  63. package/dist/lib/hosts/ready.js +24 -8
  64. package/dist/lib/hosts/reconcile.d.ts +1 -1
  65. package/dist/lib/hosts/reconcile.js +16 -7
  66. package/dist/lib/hosts/reconnect.d.ts +45 -12
  67. package/dist/lib/hosts/reconnect.js +90 -34
  68. package/dist/lib/hosts/registry.d.ts +2 -2
  69. package/dist/lib/hosts/registry.js +3 -5
  70. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  71. package/dist/lib/hosts/remote-cmd.js +29 -0
  72. package/dist/lib/hosts/tasks.d.ts +2 -0
  73. package/dist/lib/hosts/types.d.ts +1 -0
  74. package/dist/lib/hosts/types.js +3 -0
  75. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  76. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  77. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  78. package/dist/lib/menubar/install-menubar.js +20 -9
  79. package/dist/lib/models.d.ts +30 -1
  80. package/dist/lib/models.js +42 -144
  81. package/dist/lib/pricing/cost.d.ts +9 -0
  82. package/dist/lib/pricing/cost.js +24 -0
  83. package/dist/lib/pricing/index.d.ts +1 -1
  84. package/dist/lib/pricing/index.js +1 -1
  85. package/dist/lib/redact.js +8 -3
  86. package/dist/lib/remote-agents-json.d.ts +32 -0
  87. package/dist/lib/remote-agents-json.js +47 -16
  88. package/dist/lib/resource-profiles.js +1 -2
  89. package/dist/lib/routine-notify-owner.d.ts +102 -0
  90. package/dist/lib/routine-notify-owner.js +232 -0
  91. package/dist/lib/routines.d.ts +10 -0
  92. package/dist/lib/runner.d.ts +9 -1
  93. package/dist/lib/runner.js +182 -17
  94. package/dist/lib/sandbox.d.ts +0 -2
  95. package/dist/lib/sandbox.js +2 -19
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.js +3 -2
  99. package/dist/lib/secrets/lease.d.ts +25 -0
  100. package/dist/lib/secrets/lease.js +44 -0
  101. package/dist/lib/self-update.d.ts +20 -5
  102. package/dist/lib/self-update.js +93 -16
  103. package/dist/lib/session/db.d.ts +19 -2
  104. package/dist/lib/session/db.js +54 -5
  105. package/dist/lib/session/discover.d.ts +21 -1
  106. package/dist/lib/session/discover.js +124 -32
  107. package/dist/lib/session/insights.d.ts +19 -0
  108. package/dist/lib/session/insights.js +78 -10
  109. package/dist/lib/session/recovery.d.ts +17 -1
  110. package/dist/lib/session/recovery.js +111 -4
  111. package/dist/lib/session/remote-list.d.ts +10 -9
  112. package/dist/lib/session/remote-list.js +25 -23
  113. package/dist/lib/session/resume-owner.d.ts +55 -0
  114. package/dist/lib/session/resume-owner.js +69 -0
  115. package/dist/lib/session/team-filter.d.ts +65 -0
  116. package/dist/lib/session/team-filter.js +98 -3
  117. package/dist/lib/session/types.d.ts +33 -0
  118. package/dist/lib/smart-launch.d.ts +31 -5
  119. package/dist/lib/smart-launch.js +43 -7
  120. package/dist/lib/ssh-exec.d.ts +27 -0
  121. package/dist/lib/ssh-exec.js +34 -1
  122. package/dist/lib/ssh-tunnel.d.ts +3 -2
  123. package/dist/lib/ssh-tunnel.js +25 -16
  124. package/dist/lib/startup/command-registry.d.ts +1 -0
  125. package/dist/lib/startup/command-registry.js +2 -0
  126. package/dist/lib/teams/placement-probe.d.ts +1 -1
  127. package/dist/lib/teams/placement-probe.js +24 -24
  128. package/dist/lib/tmux/session.d.ts +8 -0
  129. package/dist/lib/tmux/session.js +22 -0
  130. package/dist/lib/types.d.ts +2 -0
  131. package/package.json +1 -1
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Device affinity for `--device auto` / `--host auto`.
2
+ * Device placement for `--device auto` / `--host auto`.
3
3
  *
4
- * Picks a host from 14d session usage (launches by machine), weighted sample
5
- * among online devices. Most-used has highest probability — not a hard lock.
4
+ * Explicit auto placement probes live fleet health and harness readiness.
5
+ * Historical affinity remains for generic host resolution callers.
6
6
  * Account selection is separate (`--strategy balanced` / rotate.ts).
7
7
  */
8
8
  import { type AffinityRow } from './session/db.js';
9
+ import { type DevicePlacementSignal } from './teams/scheduler.js';
10
+ import type { AgentType } from './teams/agents.js';
9
11
  /** Peakiness of usage weights; >1 amplifies the most-used option. */
10
12
  export declare const DEFAULT_AFFINITY_ALPHA = 1.3;
11
13
  export interface WeightedCandidate {
@@ -45,6 +47,29 @@ export interface DeviceAffinityPlan {
45
47
  deviceCandidates: WeightedCandidate[];
46
48
  pickedDeviceKey: string | null;
47
49
  }
50
+ /** Live placement plan used by the explicit `--device auto` surface. */
51
+ export interface DeviceAutoPlan {
52
+ /** null means the local machine won the comparison. */
53
+ host: string | null;
54
+ candidates: Array<{
55
+ key: string;
56
+ loadPercent?: number;
57
+ installed?: boolean;
58
+ signedIn?: boolean;
59
+ }>;
60
+ pickedDeviceKey: string;
61
+ }
62
+ /**
63
+ * Pick the least-loaded healthy device that can run `agent` when the harness is
64
+ * known. `run auto` omits the agent and ranks the same live health/load signals
65
+ * without an installed-harness filter. The local machine participates in the
66
+ * same probe; a fully unusable fleet degrades to local instead of stranding a run.
67
+ */
68
+ export declare function resolveDeviceAuto(agent?: string, opts?: {
69
+ eligibleHosts?: string[];
70
+ localMachine?: string;
71
+ probe?: (pool: string[], agent?: AgentType) => Promise<Map<string, DevicePlacementSignal>>;
72
+ }): Promise<DeviceAutoPlan>;
48
73
  /**
49
74
  * Resolve host for `--device auto`. Does NOT pick harness or accounts.
50
75
  */
@@ -81,6 +106,7 @@ export type DeviceAutoApplyResult = {
81
106
  * returns `skipped` — never throws; callers must not hard-exit.
82
107
  */
83
108
  export declare function applyDeviceAutoToOptions(options: DeviceAutoHostOptions, deps?: {
84
- resolve?: () => DeviceAffinityPlan;
109
+ resolve?: () => DeviceAutoPlan | Promise<DeviceAutoPlan>;
110
+ agent?: string;
85
111
  accountPickerRequested?: boolean;
86
- }): DeviceAutoApplyResult;
112
+ }): Promise<DeviceAutoApplyResult>;
@@ -1,14 +1,16 @@
1
1
  /**
2
- * Device affinity for `--device auto` / `--host auto`.
2
+ * Device placement for `--device auto` / `--host auto`.
3
3
  *
4
- * Picks a host from 14d session usage (launches by machine), weighted sample
5
- * among online devices. Most-used has highest probability — not a hard lock.
4
+ * Explicit auto placement probes live fleet health and harness readiness.
5
+ * Historical affinity remains for generic host resolution callers.
6
6
  * Account selection is separate (`--strategy balanced` / rotate.ts).
7
7
  */
8
8
  import { queryAffinityRollup } from './session/db.js';
9
9
  import { localMachineId } from './session/origin-machine.js';
10
10
  import { loadDevicesSync } from './devices/registry.js';
11
11
  import { normalizeHost } from './machine-id.js';
12
+ import { probePoolSignals } from './teams/placement-probe.js';
13
+ import { pickBestDevice } from './teams/scheduler.js';
12
14
  /** Peakiness of usage weights; >1 amplifies the most-used option. */
13
15
  export const DEFAULT_AFFINITY_ALPHA = 1.3;
14
16
  /**
@@ -62,6 +64,39 @@ export function listOnlineDeviceNames(localName = localMachineId()) {
62
64
  }
63
65
  return [...names];
64
66
  }
67
+ /**
68
+ * Pick the least-loaded healthy device that can run `agent` when the harness is
69
+ * known. `run auto` omits the agent and ranks the same live health/load signals
70
+ * without an installed-harness filter. The local machine participates in the
71
+ * same probe; a fully unusable fleet degrades to local instead of stranding a run.
72
+ */
73
+ export async function resolveDeviceAuto(agent, opts = {}) {
74
+ const local = normalizeHost(opts.localMachine ?? localMachineId());
75
+ const pool = [...new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost))];
76
+ if (!pool.includes(local))
77
+ pool.push(local);
78
+ try {
79
+ const signals = await (opts.probe ?? probePoolSignals)(pool, agent);
80
+ const picked = pickBestDevice(pool, [], { signals, agentLabel: agent });
81
+ return {
82
+ host: picked === local ? null : picked,
83
+ pickedDeviceKey: picked,
84
+ candidates: pool.map((key) => ({
85
+ key,
86
+ loadPercent: signals.get(key)?.loadPercent,
87
+ installed: signals.get(key)?.installed,
88
+ signedIn: signals.get(key)?.signedIn,
89
+ })),
90
+ };
91
+ }
92
+ catch {
93
+ return {
94
+ host: null,
95
+ pickedDeviceKey: local,
96
+ candidates: [{ key: local }],
97
+ };
98
+ }
99
+ }
65
100
  /**
66
101
  * Resolve host for `--device auto`. Does NOT pick harness or accounts.
67
102
  */
@@ -120,7 +155,7 @@ const HOST_SLOTS = ['host', 'device', 'on', 'computer'];
120
155
  * Mutates `options` in place. On affinity failure, clears auto slots (local) and
121
156
  * returns `skipped` — never throws; callers must not hard-exit.
122
157
  */
123
- export function applyDeviceAutoToOptions(options, deps = {}) {
158
+ export async function applyDeviceAutoToOptions(options, deps = {}) {
124
159
  let deprecationSmart = false;
125
160
  if (options.smart) {
126
161
  const anyHost = HOST_SLOTS.some((k) => typeof options[k] === 'string' && options[k].trim() !== '');
@@ -133,7 +168,8 @@ export function applyDeviceAutoToOptions(options, deps = {}) {
133
168
  return { attempted: false, deprecationSmart };
134
169
  }
135
170
  try {
136
- const plan = (deps.resolve ?? (() => resolveDeviceAffinity({})))();
171
+ const resolve = deps.resolve ?? (() => resolveDeviceAuto(deps.agent));
172
+ const plan = await resolve();
137
173
  const concrete = plan.host; // null = local
138
174
  for (const k of HOST_SLOTS) {
139
175
  if (isDeviceAuto(options[k])) {
@@ -145,9 +181,9 @@ export function applyDeviceAutoToOptions(options, deps = {}) {
145
181
  options.balanced = true;
146
182
  }
147
183
  const hostLabel = concrete ?? 'local';
148
- const deviceHint = plan.deviceCandidates
184
+ const deviceHint = plan.candidates
149
185
  .slice(0, 4)
150
- .map((c) => `${c.key}:${c.launches}`)
186
+ .map((c) => `${c.key}:${c.loadPercent === undefined ? '?' : `${Math.round(c.loadPercent)}%`}`)
151
187
  .join(', ');
152
188
  const acctNote = accountPickerRequested ? 'accounts=picker' : 'accounts=balanced';
153
189
  return {
@@ -28,6 +28,31 @@ export declare function shellQuote(s: string): string;
28
28
  * long-lived `-N` tunnel can't leave a zombie ssh + socket pinned on the laptop.
29
29
  */
30
30
  export declare const SSH_OPTS: readonly string[];
31
+ /**
32
+ * Hard ceiling on the stdout one peer may return before its capture is aborted.
33
+ * A cross-machine fan-out streams every peer's `agents … --json` into memory in
34
+ * parallel, so an unbounded buffer means one runaway peer (a corrupt or
35
+ * pathologically large payload) can exhaust the caller's heap and take the whole
36
+ * sweep down with it — RUSH-2065 observed ~170MB retained across a single
37
+ * `--active` gather. 16 MiB is far above any legitimate JSON listing yet small
38
+ * enough that N peers in flight stay bounded. A peer that overflows is treated as
39
+ * unreachable rather than trusted with partial output.
40
+ */
41
+ export declare const REMOTE_STDOUT_MAX_BYTES: number;
42
+ /**
43
+ * Accumulate SSH stdout as UTF-8 without corrupting a multi-byte code point that
44
+ * a chunk boundary splits — the streaming `on('data')` callbacks hand us raw
45
+ * `Buffer`s, and a naive `buf.toString()` per chunk mangles any character the
46
+ * kernel cut in half. `StringDecoder` holds the trailing partial bytes until the
47
+ * next chunk completes them.
48
+ */
49
+ export declare class RemoteUtf8Accumulator {
50
+ private readonly decoder;
51
+ private value;
52
+ write(chunk: Buffer): void;
53
+ end(): string;
54
+ current(): string;
55
+ }
31
56
  export declare function controlOpts(): string[];
32
57
  /**
33
58
  * Compose an ssh connection-option prefix.
@@ -128,6 +153,8 @@ export interface SshStreamOptions {
128
153
  * interactive credential-copy path (RUSH-1767).
129
154
  */
130
155
  hostKeyOpts?: string[];
156
+ /** Additional OpenSSH argv placed before the target (for example `-i <path>`). */
157
+ extraSshArgs?: string[];
131
158
  }
132
159
  /**
133
160
  * Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
@@ -8,6 +8,7 @@
8
8
  * canonical definition; `commands/secrets.ts` re-exports it.
9
9
  */
10
10
  import { spawn, spawnSync } from 'child_process';
11
+ import { StringDecoder } from 'string_decoder';
11
12
  import * as fs from 'fs';
12
13
  import * as path from 'path';
13
14
  import { getCacheDir } from './state.js';
@@ -46,6 +47,38 @@ export const SSH_OPTS = [
46
47
  '-o', 'ServerAliveInterval=15',
47
48
  '-o', 'ServerAliveCountMax=3',
48
49
  ];
50
+ /**
51
+ * Hard ceiling on the stdout one peer may return before its capture is aborted.
52
+ * A cross-machine fan-out streams every peer's `agents … --json` into memory in
53
+ * parallel, so an unbounded buffer means one runaway peer (a corrupt or
54
+ * pathologically large payload) can exhaust the caller's heap and take the whole
55
+ * sweep down with it — RUSH-2065 observed ~170MB retained across a single
56
+ * `--active` gather. 16 MiB is far above any legitimate JSON listing yet small
57
+ * enough that N peers in flight stay bounded. A peer that overflows is treated as
58
+ * unreachable rather than trusted with partial output.
59
+ */
60
+ export const REMOTE_STDOUT_MAX_BYTES = 16 * 1024 * 1024;
61
+ /**
62
+ * Accumulate SSH stdout as UTF-8 without corrupting a multi-byte code point that
63
+ * a chunk boundary splits — the streaming `on('data')` callbacks hand us raw
64
+ * `Buffer`s, and a naive `buf.toString()` per chunk mangles any character the
65
+ * kernel cut in half. `StringDecoder` holds the trailing partial bytes until the
66
+ * next chunk completes them.
67
+ */
68
+ export class RemoteUtf8Accumulator {
69
+ decoder = new StringDecoder('utf8');
70
+ value = '';
71
+ write(chunk) {
72
+ this.value += this.decoder.write(chunk);
73
+ }
74
+ end() {
75
+ this.value += this.decoder.end();
76
+ return this.value;
77
+ }
78
+ current() {
79
+ return this.value;
80
+ }
81
+ }
49
82
  /**
50
83
  * OpenSSH connection-multiplexing options. The first connection to a host opens
51
84
  * a control socket; subsequent connections (even from a *separate* `agents`
@@ -292,7 +325,7 @@ export function sshStream(target, remoteCmd, opts = {}) {
292
325
  assertValidSshTarget(target);
293
326
  const mux = opts.multiplex === false ? [] : controlOpts();
294
327
  const tty = opts.tty ? ['-tt'] : [];
295
- const args = [...sshConnectOpts(mux, opts.hostKeyOpts), ...tty, target, remoteCmd];
328
+ const args = [...sshConnectOpts(mux, opts.hostKeyOpts), ...(opts.extraSshArgs ?? []), ...tty, target, remoteCmd];
296
329
  const res = spawnSync('ssh', args, { stdio: 'inherit' });
297
330
  if (typeof res.status === 'number')
298
331
  return res.status;
@@ -27,13 +27,14 @@ export interface StartTunnelOptions {
27
27
  * of one CDP session and kills it on cleanup.
28
28
  */
29
29
  detached?: boolean;
30
+ extraSshArgs?: string[];
30
31
  }
31
32
  /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure.
32
33
  *
33
34
  * Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
34
35
  * so the tunnel inherits the same options — crucially the keepalive, which lets
35
36
  * a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
36
- export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number): string[];
37
+ export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number, extraSshArgs?: string[]): string[];
37
38
  /**
38
39
  * Spawn `ssh -L localPort:127.0.0.1:remotePort -N user@host`.
39
40
  *
@@ -151,7 +152,7 @@ export declare function scpRemotePath(remotePath: string): string;
151
152
  * assert the real binary copy path keeps BatchMode and does not route bytes
152
153
  * through a PowerShell decoder.
153
154
  */
154
- export declare function buildScpArgs(target: string, remotePath: string, filePath: string): string[];
155
+ export declare function buildScpArgs(target: string, remotePath: string, filePath: string, extraSshArgs?: string[]): string[];
155
156
  /**
156
157
  * `setup --host`: push the exe, then register + start the LOGON task. Remote
157
158
  * PowerShell hops go through `sshExec` (BatchMode key auth — the same hardening
@@ -28,7 +28,7 @@ import { sshExec, SSH_OPTS, assertValidSshTarget } from './ssh-exec.js';
28
28
  import { backgroundSpawnOptions } from './platform/process.js';
29
29
  import { encodePowerShell } from './browser/drivers/ssh.js';
30
30
  import { getDevice } from './devices/registry.js';
31
- import { sshTargetFor } from './devices/connect.js';
31
+ import { deviceIdentityArgs, sshTargetFor } from './devices/connect.js';
32
32
  import { hostNameFor } from './devices/ssh-config.js';
33
33
  import { getCacheDir } from './state.js';
34
34
  import { getCliVersion } from './version.js';
@@ -38,12 +38,13 @@ import { openComputerClient, resolveTcpEndpoint } from './computer-rpc.js';
38
38
  * Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
39
39
  * so the tunnel inherits the same options — crucially the keepalive, which lets
40
40
  * a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
41
- export function buildTunnelArgs(user, host, localPort, remotePort) {
41
+ export function buildTunnelArgs(user, host, localPort, remotePort, extraSshArgs = []) {
42
42
  return [
43
43
  '-L',
44
44
  `${localPort}:127.0.0.1:${remotePort}`,
45
45
  `${user}@${host}`,
46
46
  '-N',
47
+ ...extraSshArgs,
47
48
  ...SSH_OPTS,
48
49
  ];
49
50
  }
@@ -69,7 +70,7 @@ export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
69
70
  reject(err);
70
71
  return;
71
72
  }
72
- const args = buildTunnelArgs(user, host, localPort, remotePort);
73
+ const args = buildTunnelArgs(user, host, localPort, remotePort, opts.extraSshArgs);
73
74
  const tunnel = spawn('ssh', args, {
74
75
  stdio: opts.detached ? 'ignore' : ['ignore', 'ignore', 'pipe'],
75
76
  ...(opts.detached ? backgroundSpawnOptions() : { detached: false, windowsHide: true }),
@@ -360,16 +361,16 @@ export function scpRemotePath(remotePath) {
360
361
  * assert the real binary copy path keeps BatchMode and does not route bytes
361
362
  * through a PowerShell decoder.
362
363
  */
363
- export function buildScpArgs(target, remotePath, filePath) {
364
- return [...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
364
+ export function buildScpArgs(target, remotePath, filePath, extraSshArgs = []) {
365
+ return [...extraSshArgs, ...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
365
366
  }
366
367
  /**
367
368
  * Copy a local file to the remote destination with scp. This is a binary
368
369
  * transfer; no base64 transform runs on either side.
369
370
  */
370
- function copyFileOverScp(target, remotePath, filePath, timeoutMs = 600_000) {
371
+ function copyFileOverScp(target, remotePath, filePath, extraSshArgs = [], timeoutMs = 600_000) {
371
372
  return new Promise((resolve, reject) => {
372
- const child = spawn('scp', buildScpArgs(target, remotePath, filePath), {
373
+ const child = spawn('scp', buildScpArgs(target, remotePath, filePath, extraSshArgs), {
373
374
  stdio: ['ignore', 'ignore', 'pipe'],
374
375
  });
375
376
  let stderr = '';
@@ -397,11 +398,12 @@ function copyFileOverScp(target, remotePath, filePath, timeoutMs = 600_000) {
397
398
  * scp transfer. Throws with the remote stderr on any failure.
398
399
  */
399
400
  export async function setupRemoteHelper(name) {
400
- const { target } = await resolveRemoteDevice(name);
401
+ const { device, target } = await resolveRemoteDevice(name);
402
+ const identityArgs = deviceIdentityArgs(device);
401
403
  // Local build output, else the checksum-verified GitHub release asset for
402
404
  // this CLI version. Throws naming the tag it checked when neither exists.
403
405
  const exe = await ensureWinHelperExe();
404
- const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000 });
406
+ const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000, extraSshArgs: identityArgs });
405
407
  if (prep.code !== 0) {
406
408
  throw new Error(`preparing helper exe path on '${name}' failed (exit ${prep.code ?? 'null'}): ${prep.stderr.trim() || prep.stdout.trim()}`);
407
409
  }
@@ -409,12 +411,12 @@ export async function setupRemoteHelper(name) {
409
411
  if (!remotePath) {
410
412
  throw new Error(`preparing helper exe path on '${name}' did not return a destination path`);
411
413
  }
412
- const push = await copyFileOverScp(target, remotePath, exe);
414
+ const push = await copyFileOverScp(target, remotePath, exe, identityArgs);
413
415
  if (push.code !== 0) {
414
416
  throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
415
417
  }
416
418
  const expectedBytes = fs.statSync(exe).size;
417
- const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000 });
419
+ const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
418
420
  if (verify.code !== 0) {
419
421
  throw new Error(`verifying helper exe on '${name}' failed (exit ${verify.code ?? 'null'}): ${verify.stderr.trim() || verify.stdout.trim()}`);
420
422
  }
@@ -423,7 +425,7 @@ export async function setupRemoteHelper(name) {
423
425
  // refuses to start without a token, so a token-less (open-to-any-local-process)
424
426
  // daemon can no longer be stood up through the CLI.
425
427
  const token = generateToken();
426
- const tokWrite = sshExec(target, encodePowerShell(buildWriteTokenScript(token)), { timeoutMs: 60_000 });
428
+ const tokWrite = sshExec(target, encodePowerShell(buildWriteTokenScript(token)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
427
429
  if (tokWrite.code !== 0) {
428
430
  throw new Error(`writing helper token on '${name}' failed (exit ${tokWrite.code ?? 'null'}): ${tokWrite.stderr.trim() || tokWrite.stdout.trim()}`);
429
431
  }
@@ -434,6 +436,7 @@ export async function setupRemoteHelper(name) {
434
436
  // Register + start the LOGON task, pointing it at the token file.
435
437
  const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME, remoteTokenPath)), {
436
438
  timeoutMs: 60_000,
439
+ extraSshArgs: identityArgs,
437
440
  });
438
441
  if (reg.code !== 0) {
439
442
  throw new Error(`registering scheduled task on '${name}' failed (exit ${reg.code ?? 'null'}): ${reg.stderr.trim() || reg.stdout.trim()}`);
@@ -460,10 +463,13 @@ export function pickFreePort() {
460
463
  * the state (and leaves the tunnel running in the background).
461
464
  */
462
465
  export async function startRemoteTunnel(name) {
463
- const { target, user, host } = await resolveRemoteDevice(name);
466
+ const { device, target, user, host } = await resolveRemoteDevice(name);
464
467
  const remotePort = REMOTE_HELPER_PORT;
465
468
  const localPort = await pickFreePort();
466
- const tunnel = await startSSHTunnel(user, host, localPort, remotePort, { detached: true });
469
+ const tunnel = await startSSHTunnel(user, host, localPort, remotePort, {
470
+ detached: true,
471
+ extraSshArgs: deviceIdentityArgs(device),
472
+ });
467
473
  const tunnelPid = tunnel.pid ?? 0;
468
474
  // Verify the daemon answers through the tunnel before we record it. This is
469
475
  // the real end-to-end check: tunnel up + daemon listening + RPC round-trips.
@@ -543,8 +549,11 @@ export async function stopRemoteHelper(name) {
543
549
  }
544
550
  let taskRemoved = false;
545
551
  try {
546
- const { target } = await resolveRemoteDevice(name);
547
- const res = sshExec(target, encodePowerShell(buildUnregisterTaskScript(REMOTE_TASK_NAME)), { timeoutMs: 60_000 });
552
+ const { device, target } = await resolveRemoteDevice(name);
553
+ const res = sshExec(target, encodePowerShell(buildUnregisterTaskScript(REMOTE_TASK_NAME)), {
554
+ timeoutMs: 60_000,
555
+ extraSshArgs: deviceIdentityArgs(device),
556
+ });
548
557
  taskRemoved = res.code === 0;
549
558
  }
550
559
  catch {
@@ -76,6 +76,7 @@ export declare const loadUsage: ModuleLoader;
76
76
  export declare const loadCost: ModuleLoader;
77
77
  export declare const loadInsights: ModuleLoader;
78
78
  export declare const loadPerf: ModuleLoader;
79
+ export declare const loadBench: ModuleLoader;
79
80
  export declare const loadTrends: ModuleLoader;
80
81
  export declare const loadOutput: ModuleLoader;
81
82
  export declare const loadBudget: ModuleLoader;
@@ -54,6 +54,7 @@ export const loadUsage = async () => (await import('../../commands/usage.js')).r
54
54
  export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
55
55
  export const loadInsights = async () => (await import('../../commands/insights.js')).registerInsightsCommand;
56
56
  export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
57
+ export const loadBench = async () => (await import('../../commands/bench.js')).registerBenchCommand;
57
58
  // Thin deprecated alias of `agents insights mix` — no second mix implementation.
58
59
  export const loadTrends = async () => (await import('../../commands/trends.js')).registerTrendsCommand;
59
60
  export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
@@ -191,6 +192,7 @@ export const COMMAND_LOADERS = {
191
192
  cost: [loadCost],
192
193
  insights: [loadInsights],
193
194
  perf: [loadPerf],
195
+ bench: [loadBench],
194
196
  trends: [loadTrends],
195
197
  output: [loadOutput],
196
198
  budget: [loadBudget],
@@ -15,7 +15,7 @@ export declare function clearPlacementSignalCache(): void;
15
15
  * excludes nor prefers them). Never throws — a probe failure degrades to a
16
16
  * missing/partial signal.
17
17
  */
18
- export declare function probePoolSignals(pool: string[], agent: AgentType, opts?: {
18
+ export declare function probePoolSignals(pool: string[], agent?: AgentType, opts?: {
19
19
  force?: boolean;
20
20
  now?: number;
21
21
  }): Promise<Map<string, DevicePlacementSignal>>;
@@ -7,26 +7,25 @@
7
7
  *
8
8
  * - reachability + headroom + load ← {@link probeFleetStats} (one parallel
9
9
  * SSH fan-out over the pool; the local box is measured directly).
10
- * - requested agent installed (+ signed in, locally) ← a one-shot readiness
10
+ * - requested agent installed + signed in (when known) ← a one-shot readiness
11
11
  * probe per remote device ({@link buildReadyProbeCommand} → `agents view`),
12
12
  * the local box via {@link checkCliAvailable}/{@link checkCliSignedIn}.
13
13
  *
14
- * The result is cached briefly per (pool, agent) so a `teams start` wave that
14
+ * The result is cached briefly per (pool, agent-or-any) so a `teams start` wave that
15
15
  * places N teammates probes the pool ONCE, not N times — the roster-count part
16
16
  * of the rank stays live (the pure pick recounts the roster each call), only the
17
17
  * SSH-measured load/harness snapshot is reused within the TTL.
18
18
  *
19
19
  * All SSH here is via `execFile` (async, bounded) so the whole pool is probed in
20
20
  * parallel; a slow or wedged box degrades to "no signal" instead of blocking the
21
- * launch. Remote sign-in is deliberately left unknown sign-in detection is
22
- * unreliable over SSH (see {@link checkCliSignedIn}); it ranks the local box
23
- * only, and the install gate (not sign-in) is what drives the fail-loud.
21
+ * launch. The readiness payload is `agents view --json`, so remote and local
22
+ * candidates carry the same installed/sign-in verdict.
24
23
  */
25
24
  import { execFile } from 'child_process';
26
25
  import { probeFleetStats, headroom } from '../devices/health.js';
27
26
  import { loadDevicesSync } from '../devices/registry.js';
28
27
  import { buildSshInvocation, writeAskpassShim } from '../devices/connect.js';
29
- import { buildReadyProbeCommand, parseReadyProbe, viewHasAgent } from '../hosts/ready.js';
28
+ import { buildReadyProbeCommand, parseReadyProbe, viewAgentSignedIn, viewHasAgent, } from '../hosts/ready.js';
30
29
  import { localMachineId } from '../session/origin-machine.js';
31
30
  import { normalizeHost } from '../machine-id.js';
32
31
  import { checkCliAvailable, checkCliSignedIn } from './agents.js';
@@ -39,7 +38,7 @@ export const READY_PROBE_TIMEOUT_MS = 8_000;
39
38
  export const SIGNAL_TTL_MS = 15_000;
40
39
  const cache = new Map();
41
40
  function cacheKey(pool, agent) {
42
- return `${agent}::${[...pool].map(normalizeHost).sort().join(',')}`;
41
+ return `${agent ?? 'any-agent'}::${[...pool].map(normalizeHost).sort().join(',')}`;
43
42
  }
44
43
  /** Clear the probe cache — for tests and after a device-registry change. */
45
44
  export function clearPlacementSignalCache() {
@@ -51,7 +50,7 @@ export function clearPlacementSignalCache() {
51
50
  * failure) — reachability is then left to {@link probeFleetStats}; `false` when
52
51
  * the box answered but agents-cli or the agent is absent (a genuine can't-run).
53
52
  */
54
- function probeRemoteInstalled(device, agent) {
53
+ function probeRemoteReadiness(device, agent) {
55
54
  let args;
56
55
  let env;
57
56
  try {
@@ -62,18 +61,19 @@ function probeRemoteInstalled(device, agent) {
62
61
  ({ args, env } = buildSshInvocation(device, [cmd], shim, {}, { agentOnly: true }));
63
62
  }
64
63
  catch {
65
- return Promise.resolve(undefined);
64
+ return Promise.resolve({ installed: undefined, signedIn: undefined });
66
65
  }
67
66
  return new Promise((resolve) => {
68
67
  execFile('ssh', args, { encoding: 'utf-8', env: { ...process.env, ...env }, timeout: READY_PROBE_TIMEOUT_MS }, (err, stdout) => {
69
68
  if (err || !stdout)
70
- return resolve(undefined);
69
+ return resolve({ installed: undefined, signedIn: undefined });
71
70
  const probe = parseReadyProbe(stdout);
72
71
  if (!probe.reachable)
73
- return resolve(undefined); // ssh/login issue unknown
72
+ return resolve({ installed: undefined, signedIn: undefined });
74
73
  if (!probe.version)
75
- return resolve(false); // agents-cli missing cannot run
76
- resolve(viewHasAgent(probe.view, agent));
74
+ return resolve({ installed: false, signedIn: false });
75
+ const installed = viewHasAgent(probe.view, agent);
76
+ resolve({ installed, signedIn: installed ? viewAgentSignedIn(probe.view, agent) : false });
77
77
  });
78
78
  });
79
79
  }
@@ -103,17 +103,17 @@ export async function probePoolSignals(pool, agent, opts = {}) {
103
103
  }
104
104
  const selfProfile = profiles.find((d) => normalizeHost(d.name) === self);
105
105
  const stats = await probeFleetStats(profiles, { selfName: selfProfile?.name });
106
- const installed = new Map(await Promise.all(profiles.map(async (d) => {
107
- const isSelf = normalizeHost(d.name) === self;
108
- if (isSelf) {
109
- const inst = checkCliAvailable(agent)[0];
110
- const signedIn = inst ? await checkCliSignedIn(agent) : undefined;
111
- return [d.name, { installed: inst, signedIn }];
112
- }
113
- // Remote sign-in stays unknown (see the module docblock); the install
114
- // gate is what drives the fail-loud.
115
- return [d.name, { installed: await probeRemoteInstalled(d, agent), signedIn: undefined }];
116
- })));
106
+ const installed = new Map(agent
107
+ ? await Promise.all(profiles.map(async (d) => {
108
+ const isSelf = normalizeHost(d.name) === self;
109
+ if (isSelf) {
110
+ const inst = checkCliAvailable(agent)[0];
111
+ const signedIn = inst ? await checkCliSignedIn(agent) : undefined;
112
+ return [d.name, { installed: inst, signedIn }];
113
+ }
114
+ return [d.name, await probeRemoteReadiness(d, agent)];
115
+ }))
116
+ : []);
117
117
  const signals = new Map();
118
118
  for (const name of pool) {
119
119
  const d = lookup(name);
@@ -179,6 +179,14 @@ export declare const AGENT_HOOK_SCHEMA = 5;
179
179
  export declare function agentPaneDiedHook(sessionName: string, agentPane: string): string;
180
180
  /** Stamp a session's hook-schema marker to the current version. */
181
181
  export declare function markSessionHookSchema(name: string, socket?: string): Promise<void>;
182
+ /**
183
+ * Decide whether a native resume may attach an existing managed tmux session.
184
+ * Only a positively resolved, living agent pane is reusable. Metadata-less
185
+ * legacy sessions and stale metadata resolve their real first pane through
186
+ * tmux before the decision; a dead or unreadable session is reaped so the
187
+ * caller can create a fresh wrapper for the resumed harness.
188
+ */
189
+ export declare function prepareSessionForResume(name: string, socket?: string): Promise<'attach' | 'create'>;
182
190
  /**
183
191
  * Retrofit the current guarded `pane-died` hook onto every managed `agents run`
184
192
  * session whose hook predates AGENT_HOOK_SCHEMA. Idempotent and NON-DESTRUCTIVE:
@@ -359,6 +359,28 @@ async function lowestPaneId(name, socket) {
359
359
  return undefined;
360
360
  return ids.reduce((lo, id) => (parseInt(id.slice(1), 10) < parseInt(lo.slice(1), 10) ? id : lo));
361
361
  }
362
+ /**
363
+ * Decide whether a native resume may attach an existing managed tmux session.
364
+ * Only a positively resolved, living agent pane is reusable. Metadata-less
365
+ * legacy sessions and stale metadata resolve their real first pane through
366
+ * tmux before the decision; a dead or unreadable session is reaped so the
367
+ * caller can create a fresh wrapper for the resumed harness.
368
+ */
369
+ export async function prepareSessionForResume(name, socket) {
370
+ const sock = socket ?? getDefaultSocketPath();
371
+ if (!(await hasSession(name, sock)))
372
+ return 'create';
373
+ const recordedPane = readSessionMeta(name)?.pane;
374
+ const recordedState = recordedPane ? await paneExitStatus(recordedPane, sock) : undefined;
375
+ const pane = recordedPane && recordedState?.found
376
+ ? recordedPane
377
+ : await lowestPaneId(name, sock);
378
+ const state = pane ? await paneExitStatus(pane, sock) : undefined;
379
+ if (pane && state?.found && !state.dead)
380
+ return 'attach';
381
+ await killSession(name, sock);
382
+ return 'create';
383
+ }
362
384
  /**
363
385
  * Retrofit the current guarded `pane-died` hook onto every managed `agents run`
364
386
  * session whose hook predates AGENT_HOOK_SCHEMA. Idempotent and NON-DESTRUCTIVE:
@@ -1064,6 +1064,8 @@ export interface HostEntry {
1064
1064
  address?: string;
1065
1065
  /** SSH user — inline hosts only. */
1066
1066
  user?: string;
1067
+ /** Explicit private key inherited from a fleet device profile. */
1068
+ identityFile?: string;
1067
1069
  /** Captured at enroll probe. */
1068
1070
  os?: string;
1069
1071
  /** Free-form capability tags for routing (e.g. ['gpu']). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.26",
3
+ "version": "1.22.27",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",