@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
package/dist/lib/hooks.js CHANGED
@@ -1160,17 +1160,21 @@ export function listCentralHooks() {
1160
1160
  }
1161
1161
  return results;
1162
1162
  }
1163
+ const MAX_HOOK_DURATION_SECONDS = 24 * 60 * 60;
1163
1164
  /**
1164
1165
  * Normalize a hook `timeout` from agents.yaml into a whole number of seconds.
1165
1166
  *
1166
1167
  * A bare number stays seconds (`timeout: 30` → 30) for backward compatibility.
1167
1168
  * A Go-style duration string is parsed into seconds: `5s`, `2m`, `1h30m`,
1168
- * `90s`, `1h`. This intentionally does NOT reuse {@link parseTimeout} from
1169
- * routines.ts that one returns milliseconds, has no seconds (`s`) unit, and
1170
- * floors at one minute, none of which fit hook timeouts (typically 5–600s).
1169
+ * `90s`, `1h`. Suffixed durations longer than 24 hours are rejected as likely
1170
+ * typos, while bare seconds stay uncapped for backward compatibility. This
1171
+ * intentionally does NOT reuse {@link parseTimeout} from routines.ts that one
1172
+ * returns milliseconds, has no seconds (`s`) unit, and floors at one minute,
1173
+ * none of which fit hook timeouts (typically 5–600s).
1171
1174
  *
1172
- * Returns the seconds value, or `null` when the input is not a positive number
1173
- * or a parseable duration string — the caller decides how to surface that.
1175
+ * Returns the seconds value, or `null` when the input is not a positive number,
1176
+ * is not parseable, or is a suffixed duration longer than 24 hours — the caller
1177
+ * decides how to surface that.
1174
1178
  */
