@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.1…');
47
- const installed = spawnSync('npm', ['install', '-g', '@phnx-labs/computer-cli@0.1.1'], { stdio: 'inherit' });
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.104",
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",