@phnx-labs/agents-cli 1.22.104 → 1.22.105
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
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.105
|
|
4
|
+
|
|
5
|
+
- Computer setup installs standalone Computer CLI 0.1.2, whose application code is obfuscated before compilation to increase inspection effort. Agents continues to provide permissions and session tracking through its adapter.
|
|
6
|
+
|
|
7
|
+
- Fleet test runs use separate temporary workspaces and clean up their own copies. Router account fixtures run only with isolated file-backed secret storage, preserving native keyrings.
|
|
8
|
+
|
|
9
|
+
- **Device picker module for `agents run <agent>@` (PHNX-4083).** New
|
|
10
|
+
`run-device-picker` command module builds the fleet device menu entirely from
|
|
11
|
+
on-disk state — the device registry, the cached fleet stats, configured roles
|
|
12
|
+
and descriptions, and the fleet-synced account catalog — with zero SSH, zero
|
|
13
|
+
network, and no re-probing of stale rows. Source: `src/commands/run-device-picker.ts`.
|
|
14
|
+
|
|
3
15
|
## 1.22.104
|
|
4
16
|
|
|
5
17
|
- **Interactive Claude runs on a worker fail loud instead of dropping to the login screen (PHNX-3502 sibling).** `agents run claude --interactive --device auto/<worker>` whose selected account had no synced setup-token used to fall through to Claude Code's "Select login method" screen — an interactive OAuth on a headless box that never persists, so Anthropic re-prompted on the next run (the repeated re-login loop). The run is now refused before spawn with the real fix (pin a live account with `agents run claude#<name>`, set the fleet default with `agents accounts default claude <name>`, or mint the account's token on a headed box with `agents accounts login claude#<name>`). Headless runs are unchanged (they already fail loud with a 401), and headed boxes still show their normal native-login prompt. Source: `cli/src/lib/harness/adapters/claude.ts`, `cli/src/lib/exec.ts`.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AgentId } from '../lib/types.js';
|
|
2
|
+
import { type Headroom } from '../lib/devices/health.js';
|
|
3
|
+
/** One row of the menu, already resolved from disk. Pure data so ordering is testable. */
|
|
4
|
+
export interface RunDeviceRow {
|
|
5
|
+
name: string;
|
|
6
|
+
platform?: string;
|
|
7
|
+
isLocal: boolean;
|
|
8
|
+
online: 'online' | 'offline' | 'unknown';
|
|
9
|
+
role?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
loadPercent?: number;
|
|
12
|
+
memPercent?: number;
|
|
13
|
+
headroom: Headroom;
|
|
14
|
+
statsFetchedAt?: number;
|
|
15
|
+
lastSeenAt?: string;
|
|
16
|
+
hasAccount?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface RunDeviceChoice {
|
|
19
|
+
name: string;
|
|
20
|
+
value: string;
|
|
21
|
+
disabled?: boolean | string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Pure. Ordering: this machine first; then online rows by headroom (idle,
|
|
25
|
+
* light, busy, loaded, unknown) then loadPercent ascending then name; then
|
|
26
|
+
* unknown-state rows; then offline rows, disabled with the last-seen time.
|
|
27
|
+
*
|
|
28
|
+
* `accountLabel` renders the ✓/– account mark on rows whose `hasAccount` the
|
|
29
|
+
* catalog answered; without it the mark is omitted (hasAccount stays on the
|
|
30
|
+
* row for the caller either way).
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildRunDeviceChoices(rows: RunDeviceRow[], accountLabel?: string): RunDeviceChoice[];
|
|
33
|
+
/**
|
|
34
|
+
* Reads devices.yaml + the cached fleet stats + configured roles + (optionally)
|
|
35
|
+
* whether `accountLabel` exists on each box. ZERO SSH, zero network, never
|
|
36
|
+
* re-probes. Returns the rows and the age of the newest stats row (undefined
|
|
37
|
+
* when no cache).
|
|
38
|
+
*/
|
|
39
|
+
export declare function readRunDeviceRows(opts: {
|
|
40
|
+
agent: AgentId;
|
|
41
|
+
accountLabel?: string;
|
|
42
|
+
}): {
|
|
43
|
+
rows: RunDeviceRow[];
|
|
44
|
+
snapshotAgeMs?: number;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Interactive menu. Off a TTY: fail loud with the non-interactive forms,
|
|
48
|
+
* exactly like the account picker. Prompt message: `Select a device for this
|
|
49
|
+
* run (fleet state as of <age>):` where <age> is e.g. '2 min ago', or
|
|
50
|
+
* `Select a device for this run (no cached fleet state):`. Returns the device
|
|
51
|
+
* name, or null when the user cancels (Esc/Ctrl-C) — a cancel launches
|
|
52
|
+
* nothing. Throws when the registry has no devices at all (the message names
|
|
53
|
+
* `agents devices add`).
|
|
54
|
+
*/
|
|
55
|
+
export declare function pickRunDevice(opts: {
|
|
56
|
+
agent: AgentId;
|
|
57
|
+
accountLabel?: string;
|
|
58
|
+
}): Promise<string | null>;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device picker for `agents run <agent>@` (PHNX-4083).
|
|
3
|
+
*
|
|
4
|
+
* The menu behind the trailing-`@` form: every registered fleet device,
|
|
5
|
+
* rendered from files the daemon already keeps on disk — the SSH device
|
|
6
|
+
* registry, the cached fleet stats (`.fleet-stats.json`), the fleet-synced
|
|
7
|
+
* device docs (roles + descriptions), and the fleet-synced account catalog.
|
|
8
|
+
*
|
|
9
|
+
* This module NEVER probes: no SSH, no network, no re-fetch of stale rows.
|
|
10
|
+
* `loadFleetStats` re-probes rows older than 3 minutes over SSH, which is the
|
|
11
|
+
* multi-second hang a mid-run menu cannot afford; the whole point here is that
|
|
12
|
+
* the picker renders the last cached fleet state, honestly aged ("fleet state
|
|
13
|
+
* as of 2 min ago"), instead of re-deriving it live.
|
|
14
|
+
*
|
|
15
|
+
* PR 2 wires this into `agents run`; this module ships the data + menu only.
|
|
16
|
+
*/
|
|
17
|
+
import { select } from '@inquirer/prompts';
|
|
18
|
+
import { headroom } from '../lib/devices/health.js';
|
|
19
|
+
import { loadDevicesSync } from '../lib/devices/registry.js';
|
|
20
|
+
import { readStatsCache } from '../lib/devices/stats-cache.js';
|
|
21
|
+
import { deviceOnlineState } from '../lib/devices/reachability.js';
|
|
22
|
+
import { isSelfHost } from '../lib/devices/self-host.js';
|
|
23
|
+
import { normalizeHost } from '../lib/machine-id.js';
|
|
24
|
+
import { localMachineId } from '../lib/origin-machine.js';
|
|
25
|
+
import { listConfiguredDeviceRoles, readDeviceConfigValues } from '../lib/device-config.js';
|
|
26
|
+
import { listNativeAccounts } from '../lib/account-registry.js';
|
|
27
|
+
import { readSharedAccountVerdicts } from '../lib/account-catalog.js';
|
|
28
|
+
import { readMeta } from '../lib/state.js';
|
|
29
|
+
import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
|
|
30
|
+
const HEADROOM_ORDER = {
|
|
31
|
+
idle: 0,
|
|
32
|
+
light: 1,
|
|
33
|
+
busy: 2,
|
|
34
|
+
loaded: 3,
|
|
35
|
+
unknown: 4,
|
|
36
|
+
};
|
|
37
|
+
function formatPercentCell(value, suffix) {
|
|
38
|
+
return value === undefined ? '—' : `${Math.round(value)}% ${suffix}`;
|
|
39
|
+
}
|
|
40
|
+
/** 'HH:MM' (local) from an ISO timestamp; the raw string when it does not parse. */
|
|
41
|
+
function formatHourMinute(iso) {
|
|
42
|
+
const d = new Date(iso);
|
|
43
|
+
if (Number.isNaN(d.getTime()))
|
|
44
|
+
return iso;
|
|
45
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
46
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
47
|
+
return `${hh}:${mm}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pure. Ordering: this machine first; then online rows by headroom (idle,
|
|
51
|
+
* light, busy, loaded, unknown) then loadPercent ascending then name; then
|
|
52
|
+
* unknown-state rows; then offline rows, disabled with the last-seen time.
|
|
53
|
+
*
|
|
54
|
+
* `accountLabel` renders the ✓/– account mark on rows whose `hasAccount` the
|
|
55
|
+
* catalog answered; without it the mark is omitted (hasAccount stays on the
|
|
56
|
+
* row for the caller either way).
|
|
57
|
+
*/
|
|
58
|
+
export function buildRunDeviceChoices(rows, accountLabel) {
|
|
59
|
+
const byHeadroomLoadName = (a, b) => HEADROOM_ORDER[a.headroom] - HEADROOM_ORDER[b.headroom]
|
|
60
|
+
|| (a.loadPercent ?? Number.POSITIVE_INFINITY) - (b.loadPercent ?? Number.POSITIVE_INFINITY)
|
|
61
|
+
|| a.name.localeCompare(b.name);
|
|
62
|
+
const local = rows.filter((row) => row.isLocal);
|
|
63
|
+
const online = rows.filter((row) => !row.isLocal && row.online === 'online').sort(byHeadroomLoadName);
|
|
64
|
+
const unknownState = rows.filter((row) => !row.isLocal && row.online === 'unknown').sort(byHeadroomLoadName);
|
|
65
|
+
const offline = rows
|
|
66
|
+
.filter((row) => !row.isLocal && row.online === 'offline')
|
|
67
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
68
|
+
const ordered = [...local, ...online, ...unknownState, ...offline];
|
|
69
|
+
const nameWidth = Math.max(0, ...ordered.map((row) => row.name.length));
|
|
70
|
+
const platformWidth = Math.max(0, ...ordered.map((row) => row.platform?.length ?? 0));
|
|
71
|
+
const statusWidth = Math.max(0, ...ordered.map((row) => (row.isLocal ? 'this machine' : row.online).length));
|
|
72
|
+
const headroomWidth = Math.max(0, ...ordered.map((row) => row.headroom.length));
|
|
73
|
+
const loadWidth = Math.max(0, ...ordered.map((row) => formatPercentCell(row.loadPercent, 'load').length));
|
|
74
|
+
const memWidth = Math.max(0, ...ordered.map((row) => formatPercentCell(row.memPercent, 'mem').length));
|
|
75
|
+
return ordered.map((row) => {
|
|
76
|
+
const account = accountLabel !== undefined && row.hasAccount !== undefined
|
|
77
|
+
? `${row.hasAccount ? '✓' : '–'} ${accountLabel}`
|
|
78
|
+
: undefined;
|
|
79
|
+
const name = [
|
|
80
|
+
row.name.padEnd(nameWidth),
|
|
81
|
+
(row.platform ?? '').padEnd(platformWidth),
|
|
82
|
+
(row.isLocal ? 'this machine' : row.online).padEnd(statusWidth),
|
|
83
|
+
row.headroom.padEnd(headroomWidth),
|
|
84
|
+
formatPercentCell(row.loadPercent, 'load').padEnd(loadWidth),
|
|
85
|
+
formatPercentCell(row.memPercent, 'mem').padEnd(memWidth),
|
|
86
|
+
row.role,
|
|
87
|
+
account,
|
|
88
|
+
].filter((segment) => segment !== undefined && segment !== '').join(' · ');
|
|
89
|
+
return {
|
|
90
|
+
name: name.trimEnd(),
|
|
91
|
+
value: row.name,
|
|
92
|
+
...(row.online === 'offline'
|
|
93
|
+
? { disabled: row.lastSeenAt ? `offline since ${formatHourMinute(row.lastSeenAt)}` : 'offline' }
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The catalog's per-device answer for one account label, straight off the
|
|
100
|
+
* fleet-synced shared state (the same rows `agents view` renders): the daemon
|
|
101
|
+
* on each box publishes an auth verdict per registered account, and absence of
|
|
102
|
+
* a verdict row on a publishing box means the account is not provisioned
|
|
103
|
+
* there. A box that publishes no account rows at all (an older release) cannot
|
|
104
|
+
* be answered for — its devices stay `undefined`, never guessed.
|
|
105
|
+
*/
|
|
106
|
+
function resolveAccountDevices(agent, accountLabel) {
|
|
107
|
+
const account = listNativeAccounts(readMeta()).find((row) => row.agent === agent && (row.name === accountLabel || row.id === accountLabel));
|
|
108
|
+
if (!account)
|
|
109
|
+
return undefined; // the catalog has no such account — it cannot answer for any device
|
|
110
|
+
const shared = readSharedAccountVerdicts();
|
|
111
|
+
const publishing = new Set();
|
|
112
|
+
for (const rows of shared.values()) {
|
|
113
|
+
for (const row of rows)
|
|
114
|
+
publishing.add(normalizeHost(row.device));
|
|
115
|
+
}
|
|
116
|
+
const byDevice = new Map();
|
|
117
|
+
for (const row of shared.get(`${agent}:${account.id}`) ?? []) {
|
|
118
|
+
byDevice.set(normalizeHost(row.device), row.verdict !== 'missing');
|
|
119
|
+
}
|
|
120
|
+
return { byDevice, publishing };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Reads devices.yaml + the cached fleet stats + configured roles + (optionally)
|
|
124
|
+
* whether `accountLabel` exists on each box. ZERO SSH, zero network, never
|
|
125
|
+
* re-probes. Returns the rows and the age of the newest stats row (undefined
|
|
126
|
+
* when no cache).
|
|
127
|
+
*/
|
|
128
|
+
export function readRunDeviceRows(opts) {
|
|
129
|
+
const registry = loadDevicesSync();
|
|
130
|
+
const statsCache = readStatsCache();
|
|
131
|
+
const names = Object.keys(registry);
|
|
132
|
+
const roles = listConfiguredDeviceRoles(names);
|
|
133
|
+
const local = normalizeHost(localMachineId());
|
|
134
|
+
const accountDevices = opts.accountLabel
|
|
135
|
+
? resolveAccountDevices(opts.agent, opts.accountLabel)
|
|
136
|
+
: undefined;
|
|
137
|
+
let newestFetchedAt;
|
|
138
|
+
for (const stats of Object.values(statsCache)) {
|
|
139
|
+
if (newestFetchedAt === undefined || stats.fetchedAt > newestFetchedAt)
|
|
140
|
+
newestFetchedAt = stats.fetchedAt;
|
|
141
|
+
}
|
|
142
|
+
const snapshotAgeMs = newestFetchedAt === undefined ? undefined : Math.max(0, Date.now() - newestFetchedAt);
|
|
143
|
+
const rows = names.map((name) => {
|
|
144
|
+
const profile = registry[name];
|
|
145
|
+
const stats = statsCache[name];
|
|
146
|
+
const online = deviceOnlineState(profile, stats);
|
|
147
|
+
const config = readDeviceConfigValues(name);
|
|
148
|
+
const description = typeof config.description === 'string' ? config.description : undefined;
|
|
149
|
+
const host = normalizeHost(name);
|
|
150
|
+
return {
|
|
151
|
+
name,
|
|
152
|
+
platform: profile.platform === 'unknown' ? undefined : profile.platform,
|
|
153
|
+
isLocal: isSelfHost(name) || host === local,
|
|
154
|
+
online,
|
|
155
|
+
role: roles[name],
|
|
156
|
+
description,
|
|
157
|
+
loadPercent: stats?.loadPercent,
|
|
158
|
+
memPercent: stats?.memPercent,
|
|
159
|
+
headroom: headroom(stats),
|
|
160
|
+
statsFetchedAt: stats?.fetchedAt,
|
|
161
|
+
lastSeenAt: online === 'offline'
|
|
162
|
+
? profile.reachability?.checkedAt ?? profile.tailscale?.lastSeen
|
|
163
|
+
: undefined,
|
|
164
|
+
hasAccount: accountDevices
|
|
165
|
+
? accountDevices.publishing.has(host)
|
|
166
|
+
? accountDevices.byDevice.get(host) ?? false
|
|
167
|
+
: undefined
|
|
168
|
+
: undefined,
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
return { rows, snapshotAgeMs };
|
|
172
|
+
}
|
|
173
|
+
/** Human age for the prompt's "fleet state as of …" note: 'just now', '2 min ago', '3 h ago', … */
|
|
174
|
+
function formatSnapshotAge(ageMs) {
|
|
175
|
+
if (ageMs < 45_000)
|
|
176
|
+
return 'just now';
|
|
177
|
+
if (ageMs < 90_000)
|
|
178
|
+
return '1 min ago';
|
|
179
|
+
if (ageMs < 3_600_000)
|
|
180
|
+
return `${Math.round(ageMs / 60_000)} min ago`;
|
|
181
|
+
if (ageMs < 86_400_000)
|
|
182
|
+
return `${Math.round(ageMs / 3_600_000)} h ago`;
|
|
183
|
+
return `${Math.round(ageMs / 86_400_000)} d ago`;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Interactive menu. Off a TTY: fail loud with the non-interactive forms,
|
|
187
|
+
* exactly like the account picker. Prompt message: `Select a device for this
|
|
188
|
+
* run (fleet state as of <age>):` where <age> is e.g. '2 min ago', or
|
|
189
|
+
* `Select a device for this run (no cached fleet state):`. Returns the device
|
|
190
|
+
* name, or null when the user cancels (Esc/Ctrl-C) — a cancel launches
|
|
191
|
+
* nothing. Throws when the registry has no devices at all (the message names
|
|
192
|
+
* `agents devices add`).
|
|
193
|
+
*/
|
|
194
|
+
export async function pickRunDevice(opts) {
|
|
195
|
+
// An empty registry is wrong on every terminal, so it is judged before the
|
|
196
|
+
// TTY gate: "register a device" is the useful answer, not "need a TTY".
|
|
197
|
+
const { rows, snapshotAgeMs } = readRunDeviceRows(opts);
|
|
198
|
+
if (rows.length === 0) {
|
|
199
|
+
throw new Error('No devices are registered. Add one with: agents devices add <name>');
|
|
200
|
+
}
|
|
201
|
+
if (!isInteractiveTerminal()) {
|
|
202
|
+
requireInteractiveSelection('Selecting a device', [
|
|
203
|
+
`agents run ${opts.agent} --device <name>`,
|
|
204
|
+
'agents devices',
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
const choices = buildRunDeviceChoices(rows, opts.accountLabel);
|
|
208
|
+
try {
|
|
209
|
+
return await select({
|
|
210
|
+
message: snapshotAgeMs !== undefined
|
|
211
|
+
? `Select a device for this run (fleet state as of ${formatSnapshotAge(snapshotAgeMs)}):`
|
|
212
|
+
: 'Select a device for this run (no cached fleet state):',
|
|
213
|
+
choices,
|
|
214
|
+
loop: false,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (isPromptCancelled(err))
|
|
219
|
+
return null;
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -43,8 +43,8 @@ export async function runComputerWizard() {
|
|
|
43
43
|
catch (error) {
|
|
44
44
|
if (!(error instanceof ComputerClientError) || error.code !== 'COMPUTER_BIN_MISSING')
|
|
45
45
|
throw error;
|
|
46
|
-
console.log('Installing @phnx-labs/computer-cli@0.1.
|
|
47
|
-
const installed = spawnSync('npm', ['install', '-g', '@phnx-labs/computer-cli@0.1.
|
|
46
|
+
console.log('Installing @phnx-labs/computer-cli@0.1.2…');
|
|
47
|
+
const installed = spawnSync('npm', ['install', '-g', '@phnx-labs/computer-cli@0.1.2'], { stdio: 'inherit' });
|
|
48
48
|
if (installed.status !== 0)
|
|
49
49
|
return false;
|
|
50
50
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.105",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|