1175
1179
  export function normalizeHookTimeoutSeconds(value) {
1176
1180
  if (typeof value === 'number') {
@@ -1194,7 +1198,7 @@ export function normalizeHookTimeoutSeconds(value) {
1194
1198
  const minutes = Number(m[4] || 0);
1195
1199
  const seconds = Number(m[5] || 0);
1196
1200
  const total = ((weeks * 7 + days) * 24 + hours) * 3600 + minutes * 60 + seconds;
1197
- return total > 0 ? total : null;
1201
+ return total > 0 && total <= MAX_HOOK_DURATION_SECONDS ? total : null;
1198
1202
  }
1199
1203
  return null;
1200
1204
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { randomUUID } from 'crypto';
12
12
  import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
13
- import { sshTargetFor } from './types.js';
13
+ import { hostIdentityArgs, sshTargetFor } from './types.js';
14
14
  import { ensureHostReady } from './ready.js';
15
15
  import { remoteShellFor, posixEnvExports } from './remote-cmd.js';
16
16
  import { resolveRemoteOsSync } from './remote-os.js';
@@ -156,7 +156,7 @@ function terminateRemoteLaunch(task) {
156
156
  `sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
157
157
  `elif kill -0 -- -${pid} 2>/dev/null; then exit 1; fi; ` +
158
158
  `rm -f ${task.remoteLog} ${task.remoteExit}`;
159
- const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
159
+ const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true, extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined });
160
160
  if (result.code !== 0) {
161
161
  throw new Error(`Failed to terminate remote task ${task.id} on ${task.host}: ` +
162
162
  `${(result.stderr || result.stdout).trim() || 'ssh error'}`);
@@ -210,7 +210,7 @@ export function stopDispatchedTask(task) {
210
210
  throw new Error(`Cannot stop remote task ${task.id}: launch returned no PID.`);
211
211
  }
212
212
  const command = buildStopRemoteCommand(task.pid, task.remoteExit);
213
- const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
213
+ const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true, extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined });
214
214
  if (result.code !== 0) {
215
215
  throw new Error(`Failed to stop remote task ${task.id} on ${task.host}: ` +
216
216
  `${(result.stderr || result.stdout).trim() || 'ssh error'}`);
@@ -271,6 +271,7 @@ async function launchDetached(host, target, opts) {
271
271
  timeoutMs: 30000,
272
272
  multiplex: !opts.copyCreds,
273
273
  hostKeyOpts: credHostKeyOpts,
274
+ extraSshArgs: hostIdentityArgs(host),
274
275
  });
275
276
  if (res.code !== 0) {
276
277
  throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
@@ -280,6 +281,7 @@ async function launchDetached(host, target, opts) {
280
281
  id,
281
282
  host: host.name,
282
283
  target,
284
+ identityFile: host.identityFile,
283
285
  agent: opts.agentLabel,
284
286
  prompt: opts.promptLabel,
285
287
  pid: Number.isFinite(pid) ? pid : undefined,
@@ -311,6 +313,7 @@ async function launchDetached(host, target, opts) {
311
313
  taskId: id,
312
314
  echo: true,
313
315
  timeoutMs: opts.timeoutMs,
316
+ extraSshArgs: hostIdentityArgs(host),
314
317
  });
315
318
  // -1 = the follow window closed while the run continues on the host. Leave the
316
319
  // record 'running' (do NOT freeze it terminal) so a later `hosts ps`/`logs`
@@ -464,6 +467,7 @@ export async function runInteractiveOnHost(host, opts) {
464
467
  tty: process.stdin.isTTY,
465
468
  multiplex: !opts.copyCreds,
466
469
  hostKeyOpts: credHostKeyOpts,
470
+ extraSshArgs: hostIdentityArgs(host),
467
471
  });
468
472
  }
469
473
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
@@ -24,6 +24,28 @@ interface RemoteSpec {
24
24
  /** Flags appended when running non-interactively (no local TTY / `--no-tty`). */
25
25
  nonInteractive?: string[];
26
26
  }
27
+ /**
28
+ * First-class groups that run transparently on a remote via SSH when
29
+ * `--host`/`--device` is present. Keep both canonical names and aliases
30
+ * (`repo`/`repos`, `exec`/`run`) so either argv form routes the same way.
31
+ *
32
+ * Prefer adding here over per-command SSH code — this is the single choke point.
33
+ *
34
+ * Every key MUST be a real top-level command (a `KNOWN_TOP_LEVEL_COMMANDS`
35
+ * member) — `passthrough.test.ts` asserts it. A key that is not one is dead:
36
+ * the gate in {@link maybeRunOnHost} rejects the name as unknown before this
37
+ * table is consulted, and before that gate existed it SSH'd a command the peer
38
+ * would also reject. `cli`/`packages`/`versions`/`daemon` were exactly that
39
+ * (the commands are `clis`, `registry`/`search`/`install`/`publish`,
40
+ * `add`/`use`/`list`, and none) and were removed.
41
+ */
42
+ export declare const REMOTE_PASSTHROUGH: Record<string, RemoteSpec>;
43
+ /**
44
+ * Commands that register and interpret `--host`/`--device` themselves — must
45
+ * fall through to local commander even when the flag is present. Do not add
46
+ * these to {@link REMOTE_PASSTHROUGH}.
47
+ */
48
+ export declare const OWN_HOST_COMMANDS: Set<string>;
27
49
  /** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
28
50
  export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
29
51
  /** Injectable dependencies for {@link runFleetPassthrough} — used by tests. */
@@ -21,7 +21,7 @@ import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
21
21
  import { resolveHost, resolveHostByCap } from './registry.js';
22
22
  import { sshTargetFor } from './types.js';
23
23
  import { dispatchAgentsCommand, withActorEnv } from './dispatch.js';
24
- import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
24
+ import { stripRoutingFlags, buildRemoteAgentsInvocation, stripClixml, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
25
  import { resolveRemoteOsSync } from './remote-os.js';
26
26
  import { machineId } from '../session/sync/config.js';
27
27
  import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
@@ -36,8 +36,16 @@ import { isKnownTopLevelCommand } from '../startup/command-registry.js';
36
36
  * (`repo`/`repos`, `exec`/`run`) so either argv form routes the same way.
37
37
  *
38
38
  * Prefer adding here over per-command SSH code — this is the single choke point.
39
+ *
40
+ * Every key MUST be a real top-level command (a `KNOWN_TOP_LEVEL_COMMANDS`
41
+ * member) — `passthrough.test.ts` asserts it. A key that is not one is dead:
42
+ * the gate in {@link maybeRunOnHost} rejects the name as unknown before this
43
+ * table is consulted, and before that gate existed it SSH'd a command the peer
44
+ * would also reject. `cli`/`packages`/`versions`/`daemon` were exactly that
45
+ * (the commands are `clis`, `registry`/`search`/`install`/`publish`,
46
+ * `add`/`use`/`list`, and none) and were removed.
39
47
  */
40
- const REMOTE_PASSTHROUGH = {
48
+ export const REMOTE_PASSTHROUGH = {
41
49
  // status / inspect
42
50
  view: {},
43
51
  inspect: {},
@@ -65,10 +73,8 @@ const REMOTE_PASSTHROUGH = {
65
73
  permissions: {},
66
74
  perms: {},
67
75
  mcp: {},
68
- cli: {},
69
76
  subagents: {},
70
77
  workflows: {},
71
- packages: {},
72
78
  models: {},
73
79
  profiles: {},
74
80
  defaults: {},
@@ -89,20 +95,18 @@ const REMOTE_PASSTHROUGH = {
89
95
  lock: {},
90
96
  feedback: {},
91
97
  wallet: {},
92
- daemon: {},
93
98
  pty: {},
94
99
  tmux: {},
95
100
  watchdog: {},
96
101
  factory: {},
97
102
  browser: {},
98
- versions: {},
99
103
  };
100
104
  /**
101
105
  * Commands that register and interpret `--host`/`--device` themselves — must
102
106
  * fall through to local commander even when the flag is present. Do not add
103
107
  * these to {@link REMOTE_PASSTHROUGH}.
104
108
  */
105
- const OWN_HOST_COMMANDS = new Set([
109
+ export const OWN_HOST_COMMANDS = new Set([
106
110
  'run',
107
111
  'exec', // deprecated alias of run
108
112
  'harness', // `--host <agent>` names the host CLI to run under, not a remote device
@@ -186,7 +190,9 @@ function buildFleetForwardedArgs(allArgs) {
186
190
  /** Parse stdout as JSON; on failure return an object describing the error. */
187
191
  function safeJsonParse(stdout) {
188
192
  try {
189
- return JSON.parse(stdout);
193
+ // A Windows device relays its `--json` through PowerShell, which can prefix a
194
+ // CLIXML banner ahead of the payload — strip it before parsing (RUSH-2286).
195
+ return JSON.parse(stripClixml(stdout));
190
196
  }
191
197
  catch {
192
198
  return { parseError: 'invalid JSON', snippet: stdout.trim().slice(0, 200) };
@@ -55,6 +55,7 @@ export interface FollowOptions {
55
55
  pollMs?: number;
56
56
  /** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
57
57
  maxPollMs?: number;
58
+ extraSshArgs?: string[];
58
59
  }
59
60
  /**
60
61
  * Build the per-task sentinel that separates the log tail from the exit-file
@@ -124,7 +125,7 @@ export declare function parseStreamingExitFrame(stderr: Buffer, taskId: string):
124
125
  * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
125
126
  * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
126
127
  */
127
- export declare function readRemoteFileId(target: string, remotePath: string): string | null;
128
+ export declare function readRemoteFileId(target: string, remotePath: string, extraSshArgs?: string[]): string | null;
128
129
  /**
129
130
  * True when the local mirror file IS the very file we're tailing — the
130
131
  * localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
@@ -160,8 +160,8 @@ export function parseStreamingExitFrame(stderr, taskId) {
160
160
  * File identity (`dev:ino`) of a path on the remote host, or null if it can't be
161
161
  * stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
162
162
  */
163
- export function readRemoteFileId(target, remotePath) {
164
- const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000 });
163
+ export function readRemoteFileId(target, remotePath, extraSshArgs) {
164
+ const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000, extraSshArgs });
165
165
  const id = res.stdout.trim();
166
166
  return id || null;
167
167
  }
@@ -189,7 +189,7 @@ export async function followHostTask(target, opts) {
189
189
  let mirror = true;
190
190
  try {
191
191
  const s = fs.statSync(local);
192
- if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog))) {
192
+ if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog, opts.extraSshArgs))) {
193
193
  mirror = false;
194
194
  }
195
195
  }
@@ -223,6 +223,7 @@ export async function followHostTask(target, opts) {
223
223
  timeoutMs: remaining,
224
224
  signal: abort.signal,
225
225
  multiplex: true,
226
+ extraSshArgs: opts.extraSshArgs,
226
227
  onStdout: (chunk) => { gotOutput = flush(chunk) || gotOutput; },
227
228
  });
228
229
  exitFrame = parseStreamingExitFrame(stream.stderr, opts.taskId);
@@ -48,6 +48,7 @@ function deviceToPoolHost(device) {
48
48
  source: 'inline',
49
49
  address,
50
50
  user: device.user,
51
+ identityFile: device.auth.identityFile,
51
52
  ...(device.platform !== 'unknown' ? { os: device.platform } : {}),
52
53
  enrolled: true,
53
54
  status: statusOf(device),
@@ -31,7 +31,7 @@ export declare function remoteAgentsVersion(target: string, os?: string): string
31
31
  * equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
32
32
  export declare function buildBootstrapCommand(spec: string, os?: string): string;
33
33
  /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
34
- export declare function bootstrapAgentsCli(target: string, version: string | null, os?: string): {
34
+ export declare function bootstrapAgentsCli(target: string, version: string | null, os?: string, extraSshArgs?: string[]): {
35
35
  ok: boolean;
36
36
  output: string;
37
37
  };
@@ -61,11 +61,13 @@ export interface ReadyProbe {
61
61
  * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
62
62
  */
63
63
  export declare function buildReadyProbeCommand(os?: string): string;
64
- export declare function readyProbe(target: string, os?: string): ReadyProbe;
64
+ export declare function readyProbe(target: string, os?: string, extraSshArgs?: string[]): ReadyProbe;
65
65
  /** Pure parser for `readyProbe` output (unit-tested without ssh). */
66
66
  export declare function parseReadyProbe(stdout: string): ReadyProbe;
67
67
  /** True if `view` output lists the named agent (word-boundary, case-insensitive). */
68
68
  export declare function viewHasAgent(view: string, agent: string): boolean;
69
+ /** Read the requested harness's sign-in verdict from `agents view --json`. */
70
+ export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
69
71
  export interface EnsureReadyOptions {
70
72
  agent: string;
71
73
  /** Throw instead of warn when the agent isn't installed remotely. */
@@ -9,7 +9,7 @@ import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
11
  import { sshExec, shellQuote } from '../ssh-exec.js';
12
- import { sshTargetFor } from './types.js';
12
+ import { hostIdentityArgs, sshTargetFor } from './types.js';
13
13
  import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './remote-cmd.js';
14
14
  import { resolveRemoteOsSync } from './remote-os.js';
15
15
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
@@ -87,9 +87,9 @@ export function buildBootstrapCommand(spec, os) {
87
87
  return `bash -lc ${shellQuote(script)}`;
88
88
  }
89
89
  /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
90
- export function bootstrapAgentsCli(target, version, os) {
90
+ export function bootstrapAgentsCli(target, version, os, extraSshArgs) {
91
91
  const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
92
- const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000 });
92
+ const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000, extraSshArgs });
93
93
  return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() };
94
94
  }
95
95
  /** Sentinel splitting the version output from the agent listing in one probe. */
@@ -113,17 +113,17 @@ export function buildReadyProbeCommand(os) {
113
113
  if (remoteShellFor(os) === 'powershell') {
114
114
  const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
115
115
  `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
116
- `agents view 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
116
+ `agents view --json 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
117
117
  return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
118
118
  }
119
119
  const script = `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
120
- `agents view 2>/dev/null || agents list 2>/dev/null`;
120
+ `agents view --json 2>/dev/null || agents list 2>/dev/null`;
121
121
  return `bash -lc ${shellQuote(script)}`;
122
122
  }
123
- export function readyProbe(target, os) {
123
+ export function readyProbe(target, os, extraSshArgs) {
124
124
  // Disable multiplexing: a stale control socket can hang the local ssh client
125
125
  // until the timeout fires, just like sshExecAsync does for the same reason.
126
- const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000, multiplex: false });
126
+ const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000, multiplex: false, extraSshArgs });
127
127
  if (r.timedOut)
128
128
  return { reachable: false, version: null, view: '', timedOut: true };
129
129
  return parseReadyProbe(r.stdout);
@@ -140,6 +140,22 @@ export function parseReadyProbe(stdout) {
140
140
  export function viewHasAgent(view, agent) {
141
141
  return new RegExp(`\\b${agent}\\b`, 'i').test(view);
142
142
  }
143
+ /** Read the requested harness's sign-in verdict from `agents view --json`. */
144
+ export function viewAgentSignedIn(view, agent) {
145
+ try {
146
+ const rows = JSON.parse(view);
147
+ const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
148
+ if (!row)
149
+ return undefined;
150
+ const verdicts = (row.versions ?? [])
151
+ .map((version) => version.signedIn)
152
+ .filter((value) => typeof value === 'boolean');
153
+ return verdicts.length === 0 ? undefined : verdicts.some(Boolean);
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ }
143
159
  /**
144
160
  * Verify a host can run the agent: reachable + agents-cli present. Throws with an
145
161
  * actionable message otherwise. Agent-not-installed is a warning by default (the
@@ -149,7 +165,7 @@ export function viewHasAgent(view, agent) {
149
165
  */
150
166
  export function ensureHostReady(host, opts) {
151
167
  const target = sshTargetFor(host);
152
- const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name));
168
+ const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name), hostIdentityArgs(host));
153
169
  if (probe.timedOut) {
154
170
  throw new Error(`Host "${host.name}" (${target}) did not respond in time — the SSH probe timed out after 20 seconds. ` +
155
171
  `The host may be slow to start a login shell (nvm/sdkman init, cold node startup). ` +
@@ -35,7 +35,7 @@ export declare function classifyExit(res: Pick<SshExecResult, 'code' | 'stdout'
35
35
  * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
36
  * remote shell expands $HOME (same contract as progress.ts's fetch).
37
37
  */
38
- export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number): RemoteExitState;
38
+ export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number, identityFile?: string): RemoteExitState;
39
39
  /**
40
40
  * Heal one record. Terminal records are immutable (and never re-probed); a
41
41
  * `running` record is resolved to completed/failed only when the remote `.exit`
@@ -10,7 +10,7 @@
10
10
  * record. We only ever CONFIRM completion; an unreachable host or an absent
11
11
  * `.exit` leaves the record `running` (we never guess failure).
12
12
  */
13
- import { sshExec, sshReachable } from '../ssh-exec.js';
13
+ import { sshExec } from '../ssh-exec.js';
14
14
  import { updateTask, terminalPatch } from './tasks.js';
15
15
  /**
16
16
  * Classify a `cat <remoteExit>` result into a remote run state. Pure: all the
@@ -35,8 +35,12 @@ export function classifyExit(res) {
35
35
  * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
36
  * remote shell expands $HOME (same contract as progress.ts's fetch).
37
37
  */
38
- export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
39
- return classifyExit(sshExec(target, `cat ${remoteExit} 2>/dev/null`, { timeoutMs, multiplex: true }));
38
+ export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFile) {
39
+ return classifyExit(sshExec(target, `cat ${remoteExit} 2>/dev/null`, {
40
+ timeoutMs,
41
+ multiplex: true,
42
+ extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
43
+ }));
40
44
  }
41
45
  /**
42
46
  * Heal one record. Terminal records are immutable (and never re-probed); a
@@ -46,7 +50,7 @@ export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
46
50
  export function reconcileTask(task) {
47
51
  if (task.status !== 'running')
48
52
  return task;
49
- const st = readRemoteExit(task.target, task.remoteExit);
53
+ const st = readRemoteExit(task.target, task.remoteExit, 6000, task.identityFile);
50
54
  if (st.state !== 'done')
51
55
  return task;
52
56
  return updateTask(task.id, terminalPatch(st.code)) ?? task;
@@ -66,11 +70,16 @@ export function reconcileRunningTasks(tasks) {
66
70
  const reachable = new Map();
67
71
  const patched = new Map();
68
72
  for (const t of running) {
69
- if (!reachable.has(t.target))
70
- reachable.set(t.target, sshReachable(t.target, 6000));
73
+ if (!reachable.has(t.target)) {
74
+ const probe = sshExec(t.target, 'true', {
75
+ timeoutMs: 6000,
76
+ extraSshArgs: t.identityFile ? ['-i', t.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
77
+ });
78
+ reachable.set(t.target, probe.code === 0);
79
+ }
71
80
  if (!reachable.get(t.target))
72
81
  continue; // host down → leave running
73
- const st = readRemoteExit(t.target, t.remoteExit);
82
+ const st = readRemoteExit(t.target, t.remoteExit, 6000, t.identityFile);
74
83
  if (st.state === 'done') {
75
84
  const updated = updateTask(t.id, terminalPatch(st.code));
76
85
  if (updated)
@@ -6,13 +6,23 @@ export declare const SSH_CONN_FAILURE = 255;
6
6
  * {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
7
7
  * transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
8
8
  export declare const REMOTE_EXIT_255_REMAPPED = 254;
9
- /** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
10
- * {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
11
- * refills the budget, so a long session that blinks all day reconnects every time
12
- * only consecutive UNREACHABLE attempts exhaust it. */
9
+ /** Consecutive unproductive reattaches before giving up. Backoff is capped at
10
+ * {@link MAX_BACKOFF_MS}. A reattach that reconnected and HELD (then dropped again)
11
+ * refills the budget, so a long session that blinks all day reconnects every time
12
+ * an attempt that never reached the host, or reached it and died back inside
13
+ * {@link MIN_HOLD_MS}, counts against it. */
13
14
  export declare const MAX_ATTEMPTS = 6;
15
+ /** How long a reattach must hold the remote pane before it counts as a genuine
16
+ * reconnection that refills the budget. Measured on the interactive attach ALONE
17
+ * — the preflight probe has already returned by then, so this is not the connect
18
+ * timing the file header rules out. 10s is comfortably longer than any attach that
19
+ * dies during TTY negotiation and far shorter than a session the user is working
20
+ * in; a link that drops the user out inside 10s on every attempt is one this loop
21
+ * should stop retrying, not one it should keep re-entering. */
22
+ export declare const MIN_HOLD_MS = 10000;
14
23
  export interface ReconnectState {
15
- /** Consecutive failed-to-connect reattaches since the last genuine reconnection. */
24
+ /** Consecutive unproductive reattaches since the last genuine reconnection — one
25
+ * that reached the host and held for {@link MIN_HOLD_MS}. */
16
26
  attempt: number;
17
27
  }
18
28
  export interface ReconnectOutcome {
@@ -20,8 +30,13 @@ export interface ReconnectOutcome {
20
30
  code: number;
21
31
  /** Whether the ssh handshake for this attempt actually completed. The initial run
22
32
  * and any reattach whose preflight probe succeeded are `connected`; a reattach
23
- * that couldn't reach the host is not. Drives the budget refill (see file head). */
33
+ * that couldn't reach the host is not. Half of the budget refill (see file head). */
24
34
  connected: boolean;
35
+ /** Wall-clock ms the interactive attach ran for, timed from after the preflight
36
+ * probe returned. `0` when the attempt never reached the host. The other half of
37
+ * the refill: it must be at least {@link MIN_HOLD_MS} to count as a genuine
38
+ * reconnection rather than a link that drops the user straight back out. */
39
+ heldMs: number;
25
40
  }
26
41
  export type ReconnectDecision = {
27
42
  action: 'stop';
@@ -34,6 +49,14 @@ export type ReconnectDecision = {
34
49
  export declare function initialReconnectState(): ReconnectState;
35
50
  /** Exponential backoff capped at {@link MAX_BACKOFF_MS}: 2s, 4s, 8s, 16s, 30s… */
36
51
  export declare function backoffMs(attempt: number): number;
52
+ /**
53
+ * Did this attempt genuinely put the user back into the agent? Only such an attempt
54
+ * refills the retry budget — it must have reached the host AND held the pane for at
55
+ * least {@link MIN_HOLD_MS}. An attach that reached the host and died right back is
56
+ * a flapping link, not a reconnection, and counts against the budget like an
57
+ * unreachable host (agents-cli#1884; see the file header).
58
+ */
59
+ export declare function refillsBudget(outcome: ReconnectOutcome): boolean;
37
60
  /**
38
61
  * Decide what to do after a run/re-attach returned `outcome`. Pure — the only
39
62
  * input is the prior state and the outcome, the only output is the next action.
@@ -41,16 +64,25 @@ export declare function backoffMs(attempt: number): number;
41
64
  * - a non-255 code means the remote command spoke for itself (clean detach = 0,
42
65
  * agent exit / no live session = non-zero) → stop and surface that code.
43
66
  * - a 255 means the link dropped → retry, unless the budget is spent.
44
- * - a 255 from an attempt that DID connect (a genuine reconnection that then
45
- * dropped) refills the budget first; a 255 that never connected counts against
46
- * it, so a host that stays unreachable gives up after MAX_ATTEMPTS.
67
+ * - a 255 from an attempt that reconnected AND held ({@link refillsBudget})
68
+ * refills the budget first; every other 255 counts against it, so a host that
69
+ * stays unreachable — and a link that keeps dropping the attach immediately
70
+ * both give up after MAX_ATTEMPTS.
47
71
  */
48
72
  export declare function reconnectStep(state: ReconnectState, outcome: ReconnectOutcome): ReconnectDecision;
49
73
  /** Human-readable notice shown before each reconnect wait. "13 seconds", not "12.8s". */
50
74
  export declare function reconnectNotice(sessionId: string, host: string, attempt: number, waitMs: number): string;
51
- /** Notice shown once the retry budget is spent. Hands back the one verb that
52
- * re-enters the terminal — attach the live pane if it survived, else resume. */
75
+ /** Notice shown once the retry budget is spent on a host that stayed UNREACHABLE.
76
+ * Hands back the one verb that re-enters the terminal — attach the live pane if it
77
+ * survived, else resume. */
53
78
  export declare function exhaustedNotice(sessionId: string, host: string): string;
79
+ /** Notice shown when the budget is spent the OTHER way: the last reattach reached
80
+ * the host and the connection dropped again within {@link MIN_HOLD_MS}. Saying
81
+ * "couldn't reconnect" there would be false — it did reconnect and could not stay
82
+ * — and the user needs to know the link, not the host, is the problem. It claims
83
+ * no count of successful reconnections: the budget can also be spent by a run of
84
+ * unreachable attempts followed by one that reconnected and dropped straight out. */
85
+ export declare function unstableNotice(sessionId: string, host: string): string;
54
86
  /** Notice shown when a reattach stops on a remapped remote-side exit
55
87
  * ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
56
88
  * on for its own reasons, not the ssh transport dropping; see
@@ -94,7 +126,8 @@ export declare function reattachRemoteCommand(sessionId: string): string;
94
126
  * a reachable host do we run the interactive attach-or-resume (which carries no
95
127
  * credentials — the agent already runs on the peer — so it rides the normal
96
128
  * transport). Returns the ssh exit code (255 = dropped again / unreachable; 0 =
97
- * clean detach; other = session ended) plus whether this attempt connected.
129
+ * clean detach; other = session ended), whether this attempt connected, and how
130
+ * long the attach held — the two inputs {@link refillsBudget} decides on.
98
131
  */
99
132
  export declare function reattachRemoteSession(host: Host, sessionId: string): ReconnectOutcome;
100
133
  export interface ReconnectLoopOpts {