@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.
- package/CHANGELOG.md +244 -0
- package/README.md +18 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/doctor.js +24 -10
- package/dist/commands/exec.js +17 -16
- package/dist/commands/insights.js +39 -5
- package/dist/commands/output.js +100 -25
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +47 -1
- package/dist/commands/sessions.js +345 -37
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.js +3 -3
- package/dist/index.js +16 -20
- package/dist/lib/auth-health.d.ts +47 -3
- package/dist/lib/auth-health.js +74 -15
- package/dist/lib/bench/index.d.ts +4 -0
- package/dist/lib/bench/index.js +4 -0
- package/dist/lib/bench/runner.d.ts +16 -0
- package/dist/lib/bench/runner.js +111 -0
- package/dist/lib/bench/schema.d.ts +5 -0
- package/dist/lib/bench/schema.js +91 -0
- package/dist/lib/bench/storage.d.ts +5 -0
- package/dist/lib/bench/storage.js +32 -0
- package/dist/lib/bench/types.d.ts +40 -0
- package/dist/lib/bench/types.js +1 -0
- package/dist/lib/claude-account-token.d.ts +2 -0
- package/dist/lib/claude-account-token.js +27 -1
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +2 -0
- package/dist/lib/crabbox/lease.js +7 -1
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/devices/connect.d.ts +2 -0
- package/dist/lib/devices/connect.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +4 -1
- package/dist/lib/devices/doctor-findings.js +15 -1
- package/dist/lib/devices/harness-inventory.d.ts +97 -0
- package/dist/lib/devices/harness-inventory.js +0 -0
- package/dist/lib/devices/registry.d.ts +2 -0
- package/dist/lib/devices/ssh-config.js +3 -0
- package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
- package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
- package/dist/lib/exec.bench.d.ts +1 -0
- package/dist/lib/exec.bench.js +186 -0
- package/dist/lib/exec.js +18 -8
- package/dist/lib/fleet/apply.js +9 -7
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/hooks.d.ts +8 -5
- package/dist/lib/hooks.js +10 -6
- package/dist/lib/hosts/dispatch.js +7 -3
- package/dist/lib/hosts/passthrough.d.ts +22 -0
- package/dist/lib/hosts/passthrough.js +14 -8
- package/dist/lib/hosts/progress.d.ts +2 -1
- package/dist/lib/hosts/progress.js +4 -3
- package/dist/lib/hosts/providers/devices.js +1 -0
- package/dist/lib/hosts/ready.d.ts +4 -2
- package/dist/lib/hosts/ready.js +24 -8
- package/dist/lib/hosts/reconcile.d.ts +1 -1
- package/dist/lib/hosts/reconcile.js +16 -7
- package/dist/lib/hosts/reconnect.d.ts +45 -12
- package/dist/lib/hosts/reconnect.js +90 -34
- package/dist/lib/hosts/registry.d.ts +2 -2
- package/dist/lib/hosts/registry.js +3 -5
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +29 -0
- package/dist/lib/hosts/tasks.d.ts +2 -0
- package/dist/lib/hosts/types.d.ts +1 -0
- package/dist/lib/hosts/types.js +3 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +9 -6
- package/dist/lib/menubar/install-menubar.js +20 -9
- package/dist/lib/models.d.ts +30 -1
- package/dist/lib/models.js +42 -144
- package/dist/lib/pricing/cost.d.ts +9 -0
- package/dist/lib/pricing/cost.js +24 -0
- package/dist/lib/pricing/index.d.ts +1 -1
- package/dist/lib/pricing/index.js +1 -1
- package/dist/lib/redact.js +8 -3
- package/dist/lib/remote-agents-json.d.ts +32 -0
- package/dist/lib/remote-agents-json.js +47 -16
- package/dist/lib/resource-profiles.js +1 -2
- package/dist/lib/routine-notify-owner.d.ts +102 -0
- package/dist/lib/routine-notify-owner.js +232 -0
- package/dist/lib/routines.d.ts +10 -0
- package/dist/lib/runner.d.ts +9 -1
- package/dist/lib/runner.js +182 -17
- package/dist/lib/sandbox.d.ts +0 -2
- package/dist/lib/sandbox.js +2 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.js +3 -2
- package/dist/lib/secrets/lease.d.ts +25 -0
- package/dist/lib/secrets/lease.js +44 -0
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/db.d.ts +19 -2
- package/dist/lib/session/db.js +54 -5
- package/dist/lib/session/discover.d.ts +21 -1
- package/dist/lib/session/discover.js +124 -32
- package/dist/lib/session/insights.d.ts +19 -0
- package/dist/lib/session/insights.js +78 -10
- package/dist/lib/session/recovery.d.ts +17 -1
- package/dist/lib/session/recovery.js +111 -4
- package/dist/lib/session/remote-list.d.ts +10 -9
- package/dist/lib/session/remote-list.js +25 -23
- package/dist/lib/session/resume-owner.d.ts +55 -0
- package/dist/lib/session/resume-owner.js +69 -0
- package/dist/lib/session/team-filter.d.ts +65 -0
- package/dist/lib/session/team-filter.js +98 -3
- package/dist/lib/session/types.d.ts +33 -0
- package/dist/lib/smart-launch.d.ts +31 -5
- package/dist/lib/smart-launch.js +43 -7
- package/dist/lib/ssh-exec.d.ts +27 -0
- package/dist/lib/ssh-exec.js +34 -1
- package/dist/lib/ssh-tunnel.d.ts +3 -2
- package/dist/lib/ssh-tunnel.js +25 -16
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/teams/placement-probe.d.ts +1 -1
- package/dist/lib/teams/placement-probe.js +24 -24
- package/dist/lib/tmux/session.d.ts +8 -0
- package/dist/lib/tmux/session.js +22 -0
- package/dist/lib/types.d.ts +2 -0
- 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`.
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
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
|
|
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
|
-
|
|
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);
|
|
@@ -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. */
|
package/dist/lib/hosts/ready.js
CHANGED
|
@@ -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
|
|
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`, {
|
|
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
|
-
|
|
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
|
|
10
|
-
* {@link MAX_BACKOFF_MS}. A reattach that
|
|
11
|
-
* refills the budget, so a long session that blinks all day reconnects every time
|
|
12
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
52
|
-
* re-enters the terminal — attach the live pane if it
|
|
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)
|
|
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 {
|