@phnx-labs/agents-cli 1.22.43 → 1.22.45
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 +85 -0
- package/README.md +14 -12
- package/dist/bootstrap.js +3 -6
- package/dist/cli/command-registry.d.ts +0 -7
- package/dist/cli/command-registry.js +1 -21
- package/dist/commands/accounts.d.ts +1 -9
- package/dist/commands/accounts.js +12 -90
- package/dist/commands/apply.d.ts +7 -10
- package/dist/commands/apply.js +15 -35
- package/dist/commands/artifacts.js +4 -5
- package/dist/commands/audit.d.ts +6 -6
- package/dist/commands/audit.js +12 -12
- package/dist/commands/beta.d.ts +7 -1
- package/dist/commands/beta.js +16 -9
- package/dist/commands/commands.js +1 -1
- package/dist/commands/doctor.js +1 -4
- package/dist/commands/events.d.ts +2 -2
- package/dist/commands/events.js +6 -5
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +8 -4
- package/dist/commands/factory.d.ts +1 -1
- package/dist/commands/fleet-capture.d.ts +1 -1
- package/dist/commands/fleet-capture.js +4 -5
- package/dist/commands/harness.js +1 -49
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.js +85 -156
- package/dist/commands/menubar.js +58 -1
- package/dist/commands/output.js +2 -2
- package/dist/commands/prune.js +5 -4
- package/dist/commands/routines.test-fixture.d.ts +86 -0
- package/dist/commands/routines.test-fixture.js +345 -0
- package/dist/commands/sessions.test-fixture.d.ts +24 -0
- package/dist/commands/sessions.test-fixture.js +224 -0
- package/dist/commands/setup.js +5 -1
- package/dist/commands/share.d.ts +9 -10
- package/dist/commands/share.js +68 -50
- package/dist/commands/skills.js +1 -1
- package/dist/commands/ssh.js +15 -62
- package/dist/commands/subagents.js +1 -1
- package/dist/commands/view.js +5 -2
- package/dist/lib/analytics/mix-commands.d.ts +6 -28
- package/dist/lib/analytics/mix-commands.js +14 -40
- package/dist/lib/audit/log.d.ts +2 -2
- package/dist/lib/audit/log.js +2 -2
- package/dist/lib/beta.js +1 -1
- package/dist/lib/browser/domain-skills.d.ts +26 -6
- package/dist/lib/browser/domain-skills.js +81 -43
- package/dist/lib/daemon/daemon.js +11 -0
- package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
- package/dist/lib/daemon/daemon.test-fixture.js +71 -0
- package/dist/lib/daemon-ticks.d.ts +38 -1
- package/dist/lib/daemon-ticks.js +52 -5
- package/dist/lib/device-config.js +1 -2
- package/dist/lib/devices/discovery-policy.d.ts +3 -3
- package/dist/lib/devices/discovery-policy.js +3 -3
- package/dist/lib/devices/fleet.d.ts +4 -8
- package/dist/lib/devices/fleet.js +3 -15
- package/dist/lib/devices/health-report.js +3 -3
- package/dist/lib/devices/pool.d.ts +0 -6
- package/dist/lib/devices/pool.js +0 -6
- package/dist/lib/devices/registry.d.ts +0 -15
- package/dist/lib/devices/registry.js +0 -9
- package/dist/lib/devices/stats-cache.d.ts +12 -0
- package/dist/lib/devices/stats-cache.js +24 -5
- package/dist/lib/fleet/capture.d.ts +1 -1
- package/dist/lib/fleet/manifest.js +1 -1
- package/dist/lib/fleet/remote-login.d.ts +2 -2
- package/dist/lib/fleet/remote-login.js +3 -4
- package/dist/lib/hosts/dispatch.d.ts +2 -0
- package/dist/lib/hosts/dispatch.js +8 -1
- package/dist/lib/hosts/passthrough.js +0 -1
- package/dist/lib/hosts/providers/devices.js +1 -13
- package/dist/lib/hosts/ready.d.ts +12 -3
- package/dist/lib/hosts/ready.js +27 -12
- package/dist/lib/hosts/registry.d.ts +4 -5
- package/dist/lib/hosts/registry.js +3 -7
- package/dist/lib/installations/versions.js +21 -2
- package/dist/lib/menubar/install-menubar.d.ts +109 -0
- package/dist/lib/menubar/install-menubar.js +199 -4
- package/dist/lib/remote-agents-json.js +1 -7
- package/dist/lib/routines-placement.d.ts +1 -1
- package/dist/lib/routines-placement.js +1 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
- package/dist/lib/session/active.d.ts +4 -16
- package/dist/lib/session/active.js +2 -6
- package/dist/lib/session/db.d.ts +13 -98
- package/dist/lib/session/db.js +4 -11
- package/dist/lib/session/discover.d.ts +5 -81
- package/dist/lib/session/discover.js +5 -5
- package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote/remote-bundle.js +61 -0
- package/dist/lib/session/remote/remote-list.d.ts +148 -0
- package/dist/lib/session/remote/remote-list.js +607 -0
- package/dist/lib/session/remote/remote.d.ts +132 -0
- package/dist/lib/session/remote/remote.js +314 -0
- package/dist/lib/session/remote/watch.d.ts +101 -0
- package/dist/lib/session/remote/watch.js +241 -0
- package/dist/lib/session/remote-bundle.d.ts +1 -12
- package/dist/lib/session/remote-bundle.js +3 -61
- package/dist/lib/session/remote-list.d.ts +1 -148
- package/dist/lib/session/remote-list.js +3 -607
- package/dist/lib/session/remote.d.ts +1 -132
- package/dist/lib/session/remote.js +3 -314
- package/dist/lib/session/watch.d.ts +1 -101
- package/dist/lib/session/watch.js +3 -242
- package/dist/lib/share/config.d.ts +12 -2
- package/dist/lib/share/config.js +47 -11
- package/dist/lib/share/delete.js +1 -1
- package/dist/lib/share/publish.js +1 -1
- package/dist/lib/share/worker-template.js +2 -2
- package/dist/lib/smart-launch.d.ts +4 -4
- package/dist/lib/smart-launch.js +8 -18
- package/dist/lib/staleness/index.d.ts +3 -1
- package/dist/lib/staleness/index.js +21 -1
- package/dist/lib/staleness/types.d.ts +11 -0
- package/dist/lib/staleness/writers/commands.js +13 -4
- package/dist/lib/staleness/writers/hooks.js +3 -1
- package/dist/lib/staleness/writers/mcp.d.ts +0 -7
- package/dist/lib/staleness/writers/mcp.js +16 -1
- package/dist/lib/staleness/writers/rules.js +1 -1
- package/dist/lib/staleness/writers/skills.js +3 -1
- package/dist/lib/staleness/writers/subagents.d.ts +0 -9
- package/dist/lib/staleness/writers/subagents.js +18 -1
- package/dist/lib/staleness/writers/types.d.ts +10 -0
- package/dist/lib/startup/command-registry.d.ts +8 -1
- package/dist/lib/startup/command-registry.js +21 -6
- package/dist/lib/startup/root-command.d.ts +18 -1
- package/dist/lib/startup/root-command.js +18 -1
- package/dist/lib/state.js +7 -0
- package/dist/lib/teams/placement-probe.js +27 -12
- package/dist/lib/teams/scheduler.d.ts +3 -1
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/view-types.d.ts +3 -0
- package/package.json +2 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/auth.d.ts +0 -9
- package/dist/commands/auth.js +0 -108
- package/dist/commands/org.d.ts +0 -6
- package/dist/commands/org.js +0 -175
- package/dist/commands/serve.d.ts +0 -10
- package/dist/commands/serve.js +0 -68
- package/dist/commands/trends.d.ts +0 -10
- package/dist/commands/trends.js +0 -12
- package/dist/lib/entitlement.d.ts +0 -31
- package/dist/lib/entitlement.js +0 -137
- package/dist/lib/prix-account.d.ts +0 -158
- package/dist/lib/prix-account.js +0 -214
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/serve/control.d.ts +0 -95
- package/dist/lib/serve/control.js +0 -260
- package/dist/lib/serve/data.d.ts +0 -81
- package/dist/lib/serve/data.js +0 -91
- package/dist/lib/serve/page.d.ts +0 -7
- package/dist/lib/serve/page.js +0 -140
- package/dist/lib/serve/server.d.ts +0 -80
- package/dist/lib/serve/server.js +0 -145
- package/dist/lib/serve/stream.d.ts +0 -43
- package/dist/lib/serve/stream.js +0 -116
- package/dist/lib/serve/token.d.ts +0 -35
- package/dist/lib/serve/token.js +0 -85
|
@@ -19,9 +19,9 @@ export declare function loadDeviceDiscoveryPolicies(): Map<string, DeviceDiscove
|
|
|
19
19
|
* Only devices with an EXPLICIT entry in the synced policy are touched. A
|
|
20
20
|
* device absent from the map is left alone, whether or not the map itself is
|
|
21
21
|
* defined — the map is not treated as authoritative-by-omission, because not
|
|
22
|
-
* every local registration path writes a policy entry (
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* every local registration path writes a policy entry (daemon/umbrella
|
|
23
|
+
* auto-refresh, and any device registered before this feature shipped).
|
|
24
|
+
* Wiping those on the next `agents repo pull user` would be
|
|
25
25
|
* silent, fleet-wide data loss for a decision (e.g. "ignore this one iPad")
|
|
26
26
|
* that never meant to touch them.
|
|
27
27
|
*/
|
|
@@ -48,9 +48,9 @@ export function loadDeviceDiscoveryPolicies() {
|
|
|
48
48
|
* Only devices with an EXPLICIT entry in the synced policy are touched. A
|
|
49
49
|
* device absent from the map is left alone, whether or not the map itself is
|
|
50
50
|
* defined — the map is not treated as authoritative-by-omission, because not
|
|
51
|
-
* every local registration path writes a policy entry (
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* every local registration path writes a policy entry (daemon/umbrella
|
|
52
|
+
* auto-refresh, and any device registered before this feature shipped).
|
|
53
|
+
* Wiping those on the next `agents repo pull user` would be
|
|
54
54
|
* silent, fleet-wide data loss for a decision (e.g. "ignore this one iPad")
|
|
55
55
|
* that never meant to touch them.
|
|
56
56
|
*/
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* throws (misconfigured auth, etc.) become `failed` rows — they never abort
|
|
8
8
|
* the remaining devices.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import type { DeviceProfile, DeviceRegistry } from './registry.js';
|
|
11
11
|
import type { DeviceStats } from './health.js';
|
|
12
|
-
export type FleetSkipReason = 'offline' | 'no-address'
|
|
12
|
+
export type FleetSkipReason = 'offline' | 'no-address';
|
|
13
13
|
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
14
14
|
export declare const FLEET_VERSION_RE: RegExp;
|
|
15
15
|
export interface FleetTarget {
|
|
@@ -39,7 +39,6 @@ export interface FanOutDeviceResult<T> {
|
|
|
39
39
|
/**
|
|
40
40
|
* Classify each registered device for a fleet operation.
|
|
41
41
|
*
|
|
42
|
-
* - Control-only device (a cockpit, e.g. a paired iPhone) → skip `control`
|
|
43
42
|
* - Tailscale-offline → skip `offline`
|
|
44
43
|
* - No address → skip `no-address`
|
|
45
44
|
* - Everything else is a target (including this machine, reached over ssh when
|
|
@@ -48,10 +47,7 @@ export interface FanOutDeviceResult<T> {
|
|
|
48
47
|
export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
|
|
49
48
|
/**
|
|
50
49
|
* Remote fan-out targets for the fleet health/drift gates (`fleet status`,
|
|
51
|
-
* `doctor --check --devices`): every planned device except this machine
|
|
52
|
-
* cockpits. A control device never runs agents (mirrors doctor's fan-out, which
|
|
53
|
-
* drops it via `isControlDevice`), so counting it as unreachable/drift would make
|
|
54
|
-
* the CI gate fail on every run for a fleet that merely has a cockpit registered.
|
|
50
|
+
* `doctor --check --devices`): every planned device except this machine.
|
|
55
51
|
* Offline / no-address devices are kept — those are genuine faults a gate should
|
|
56
52
|
* surface — so their `skip` reason still flows through as an `unreachable` row.
|
|
57
53
|
*/
|
|
@@ -72,7 +68,7 @@ export declare function remoteFleetTargets(planned: FleetTarget[], self: string)
|
|
|
72
68
|
* minutes is the only false-negative window — it renders `unreachable` until the
|
|
73
69
|
* next stats warm, which beats letting it hang the status glance for 45s.
|
|
74
70
|
*
|
|
75
|
-
* An existing skip (
|
|
71
|
+
* An existing skip (offline/no-address from {@link planFleetTargets})
|
|
76
72
|
* always wins — those are classified before any probe.
|
|
77
73
|
*/
|
|
78
74
|
export declare function fleetHealthSkip(currentSkip: FleetSkipReason | string | undefined, stats: DeviceStats | undefined): FleetSkipReason | string | undefined;
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* the remaining devices.
|
|
9
9
|
*/
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
|
-
import { isControlDevice } from './registry.js';
|
|
12
11
|
import { isSelfHost } from './self-host.js';
|
|
13
12
|
import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
|
|
14
13
|
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
@@ -16,7 +15,6 @@ export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
|
|
|
16
15
|
/**
|
|
17
16
|
* Classify each registered device for a fleet operation.
|
|
18
17
|
*
|
|
19
|
-
* - Control-only device (a cockpit, e.g. a paired iPhone) → skip `control`
|
|
20
18
|
* - Tailscale-offline → skip `offline`
|
|
21
19
|
* - No address → skip `no-address`
|
|
22
20
|
* - Everything else is a target (including this machine, reached over ssh when
|
|
@@ -26,11 +24,6 @@ export function planFleetTargets(reg) {
|
|
|
26
24
|
const names = Object.keys(reg).sort();
|
|
27
25
|
return names.map((name) => {
|
|
28
26
|
const device = reg[name];
|
|
29
|
-
// A control device drives the fleet but never runs agents — never a target
|
|
30
|
-
// for update/run/stats, whatever its platform reads as.
|
|
31
|
-
if (isControlDevice(device)) {
|
|
32
|
-
return { device, skip: 'control' };
|
|
33
|
-
}
|
|
34
27
|
if (device.tailscale && !device.tailscale.online) {
|
|
35
28
|
return { device, skip: 'offline' };
|
|
36
29
|
}
|
|
@@ -45,10 +38,7 @@ export function planFleetTargets(reg) {
|
|
|
45
38
|
}
|
|
46
39
|
/**
|
|
47
40
|
* Remote fan-out targets for the fleet health/drift gates (`fleet status`,
|
|
48
|
-
* `doctor --check --devices`): every planned device except this machine
|
|
49
|
-
* cockpits. A control device never runs agents (mirrors doctor's fan-out, which
|
|
50
|
-
* drops it via `isControlDevice`), so counting it as unreachable/drift would make
|
|
51
|
-
* the CI gate fail on every run for a fleet that merely has a cockpit registered.
|
|
41
|
+
* `doctor --check --devices`): every planned device except this machine.
|
|
52
42
|
* Offline / no-address devices are kept — those are genuine faults a gate should
|
|
53
43
|
* surface — so their `skip` reason still flows through as an `unreachable` row.
|
|
54
44
|
*/
|
|
@@ -57,7 +47,7 @@ export function remoteFleetTargets(planned, self) {
|
|
|
57
47
|
// device referenced by its dnsName slipped past the bare name check and got a
|
|
58
48
|
// remote version+doctor dial back to THIS box, which orphaned on timeout and
|
|
59
49
|
// piled up (RUSH-2114). `isSelfHost` matches every alias the box answers to.
|
|
60
|
-
return planned.filter((t) => t.device.name !== self && !isSelfHost(t.device.name)
|
|
50
|
+
return planned.filter((t) => t.device.name !== self && !isSelfHost(t.device.name));
|
|
61
51
|
}
|
|
62
52
|
/**
|
|
63
53
|
* Decide whether a fleet-health target should skip the expensive version+doctor
|
|
@@ -75,7 +65,7 @@ export function remoteFleetTargets(planned, self) {
|
|
|
75
65
|
* minutes is the only false-negative window — it renders `unreachable` until the
|
|
76
66
|
* next stats warm, which beats letting it hang the status glance for 45s.
|
|
77
67
|
*
|
|
78
|
-
* An existing skip (
|
|
68
|
+
* An existing skip (offline/no-address from {@link planFleetTargets})
|
|
79
69
|
* always wins — those are classified before any probe.
|
|
80
70
|
*/
|
|
81
71
|
export function fleetHealthSkip(currentSkip, stats) {
|
|
@@ -92,8 +82,6 @@ export function skipLabel(reason) {
|
|
|
92
82
|
return 'offline';
|
|
93
83
|
case 'no-address':
|
|
94
84
|
return 'no address';
|
|
95
|
-
case 'control':
|
|
96
|
-
return 'control device';
|
|
97
85
|
}
|
|
98
86
|
}
|
|
99
87
|
/**
|
|
@@ -314,7 +314,7 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
|
|
|
314
314
|
const seen = row.lastSeen ? ` · last seen ${formatCheckedAge(Date.parse(row.lastSeen), now)}` : '';
|
|
315
315
|
items.push({ glyph: 'offline', subject: row.name, detail: `offline${seen}`, fix: 'check the box' });
|
|
316
316
|
}
|
|
317
|
-
// 2) Boxes that need `agents apply` — merge config drift and a stark CLI gap
|
|
317
|
+
// 2) Boxes that need `agents fleet apply` — merge config drift and a stark CLI gap
|
|
318
318
|
// into ONE item per box (both are fixed by the same command, so don't
|
|
319
319
|
// double-list). An offline box's config/CLI is unknowable, so skip it.
|
|
320
320
|
for (const row of report.devices) {
|
|
@@ -327,7 +327,7 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
|
|
|
327
327
|
if (stark)
|
|
328
328
|
reasons.push(`only ${stark.installed} of ${stark.total} agent CLIs installed`);
|
|
329
329
|
if (reasons.length > 0) {
|
|
330
|
-
items.push({ glyph: 'warn', subject: row.name, detail: reasons.join(' · '), fix: `agents apply ${row.name}` });
|
|
330
|
+
items.push({ glyph: 'warn', subject: row.name, detail: reasons.join(' · '), fix: `agents fleet apply --device ${row.name}` });
|
|
331
331
|
}
|
|
332
332
|
}
|
|
333
333
|
// 3) Version skew across the fleet — one line.
|
|
@@ -354,7 +354,7 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
|
|
|
354
354
|
glyph: 'warn',
|
|
355
355
|
subject: w.devices[0] ?? 'fleet',
|
|
356
356
|
detail: w.message.replace(`${w.devices[0]} `, ''),
|
|
357
|
-
fix: `agents apply ${w.devices[0] ?? ''}`.trim(),
|
|
357
|
+
fix: `agents fleet apply --device ${w.devices[0] ?? ''}`.trim(),
|
|
358
358
|
});
|
|
359
359
|
}
|
|
360
360
|
return items;
|
|
@@ -19,12 +19,6 @@
|
|
|
19
19
|
*
|
|
20
20
|
* `auto.pool all` turns the allowlist off; `personal` stays excluded, because a
|
|
21
21
|
* machine the user sits at is marked precisely so agents stay off it.
|
|
22
|
-
*
|
|
23
|
-
* Paired cockpits (an iPhone/iPad, `role: control` in the device registry, set
|
|
24
|
-
* by `agents devices pair-ios`) are excluded by the CALLER that reads the
|
|
25
|
-
* registry — `listOnlineDeviceNames` in `lib/smart-launch.ts` — not here. That
|
|
26
|
-
* role is machine-local by nature and already has a home; duplicating it in the
|
|
27
|
-
* shared config would be a second store for one concept.
|
|
28
22
|
*/
|
|
29
23
|
import { type AutoPoolMode, type ConfiguredDeviceRole } from '../device-config.js';
|
|
30
24
|
export interface AutoPoolOptions {
|
package/dist/lib/devices/pool.js
CHANGED
|
@@ -19,12 +19,6 @@
|
|
|
19
19
|
*
|
|
20
20
|
* `auto.pool all` turns the allowlist off; `personal` stays excluded, because a
|
|
21
21
|
* machine the user sits at is marked precisely so agents stay off it.
|
|
22
|
-
*
|
|
23
|
-
* Paired cockpits (an iPhone/iPad, `role: control` in the device registry, set
|
|
24
|
-
* by `agents devices pair-ios`) are excluded by the CALLER that reads the
|
|
25
|
-
* registry — `listOnlineDeviceNames` in `lib/smart-launch.ts` — not here. That
|
|
26
|
-
* role is machine-local by nature and already has a home; duplicating it in the
|
|
27
|
-
* shared config would be a second store for one concept.
|
|
28
22
|
*/
|
|
29
23
|
import { autoPoolMode, listConfiguredDeviceRoles } from '../device-config.js';
|
|
30
24
|
import { normalizeHost } from '../machine-id.js';
|
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
/** Operating-system family of a device, used to pick the remote shell. */
|
|
2
2
|
export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
|
|
3
|
-
/**
|
|
4
|
-
* What a device is *for*. A `worker` (the default) can run agents — it's dialed
|
|
5
|
-
* over SSH for sessions and eligible for run/team placement. A `control` device
|
|
6
|
-
* is a cockpit that drives the fleet but never executes agents itself (an
|
|
7
|
-
* iPhone/iPad running the companion app): it appears in the fleet but is never
|
|
8
|
-
* dialed for SSH nor scheduled work. Absent `role` means `worker`.
|
|
9
|
-
*/
|
|
10
|
-
export type DeviceRole = 'worker' | 'control';
|
|
11
3
|
/** Remote shell dialect derived from the platform. */
|
|
12
4
|
export type DeviceShell = 'powershell' | 'posix';
|
|
13
5
|
/** How `agents ssh` authenticates to a device. Both are first-class, fully
|
|
@@ -73,15 +65,9 @@ export interface DeviceProfile {
|
|
|
73
65
|
* cached {@link DeviceTailscale.online} snapshot when rendering online/offline,
|
|
74
66
|
* because the live probe reflects whether the box answered right now. */
|
|
75
67
|
reachability?: DeviceReachability;
|
|
76
|
-
/** What the device is for. Absent means `worker` (see {@link DeviceRole}). */
|
|
77
|
-
role?: DeviceRole;
|
|
78
68
|
createdAt: string;
|
|
79
69
|
updatedAt: string;
|
|
80
70
|
}
|
|
81
|
-
/** A device's effective role, defaulting to `worker` when unset. */
|
|
82
|
-
export declare function deviceRole(d: DeviceProfile): DeviceRole;
|
|
83
|
-
/** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
|
|
84
|
-
export declare function isControlDevice(d: DeviceProfile): boolean;
|
|
85
71
|
/**
|
|
86
72
|
* Whether a fan-out should dial this device, honouring the preference stated on
|
|
87
73
|
* {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
|
|
@@ -127,7 +113,6 @@ export interface DeviceInput {
|
|
|
127
113
|
auth?: DeviceAuth;
|
|
128
114
|
tailscale?: DeviceTailscale;
|
|
129
115
|
reachability?: DeviceReachability;
|
|
130
|
-
role?: DeviceRole;
|
|
131
116
|
}
|
|
132
117
|
/**
|
|
133
118
|
* Create the device if absent, otherwise merge the supplied fields into the
|
|
@@ -19,14 +19,6 @@ import * as path from 'path';
|
|
|
19
19
|
import lockfile from 'proper-lockfile';
|
|
20
20
|
import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
|
|
21
21
|
import { atomicWriteJsonSync } from '../fs-atomic.js';
|
|
22
|
-
/** A device's effective role, defaulting to `worker` when unset. */
|
|
23
|
-
export function deviceRole(d) {
|
|
24
|
-
return d.role ?? 'worker';
|
|
25
|
-
}
|
|
26
|
-
/** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
|
|
27
|
-
export function isControlDevice(d) {
|
|
28
|
-
return deviceRole(d) === 'control';
|
|
29
|
-
}
|
|
30
22
|
/**
|
|
31
23
|
* Whether a fan-out should dial this device, honouring the preference stated on
|
|
32
24
|
* {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
|
|
@@ -198,7 +190,6 @@ export async function upsertDevice(name, input) {
|
|
|
198
190
|
auth: input.auth ?? prev?.auth ?? { method: 'key' },
|
|
199
191
|
tailscale: input.tailscale ?? prev?.tailscale,
|
|
200
192
|
reachability: input.reachability ?? prev?.reachability,
|
|
201
|
-
role: input.role ?? prev?.role,
|
|
202
193
|
createdAt: prev?.createdAt ?? now,
|
|
203
194
|
updatedAt: now,
|
|
204
195
|
};
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { probeFleetStats, probeLocalStats, type DeviceStats } from './health.js';
|
|
2
2
|
import type { DeviceProfile } from './registry.js';
|
|
3
|
+
/**
|
|
4
|
+
* Freshness bound for a cached row. Matches the agent-count mirror's window
|
|
5
|
+
* (`AGENT_STATUS_STALE_MS` in `commands/ssh.ts`) so both columns of
|
|
6
|
+
* `devices list` / `fleet status` share one staleness model. A row older than
|
|
7
|
+
* this is never served as current — it is re-probed live (and the probe result
|
|
8
|
+
* rewrites the cache, unreachable boxes included).
|
|
9
|
+
*/
|
|
10
|
+
export declare const STATS_STALE_MS: number;
|
|
11
|
+
/** True when a cached row is still within {@link STATS_STALE_MS}. */
|
|
12
|
+
export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
|
|
3
13
|
/** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
|
|
4
14
|
export declare function readStatsCache(): Record<string, DeviceStats>;
|
|
5
15
|
/**
|
|
@@ -27,6 +37,8 @@ export interface LoadFleetStatsOptions {
|
|
|
27
37
|
probeLocal?: typeof probeLocalStats;
|
|
28
38
|
readCache?: typeof readStatsCache;
|
|
29
39
|
writeCache?: typeof writeStatsCache;
|
|
40
|
+
/** Clock override for tests (drives the {@link STATS_STALE_MS} bound). */
|
|
41
|
+
now?: number;
|
|
30
42
|
}
|
|
31
43
|
/**
|
|
32
44
|
* Load fleet stats cache-first. See the module doc for the default vs
|
|
@@ -15,16 +15,32 @@
|
|
|
15
15
|
* - **`--refresh` / `--live`:** skip the cache and live-probe every device,
|
|
16
16
|
* rewriting the cache.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* Every cached row is bounded by {@link STATS_STALE_MS}: a row older than the
|
|
19
|
+
* window is treated exactly like a missing one — re-probed live this call and
|
|
20
|
+
* rewritten — so the default read is itself the cache's writer and a value can
|
|
21
|
+
* never fossilize at the last manual `--refresh`. (The daemon used to warm this
|
|
22
|
+
* cache every ~3 min; RUSH-2061 removed that N² fleet probe, which left the
|
|
23
|
+
* unbounded read serving multi-day-old rows as current fleet state — the
|
|
24
|
+
* scheduler, `--device auto`, and the session-start banner all read these
|
|
25
|
+
* numbers. See #2666.)
|
|
22
26
|
*/
|
|
23
27
|
import * as fs from 'fs';
|
|
24
28
|
import * as path from 'path';
|
|
25
29
|
import { getCacheDir } from '../state.js';
|
|
26
30
|
import { probeFleetStats, probeLocalStats } from './health.js';
|
|
27
31
|
const CACHE_FILE = '.fleet-stats.json';
|
|
32
|
+
/**
|
|
33
|
+
* Freshness bound for a cached row. Matches the agent-count mirror's window
|
|
34
|
+
* (`AGENT_STATUS_STALE_MS` in `commands/ssh.ts`) so both columns of
|
|
35
|
+
* `devices list` / `fleet status` share one staleness model. A row older than
|
|
36
|
+
* this is never served as current — it is re-probed live (and the probe result
|
|
37
|
+
* rewrites the cache, unreachable boxes included).
|
|
38
|
+
*/
|
|
39
|
+
export const STATS_STALE_MS = 3 * 60_000;
|
|
40
|
+
/** True when a cached row is still within {@link STATS_STALE_MS}. */
|
|
41
|
+
export function isFreshDeviceStats(stats, now = Date.now()) {
|
|
42
|
+
return now - stats.fetchedAt <= STATS_STALE_MS;
|
|
43
|
+
}
|
|
28
44
|
function cacheFilePath() {
|
|
29
45
|
return path.join(getCacheDir(), CACHE_FILE);
|
|
30
46
|
}
|
|
@@ -71,6 +87,7 @@ export async function loadFleetStats(devices, opts = {}) {
|
|
|
71
87
|
const readCache = opts.readCache ?? readStatsCache;
|
|
72
88
|
const writeCache = opts.writeCache ?? writeStatsCache;
|
|
73
89
|
const self = opts.selfName;
|
|
90
|
+
const now = opts.now ?? Date.now();
|
|
74
91
|
const cache = opts.forceRefresh ? {} : readCache();
|
|
75
92
|
const stats = new Map();
|
|
76
93
|
const toProbe = [];
|
|
@@ -82,11 +99,13 @@ export async function loadFleetStats(devices, opts = {}) {
|
|
|
82
99
|
continue;
|
|
83
100
|
}
|
|
84
101
|
const cached = cache[d.name];
|
|
85
|
-
if (cached) {
|
|
102
|
+
if (cached && isFreshDeviceStats(cached, now)) {
|
|
86
103
|
stats.set(d.name, cached);
|
|
87
104
|
servedFromCache = true;
|
|
88
105
|
}
|
|
89
106
|
else {
|
|
107
|
+
// Missing OR beyond the staleness bound: a stale number rendered as
|
|
108
|
+
// current is worse than the probe's cost, so re-probe live (#2666).
|
|
90
109
|
toProbe.push(d);
|
|
91
110
|
}
|
|
92
111
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { FleetManifest, FleetDefaults } from './types.js';
|
|
17
17
|
export interface CaptureInputs {
|
|
18
|
-
/** Registered
|
|
18
|
+
/** Registered device names to record (the roster — names only). */
|
|
19
19
|
devices: string[];
|
|
20
20
|
/** Optional per-device agent specs (e.g. from `--from-pins`), keyed by name. */
|
|
21
21
|
agentsByDevice?: Record<string, string[]>;
|
|
@@ -124,7 +124,7 @@ export function readFleetFile(filePath) {
|
|
|
124
124
|
}
|
|
125
125
|
const fleet = doc.fleet;
|
|
126
126
|
if (fleet === undefined) {
|
|
127
|
-
throw new Error(`${filePath} has no fleet: block. Add one to declare the profile (see \`agents apply --help\`).`);
|
|
127
|
+
throw new Error(`${filePath} has no fleet: block. Add one to declare the profile (see \`agents fleet apply --help\`).`);
|
|
128
128
|
}
|
|
129
129
|
return parseFleetManifest(fleet);
|
|
130
130
|
}
|
|
@@ -63,7 +63,7 @@ export interface SelectLoginOptions {
|
|
|
63
63
|
*
|
|
64
64
|
* A pair is pending when its cached verdict {@link isPendingVerdict} (or
|
|
65
65
|
* `includeLoggedIn` forces it). Agents with no defined login flow are dropped
|
|
66
|
-
* silently (nothing to log into).
|
|
66
|
+
* silently (nothing to log into).
|
|
67
67
|
*/
|
|
68
68
|
export declare function selectLoginTargets(devices: DeviceProfile[], agents: (AgentId | string)[], cache: Record<string, AuthHealth>, opts?: SelectLoginOptions): PendingLogin[];
|
|
69
69
|
/**
|
|
@@ -146,7 +146,7 @@ export interface DetectOptions {
|
|
|
146
146
|
includeLoggedIn?: boolean;
|
|
147
147
|
}
|
|
148
148
|
/**
|
|
149
|
-
* Resolve the online
|
|
149
|
+
* Resolve the online fleet devices, then compute pending logins
|
|
150
150
|
* from the shared auth-health cache (populated by `agents fleet ping`). Thin I/O
|
|
151
151
|
* over the pure {@link selectLoginTargets}. Devices/agents can be narrowed by the
|
|
152
152
|
* caller's flags; the default agent set is every agent with a defined flow.
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
* human works through the earlier ones.
|
|
28
28
|
*/
|
|
29
29
|
import * as http from 'http';
|
|
30
|
-
import { isControlDevice } from '../devices/registry.js';
|
|
31
30
|
import { loadDevices } from '../devices/registry.js';
|
|
32
31
|
import { deviceIdentityArgs, fleetDialTarget } from '../devices/connect.js';
|
|
33
32
|
import { planFleetTargets } from '../devices/fleet.js';
|
|
@@ -117,7 +116,7 @@ function worstCachedVerdict(cache, host, agent) {
|
|
|
117
116
|
*
|
|
118
117
|
* A pair is pending when its cached verdict {@link isPendingVerdict} (or
|
|
119
118
|
* `includeLoggedIn` forces it). Agents with no defined login flow are dropped
|
|
120
|
-
* silently (nothing to log into).
|
|
119
|
+
* silently (nothing to log into).
|
|
121
120
|
*/
|
|
122
121
|
export function selectLoginTargets(devices, agents, cache, opts = {}) {
|
|
123
122
|
const out = [];
|
|
@@ -408,7 +407,7 @@ export async function driveRemoteLogin(target, flow, driver, opts = {}, extraSsh
|
|
|
408
407
|
}
|
|
409
408
|
}
|
|
410
409
|
/**
|
|
411
|
-
* Resolve the online
|
|
410
|
+
* Resolve the online fleet devices, then compute pending logins
|
|
412
411
|
* from the shared auth-health cache (populated by `agents fleet ping`). Thin I/O
|
|
413
412
|
* over the pure {@link selectLoginTargets}. Devices/agents can be narrowed by the
|
|
414
413
|
* caller's flags; the default agent set is every agent with a defined flow.
|
|
@@ -417,7 +416,7 @@ export async function detectPending(opts = {}) {
|
|
|
417
416
|
const reg = await loadDevices();
|
|
418
417
|
const self = machineId();
|
|
419
418
|
const online = planFleetTargets(reg)
|
|
420
|
-
.filter((t) => !t.skip && t.device.name !== self
|
|
419
|
+
.filter((t) => !t.skip && t.device.name !== self)
|
|
421
420
|
.map((t) => t.device);
|
|
422
421
|
const deviceFilter = opts.devices && opts.devices.length > 0 ? new Set(opts.devices) : null;
|
|
423
422
|
const devices = deviceFilter ? online.filter((d) => deviceFilter.has(d.name)) : online;
|
|
@@ -168,6 +168,8 @@ export interface InteractiveDispatchOptions {
|
|
|
168
168
|
agent: string;
|
|
169
169
|
/** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
|
|
170
170
|
version?: string;
|
|
171
|
+
/** Preserve the trailing-@ account picker so selection happens on the execution host. */
|
|
172
|
+
accountPicker?: boolean;
|
|
171
173
|
/** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
|
|
172
174
|
strategy?: string;
|
|
173
175
|
/** Named provider account to resolve on the remote host. */
|
|
@@ -417,7 +417,14 @@ export function buildRunForwardedArgs(opts) {
|
|
|
417
417
|
* infer headless from the prompt).
|
|
418
418
|
*/
|
|
419
419
|
export function buildInteractiveRunForwardedArgs(opts) {
|
|
420
|
-
|
|
420
|
+
if (opts.version && opts.accountPicker) {
|
|
421
|
+
throw new Error('Interactive host dispatch cannot combine an account picker with a version pin');
|
|
422
|
+
}
|
|
423
|
+
const agentArg = opts.accountPicker
|
|
424
|
+
? `${opts.agent}@`
|
|
425
|
+
: opts.version
|
|
426
|
+
? `${opts.agent}@${opts.version}`
|
|
427
|
+
: opts.agent;
|
|
421
428
|
const args = ['run', agentArg];
|
|
422
429
|
if (opts.prompt && opts.forceInteractive)
|
|
423
430
|
args.push(opts.prompt);
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* enrolled host shadows a same-name device in both list dedup and resolve
|
|
18
18
|
* order, exactly like the old tier-2 devices fall-through in resolveHost.
|
|
19
19
|
*/
|
|
20
|
-
import { loadDevices, getDevice
|
|
20
|
+
import { loadDevices, getDevice } from '../../devices/registry.js';
|
|
21
21
|
import { resolveDeviceProfile } from '../../devices/resolve-profile.js';
|
|
22
22
|
import { DeviceOffloadUnsupportedError } from '../types.js';
|
|
23
23
|
/** Tailscale's own presence bit, when the sync captured one. */
|
|
@@ -34,12 +34,6 @@ function statusOf(device) {
|
|
|
34
34
|
* precedence, carrying the caps.
|
|
35
35
|
*/
|
|
36
36
|
function deviceToPoolHost(rawDevice) {
|
|
37
|
-
// A control device (a cockpit, e.g. a paired iPhone) drives the fleet but
|
|
38
|
-
// never runs agents — it must never enter the host pool or be resolvable as a
|
|
39
|
-
// dispatch target, whatever platform it reports (an iPhone syncs as `unknown`,
|
|
40
|
-
// which remoteShellFor would otherwise default to POSIX and try to SSH).
|
|
41
|
-
if (isControlDevice(rawDevice))
|
|
42
|
-
return null;
|
|
43
37
|
// Effective profile: central config (ssh.*/platform) overlays discovery.
|
|
44
38
|
const device = resolveDeviceProfile(rawDevice);
|
|
45
39
|
const address = device.address.dnsName ?? device.address.ip;
|
|
@@ -78,12 +72,6 @@ export class DevicesHostProvider {
|
|
|
78
72
|
const raw = await getDevice(name);
|
|
79
73
|
if (!raw)
|
|
80
74
|
return null;
|
|
81
|
-
// Resolving is asking to dispatch. A control device can't run agents — fail
|
|
82
|
-
// loud with a clear message instead of attempting an SSH dispatch onto a
|
|
83
|
-
// phone (which remoteShellFor would treat as a POSIX host).
|
|
84
|
-
if (isControlDevice(raw)) {
|
|
85
|
-
throw new Error(`Device "${raw.name}" is a control device (a cockpit), not an executor — it can't run agents. Dispatch to a worker device instead.`);
|
|
86
|
-
}
|
|
87
75
|
// Effective profile: central config (ssh.*/platform) overlays discovery —
|
|
88
76
|
// the password-auth refusal and the dial shape both follow the config.
|
|
89
77
|
const device = resolveDeviceProfile(raw);
|
|
@@ -88,11 +88,20 @@ export declare function viewAgentVersions(view: string, agent: string): string[]
|
|
|
88
88
|
export declare function viewHasAgentVersion(view: string, agent: string, version: string): boolean | undefined;
|
|
89
89
|
/** Actionable fail-loud message for a missing remote pin (RUSH-2313). */
|
|
90
90
|
export declare function missingPinnedVersionMessage(hostName: string, agent: string, version: string, installed?: string[]): string;
|
|
91
|
+
/** Account eligibility extracted from one device's `agents view --json`. */
|
|
92
|
+
export interface ViewAgentAccountEligibility {
|
|
93
|
+
/** At least one account can launch immediately. */
|
|
94
|
+
signedIn: boolean | undefined;
|
|
95
|
+
/** At least one account can launch immediately or enter the harness login flow. */
|
|
96
|
+
pickerEligible: boolean | undefined;
|
|
97
|
+
}
|
|
91
98
|
/**
|
|
92
|
-
* Read
|
|
93
|
-
*
|
|
94
|
-
*
|
|
99
|
+
* Read the two account gates automatic placement needs from `agents view
|
|
100
|
+
* --json`. A picker may route to a signed-out version because launching it is
|
|
101
|
+
* the login flow, but it must not route to a device whose every signed-in
|
|
102
|
+
* account is throttled.
|
|
95
103
|
*/
|
|
104
|
+
export declare function viewAgentAccountEligibility(view: string, agent: string): ViewAgentAccountEligibility;
|
|
96
105
|
export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
|
|
97
106
|
export interface EnsureReadyOptions {
|
|
98
107
|
agent: string;
|
package/dist/lib/hosts/ready.js
CHANGED
|
@@ -12,6 +12,7 @@ import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
|
12
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
|
+
import { isDeadVerdict } from '../auth-health.js';
|
|
15
16
|
/** Resolve this CLI's own version by walking up to the nearest package.json. */
|
|
16
17
|
export function localCliVersion() {
|
|
17
18
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -208,27 +209,41 @@ export function missingPinnedVersionMessage(hostName, agent, version, installed)
|
|
|
208
209
|
`Install it on that box: agents ssh ${hostName} -- agents add ${agent}@${version}`);
|
|
209
210
|
}
|
|
210
211
|
/**
|
|
211
|
-
* Read
|
|
212
|
-
*
|
|
213
|
-
*
|
|
212
|
+
* Read the two account gates automatic placement needs from `agents view
|
|
213
|
+
* --json`. A picker may route to a signed-out version because launching it is
|
|
214
|
+
* the login flow, but it must not route to a device whose every signed-in
|
|
215
|
+
* account is throttled.
|
|
214
216
|
*/
|
|
215
|
-
export function
|
|
217
|
+
export function viewAgentAccountEligibility(view, agent) {
|
|
216
218
|
try {
|
|
217
219
|
const rows = JSON.parse(view);
|
|
218
220
|
const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
|
|
219
221
|
if (!row)
|
|
220
|
-
return undefined;
|
|
221
|
-
const verdicts = (row.versions ?? [])
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
222
|
+
return { signedIn: undefined, pickerEligible: undefined };
|
|
223
|
+
const verdicts = (row.versions ?? []).flatMap((version) => {
|
|
224
|
+
if (typeof version.signedIn !== 'boolean')
|
|
225
|
+
return [];
|
|
226
|
+
const throttled = version.usageStatus === 'rate_limited' || version.usageStatus === 'out_of_credits';
|
|
227
|
+
const authBlocked = version.authVerdict !== null
|
|
228
|
+
&& version.authVerdict !== undefined
|
|
229
|
+
&& isDeadVerdict(version.authVerdict);
|
|
230
|
+
const ready = version.signedIn && !authBlocked && !throttled;
|
|
231
|
+
return [{ ready, pickerEligible: ready || !version.signedIn || authBlocked }];
|
|
232
|
+
});
|
|
233
|
+
if (verdicts.length === 0)
|
|
234
|
+
return { signedIn: undefined, pickerEligible: undefined };
|
|
235
|
+
return {
|
|
236
|
+
signedIn: verdicts.some((verdict) => verdict.ready),
|
|
237
|
+
pickerEligible: verdicts.some((verdict) => verdict.pickerEligible),
|
|
238
|
+
};
|
|
227
239
|
}
|
|
228
240
|
catch {
|
|
229
|
-
return undefined;
|
|
241
|
+
return { signedIn: undefined, pickerEligible: undefined };
|
|
230
242
|
}
|
|
231
243
|
}
|
|
244
|
+
export function viewAgentSignedIn(view, agent) {
|
|
245
|
+
return viewAgentAccountEligibility(view, agent).signedIn;
|
|
246
|
+
}
|
|
232
247
|
/**
|
|
233
248
|
* Pure readiness verdict for the agent/version half of {@link ensureHostReady}
|
|
234
249
|
* (unit-tested without SSH). Returns warnings for soft agent-missing cases, or
|
|
@@ -27,8 +27,8 @@ export declare function getAllProviders(): HostProvider[];
|
|
|
27
27
|
* A resolved host, carrying the live {@link DeviceProfile} when the token
|
|
28
28
|
* matched a registered device (by normalized name). The device reference lets
|
|
29
29
|
* the `agents ssh` wrapper reach auth/shell/tailscale metadata a plain `Host`
|
|
30
|
-
* doesn't hold, and lets dispatch callers apply the device-only
|
|
31
|
-
* (
|
|
30
|
+
* doesn't hold, and lets dispatch callers apply the device-only refusal
|
|
31
|
+
* (password auth) without re-reading the registry.
|
|
32
32
|
*/
|
|
33
33
|
export interface ResolvedHost extends Host {
|
|
34
34
|
device?: DeviceProfile;
|
|
@@ -65,7 +65,7 @@ export interface MatchHostOptions {
|
|
|
65
65
|
* all resolve the same way regardless of which subcommand called.
|
|
66
66
|
*
|
|
67
67
|
* Non-throwing: returns null on an injection-guard failure or an unresolved bare
|
|
68
|
-
* token.
|
|
68
|
+
* token. The device-only refusal (password auth) is NOT applied
|
|
69
69
|
* here — that is the dispatch wrapper's job ({@link resolveHost}), so the fan-out
|
|
70
70
|
* and `agents ssh` paths, which handle those cases differently, aren't forced
|
|
71
71
|
* into a dispatch verdict.
|
|
@@ -81,8 +81,7 @@ export declare function listAllHosts(): Promise<Host[]>;
|
|
|
81
81
|
* caller consumes (`run --device`, the generic passthrough, teams placement, the
|
|
82
82
|
* cloud host provider, doctor, funnel, remote secrets). Grammar and merge come
|
|
83
83
|
* from {@link matchHost}; on top, this layer applies the device-only dispatch
|
|
84
|
-
*
|
|
85
|
-
* - a control device (a paired iPhone) can never be a dispatch target;
|
|
84
|
+
* refusal so it holds even when an inline overlay shadows the device:
|
|
86
85
|
* - a password-auth device throws the typed {@link DeviceOffloadUnsupportedError}
|
|
87
86
|
* (offload rides `BatchMode=yes` ssh, which can't answer a prompt);
|
|
88
87
|
* - an addressless device has nothing to dial.
|