@phnx-labs/agents-cli 1.22.103 → 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 +39 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +2 -2
- package/dist/commands/computer.d.ts +100 -79
- package/dist/commands/computer.js +290 -830
- package/dist/commands/run-device-picker.d.ts +58 -0
- package/dist/commands/run-device-picker.js +222 -0
- package/dist/commands/setup-computer.js +20 -1
- package/dist/commands/setup-secrets.d.ts +2 -2
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/view.js +4 -6
- package/dist/lib/account-catalog.d.ts +22 -1
- package/dist/lib/account-catalog.js +72 -38
- package/dist/lib/accounting/usage.js +18 -14
- package/dist/lib/agent-spec/agents.d.ts +1 -0
- package/dist/lib/agent-spec/agents.js +1 -1
- package/dist/lib/browser/drivers/ssh.js +1 -1
- package/dist/lib/computer/context.d.ts +83 -0
- package/dist/lib/computer/context.js +91 -0
- package/dist/lib/computer/policy.d.ts +46 -0
- package/dist/lib/computer/policy.js +160 -0
- package/dist/lib/computer/record.d.ts +38 -0
- package/dist/lib/computer/record.js +86 -0
- package/dist/lib/computer/sessions-list.js +6 -6
- package/dist/lib/computer-client.d.ts +150 -0
- package/dist/lib/computer-client.js +222 -0
- package/dist/lib/exec.js +35 -1
- package/dist/lib/harness/adapters/claude.d.ts +37 -0
- package/dist/lib/harness/adapters/claude.js +69 -0
- package/dist/lib/helper-download.d.ts +1 -1
- package/dist/lib/helper-download.js +1 -1
- package/dist/lib/helper-versions.d.ts +9 -4
- package/dist/lib/helper-versions.js +8 -8
- package/dist/lib/installations/shims.js +8 -4
- package/dist/lib/menubar/download-menubar.d.ts +2 -1
- package/dist/lib/menubar/download-menubar.js +2 -1
- package/dist/lib/secrets-client.d.ts +3 -3
- package/dist/lib/secrets-client.js +15 -38
- package/dist/lib/session/db.js +1 -1
- package/dist/lib/sha256-asset.d.ts +2 -1
- package/dist/lib/sha256-asset.js +2 -1
- package/dist/lib/ssh-tunnel.d.ts +61 -0
- package/dist/lib/ssh-tunnel.js +105 -0
- package/dist/lib/summarizer/summarize.d.ts +2 -2
- package/dist/lib/summarizer/summarize.js +10 -3
- package/package.json +2 -3
- package/dist/commands/computer-actions.d.ts +0 -55
- package/dist/commands/computer-actions.js +0 -594
- package/dist/computer.d.ts +0 -2
- package/dist/computer.js +0 -7
- package/dist/lib/computer/actions.d.ts +0 -36
- package/dist/lib/computer/actions.js +0 -162
- package/dist/lib/computer/computer-rpc.d.ts +0 -39
- package/dist/lib/computer/computer-rpc.js +0 -447
- package/dist/lib/computer/des.d.ts +0 -1
- package/dist/lib/computer/des.js +0 -114
- package/dist/lib/computer/dispatch.d.ts +0 -10
- package/dist/lib/computer/dispatch.js +0 -133
- package/dist/lib/computer/download.d.ts +0 -54
- package/dist/lib/computer/download.js +0 -83
- package/dist/lib/computer/loop.d.ts +0 -62
- package/dist/lib/computer/loop.js +0 -98
- package/dist/lib/computer/model.d.ts +0 -44
- package/dist/lib/computer/model.js +0 -157
- package/dist/lib/computer/rfb-client.d.ts +0 -53
- package/dist/lib/computer/rfb-client.js +0 -562
- package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
- package/dist/lib/computer/ssh-tunnel.js +0 -584
|
@@ -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
|
+
}
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* Idempotent: re-running re-installs the current helper and re-checks trust.
|
|
10
10
|
*/
|
|
11
11
|
import os from 'node:os';
|
|
12
|
-
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
13
13
|
import chalk from 'chalk';
|
|
14
14
|
import { installComputerHelperMacLocal, activateComputerHelperMacLocal, probeComputerTrust, } from './computer.js';
|
|
15
|
+
import { resolveComputerBin, ComputerClientError } from '../lib/computer-client.js';
|
|
15
16
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
16
17
|
const ACCESSIBILITY_PANE = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility';
|
|
17
18
|
const SCREEN_PANE = 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture';
|
|
@@ -36,6 +37,24 @@ export async function runComputerWizard() {
|
|
|
36
37
|
console.log(chalk.dim('On Windows, provision a remote host from a Mac/Linux box instead: `agents computer setup --device <device>`.'));
|
|
37
38
|
return false;
|
|
38
39
|
}
|
|
40
|
+
try {
|
|
41
|
+
resolveComputerBin();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!(error instanceof ComputerClientError) || error.code !== 'COMPUTER_BIN_MISSING')
|
|
45
|
+
throw error;
|
|
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
|
+
if (installed.status !== 0)
|
|
49
|
+
return false;
|
|
50
|
+
try {
|
|
51
|
+
resolveComputerBin();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
console.error('Computer CLI installation did not produce an executable on PATH.');
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
39
58
|
// 1. Download + verify + install the signed, notarized helper.
|
|
40
59
|
console.log(chalk.bold('Installing the Agents Computer helper...'));
|
|
41
60
|
try {
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* `npm i -g`. Users do not set extra env vars.
|
|
9
9
|
*/
|
|
10
10
|
import type { Command } from 'commander';
|
|
11
|
-
export declare const SECRETS_CLI_PACKAGE = "@phnx-labs/secrets-cli@0.1.
|
|
12
|
-
export declare const INSTALL_HINT = "agents clis install secrets # or: npm i -g @phnx-labs/secrets-cli@0.1.
|
|
11
|
+
export declare const SECRETS_CLI_PACKAGE = "@phnx-labs/secrets-cli@0.1.4";
|
|
12
|
+
export declare const INSTALL_HINT = "agents clis install secrets # or: npm i -g @phnx-labs/secrets-cli@0.1.4";
|
|
13
13
|
export declare function setupSecretsPrefsPath(): string;
|
|
14
14
|
/** True when the standalone `secrets` executable resolves ($SECRETS_BIN or PATH). */
|
|
15
15
|
export declare function isSecretsCliInstalled(): boolean;
|
|
@@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
14
14
|
import { getHistoryDir } from '../lib/state.js';
|
|
15
15
|
import { resolveSecretsBin, invocation, SecretsClientError, _resetSecretsClientForTest } from '../lib/secrets-client.js';
|
|
16
16
|
import { installCli, resolveCliManifest } from '../lib/cli-resources.js';
|
|
17
|
-
export const SECRETS_CLI_PACKAGE = '@phnx-labs/secrets-cli@0.1.
|
|
17
|
+
export const SECRETS_CLI_PACKAGE = '@phnx-labs/secrets-cli@0.1.4';
|
|
18
18
|
export const INSTALL_HINT = `agents clis install secrets # or: npm i -g ${SECRETS_CLI_PACKAGE}`;
|
|
19
19
|
export function setupSecretsPrefsPath() {
|
|
20
20
|
return path.join(getHistoryDir(), 'setup', 'secrets.json');
|
package/dist/commands/view.js
CHANGED
|
@@ -31,7 +31,7 @@ import { isCapable } from '../lib/capabilities.js';
|
|
|
31
31
|
import { discoverPlugins, pluginSupportsAgent } from '../lib/plugins/plugins.js';
|
|
32
32
|
import { getAgentsDir, getUserAgentsDir, getEffectivePromptcutsPath, readMergedPromptcuts, readMeta } from '../lib/state.js';
|
|
33
33
|
import { findNativeAccountByIdentity } from '../lib/account-registry.js';
|
|
34
|
-
import { accountListJson, loadAccountCatalog, renderAccountRows, secretsUnavailableNote } from '../lib/account-catalog.js';
|
|
34
|
+
import { ACCOUNT_LISTING_LEGEND, accountListJson, loadAccountCatalog, renderAccountRows, secretsUnavailableNote } from '../lib/account-catalog.js';
|
|
35
35
|
import { addSupported } from '../lib/accounts/add.js';
|
|
36
36
|
import { readInstallation } from '../lib/installations/store.js';
|
|
37
37
|
import { isAutoUpdateEnabledForAgent } from '../lib/installations/update-policy.js';
|
|
@@ -557,12 +557,10 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
|
|
|
557
557
|
}
|
|
558
558
|
if (filterAgentId) {
|
|
559
559
|
console.log(chalk.gray(` Add an account: agents accounts add ${filterAgentId} [name]`));
|
|
560
|
-
console.log(chalk.gray(' STATE: LIVE ready · LIMITED rate-limited · EXPIRED needs refresh · REVOKED needs login · UNVERIFIED unconfirmed · MISSING not provisioned · * stale usage\n'));
|
|
561
|
-
}
|
|
562
|
-
else {
|
|
563
|
-
// Overview also renders account tables (footer:false) — explain STATE there too.
|
|
564
|
-
console.log(chalk.gray(' STATE: LIVE ready · LIMITED rate-limited · EXPIRED needs refresh · REVOKED needs login · UNVERIFIED unconfirmed · MISSING not provisioned · * stale usage\n'));
|
|
565
560
|
}
|
|
561
|
+
// Overview renders the same account rows with footer:false, so both paths
|
|
562
|
+
// need the legend the shared renderer would otherwise have printed.
|
|
563
|
+
console.log(chalk.gray(` ${ACCOUNT_LISTING_LEGEND}\n`));
|
|
566
564
|
}
|
|
567
565
|
if (versionManaged.length > 0 && viewOpts?.versions) {
|
|
568
566
|
// Calculate column widths across all agents for alignment
|
|
@@ -201,8 +201,29 @@ export declare function loadAccountCatalog(): Promise<AccountCatalog>;
|
|
|
201
201
|
*/
|
|
202
202
|
export declare function secretsUnavailableNote(catalog: Pick<AccountCatalog, 'secretsUnavailable'>): string | null;
|
|
203
203
|
export declare function accountListJson(native: NativeAccountCatalogRow[], providers?: ProviderAccountCatalogRow[], harness?: AgentId): AccountListJson;
|
|
204
|
-
|
|
204
|
+
/**
|
|
205
|
+
* A state worth an operator's attention, rendered at the END of the row.
|
|
206
|
+
* `live`, `ready`, `unverified` and `per-device` are the ordinary cases: they
|
|
207
|
+
* were on every row of the old table and pushed it past the terminal width
|
|
208
|
+
* without telling anyone anything. They stay in `accounts list --fleet`,
|
|
209
|
+
* `accounts view <name>` and `--json`.
|
|
210
|
+
*/
|
|
211
|
+
export declare function verdictNote(verdict: AccountVerdict): string | null;
|
|
212
|
+
/**
|
|
213
|
+
* Device coverage, but only when it SPLITS the fleet: some boxes can use the
|
|
214
|
+
* account and some cannot. Full coverage is the ordinary case, and "none of
|
|
215
|
+
* them" is already exactly what the row's state note says, so both render
|
|
216
|
+
* nothing — `accounts list --fleet` is the per-device table.
|
|
217
|
+
*/
|
|
218
|
+
export declare function coverageNote(row: NativeAccountCatalogRow, localDevice: string): string | null;
|
|
205
219
|
export declare const OVERVIEW_MAX_USAGE_WINDOWS = 2;
|
|
220
|
+
/**
|
|
221
|
+
* The one line under an account listing. It explains the two marks a row can
|
|
222
|
+
* carry and names where the columns the listing no longer prints — identity,
|
|
223
|
+
* per-device state — still live. Shared with `agents view` so the two surfaces
|
|
224
|
+
* cannot drift.
|
|
225
|
+
*/
|
|
226
|
+
export declare const ACCOUNT_LISTING_LEGEND = "* stale usage \u00B7 a healthy account carries no state \u00B7 identity + per-box state: agents accounts list --fleet";
|
|
206
227
|
/** Shared text renderer used by both `accounts list` and account-first `view`. */
|
|
207
228
|
export declare function renderAccountRows(rows: NativeAccountCatalogRow[], opts: {
|
|
208
229
|
heading?: boolean;
|
|
@@ -367,35 +367,55 @@ export function accountListJson(native, providers = [], harness) {
|
|
|
367
367
|
],
|
|
368
368
|
};
|
|
369
369
|
}
|
|
370
|
-
|
|
370
|
+
/**
|
|
371
|
+
* A state worth an operator's attention, rendered at the END of the row.
|
|
372
|
+
* `live`, `ready`, `unverified` and `per-device` are the ordinary cases: they
|
|
373
|
+
* were on every row of the old table and pushed it past the terminal width
|
|
374
|
+
* without telling anyone anything. They stay in `accounts list --fleet`,
|
|
375
|
+
* `accounts view <name>` and `--json`.
|
|
376
|
+
*/
|
|
377
|
+
export function verdictNote(verdict) {
|
|
371
378
|
if (verdict === 'rate_limited')
|
|
372
|
-
return '
|
|
373
|
-
|
|
379
|
+
return chalk.yellow('rate-limited');
|
|
380
|
+
if (verdict === 'expired' || verdict === 'revoked' || verdict === 'missing') {
|
|
381
|
+
return chalk.red(verdict);
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
374
384
|
}
|
|
375
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Device coverage, but only when it SPLITS the fleet: some boxes can use the
|
|
387
|
+
* account and some cannot. Full coverage is the ordinary case, and "none of
|
|
388
|
+
* them" is already exactly what the row's state note says, so both render
|
|
389
|
+
* nothing — `accounts list --fleet` is the per-device table.
|
|
390
|
+
*/
|
|
391
|
+
export function coverageNote(row, localDevice) {
|
|
376
392
|
const names = [...new Set(row.devices.map((device) => device.device))];
|
|
377
393
|
// Peers on an older release do not publish slot verdicts, so the only
|
|
378
|
-
// observation is this box —
|
|
394
|
+
// observation is this box — that is a gap in what we can see, not a gap in
|
|
395
|
+
// provisioning, so it is not an alarm.
|
|
379
396
|
if (names.length === 1 && names[0] === localDevice)
|
|
380
|
-
return
|
|
381
|
-
// Per-device accounts are provisioned per box;
|
|
382
|
-
// rather than a
|
|
397
|
+
return null;
|
|
398
|
+
// Per-device accounts are provisioned per box; name the boxes it is NOT on
|
|
399
|
+
// rather than a usable/total fraction — the fraction would hide that each box
|
|
383
400
|
// is its own account.
|
|
384
401
|
if (row.provisioning === 'per-device') {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
.map((device) => device.device);
|
|
388
|
-
return present.join(', ') || 'this box';
|
|
402
|
+
const absent = row.devices.filter((device) => device.verdict === 'missing').map((device) => device.device);
|
|
403
|
+
return absent.length > 0 && absent.length < row.devices.length ? `not on ${absent.join(', ')}` : null;
|
|
389
404
|
}
|
|
390
405
|
const provisioned = row.devices.filter((device) => device.verdict !== 'missing').length;
|
|
391
|
-
if (provisioned === 0)
|
|
392
|
-
return '—';
|
|
393
406
|
const usable = row.devices.filter((device) => device.verdict === 'live' || device.verdict === 'rate_limited' || device.verdict === 'unverified').length;
|
|
394
|
-
if (usable === provisioned)
|
|
395
|
-
return
|
|
396
|
-
return `on ${usable} of ${provisioned} boxes`;
|
|
407
|
+
if (usable === 0 || usable === provisioned)
|
|
408
|
+
return null;
|
|
409
|
+
return `usable on ${usable} of ${provisioned} boxes`;
|
|
397
410
|
}
|
|
398
411
|
export const OVERVIEW_MAX_USAGE_WINDOWS = 2;
|
|
412
|
+
/**
|
|
413
|
+
* The one line under an account listing. It explains the two marks a row can
|
|
414
|
+
* carry and names where the columns the listing no longer prints — identity,
|
|
415
|
+
* per-device state — still live. Shared with `agents view` so the two surfaces
|
|
416
|
+
* cannot drift.
|
|
417
|
+
*/
|
|
418
|
+
export const ACCOUNT_LISTING_LEGEND = '* stale usage · a healthy account carries no state · identity + per-box state: agents accounts list --fleet';
|
|
399
419
|
function usageText(row, maxWindows) {
|
|
400
420
|
if (row.usageSnapshot) {
|
|
401
421
|
const usageInfo = { snapshot: row.usageSnapshot, error: row.usageError ?? null };
|
|
@@ -411,13 +431,35 @@ function usageText(row, maxWindows) {
|
|
|
411
431
|
const percent = `${row.usage.usedPercent}%${row.usage.stale ? '*' : ''}`;
|
|
412
432
|
return `${renderBar(row.usage.usedPercent, LISTING_USAGE_BAR_LEN)} ${percent}`;
|
|
413
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* True only when the USAGE cell is certain to print a throttle marker, so the
|
|
436
|
+
* `rate-limited` note would say the same thing twice. Mirrors `usageText`
|
|
437
|
+
* branch for branch: with a snapshot, `formatUsageSummary` appends
|
|
438
|
+
* 'out of credits' or 'session-limited (…)' exactly for these `unavailable`
|
|
439
|
+
* reasons; without one, `usageText` prints 'limited' or 'no credits'. A verdict
|
|
440
|
+
* thrown by a maxed WINDOW is deliberately NOT here: the overview caps the cell
|
|
441
|
+
* at `OVERVIEW_MAX_USAGE_WINDOWS`, so the window that tripped it can be hidden
|
|
442
|
+
* behind the `+N` count with no color at all, and the note is the only signal.
|
|
443
|
+
*/
|
|
444
|
+
function usageCellNamesThrottle(row) {
|
|
445
|
+
if (row.usageSnapshot) {
|
|
446
|
+
const unavailable = row.usageSnapshot.unavailable;
|
|
447
|
+
return unavailable?.reason === 'out_of_credits'
|
|
448
|
+
|| (unavailable?.reason === 'session_limit' && !!unavailable.resetsAt);
|
|
449
|
+
}
|
|
450
|
+
return row.usage?.status === 'out_of_credits'
|
|
451
|
+
|| (row.usage?.status === 'rate_limited' && (row.usage.usedPercent === null || row.usage.usedPercent === undefined));
|
|
452
|
+
}
|
|
414
453
|
function nativeLine(row, localDevice, maxWindows) {
|
|
415
454
|
return {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
455
|
+
// A discovered login nobody has named is still identified by what it is:
|
|
456
|
+
// the row no longer carries an IDENTITY column, so the identity IS the name.
|
|
457
|
+
name: row.name ?? row.identityLabel,
|
|
458
|
+
notes: [
|
|
459
|
+
usageCellNamesThrottle(row) && row.verdict === 'rate_limited' ? null : verdictNote(row.verdict),
|
|
460
|
+
coverageNote(row, localDevice),
|
|
461
|
+
row.fix ? chalk.gray(`fix: ${row.fix}`) : null,
|
|
462
|
+
].filter((note) => !!note),
|
|
421
463
|
usage: usageText(row, maxWindows),
|
|
422
464
|
isDefault: row.isDefault,
|
|
423
465
|
};
|
|
@@ -425,32 +467,27 @@ function nativeLine(row, localDevice, maxWindows) {
|
|
|
425
467
|
function providerLine(row, harness) {
|
|
426
468
|
return {
|
|
427
469
|
name: row.name,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
470
|
+
notes: [
|
|
471
|
+
verdictNote(row.verdict),
|
|
472
|
+
row.fix ? chalk.gray(`fix: ${row.fix}`) : null,
|
|
473
|
+
].filter((note) => !!note),
|
|
432
474
|
usage: '',
|
|
433
475
|
isDefault: harness ? row.defaultFor.includes(harness) : false,
|
|
434
476
|
};
|
|
435
477
|
}
|
|
436
|
-
function formatListingLine(line, nameW,
|
|
478
|
+
function formatListingLine(line, nameW, usageW) {
|
|
437
479
|
const marker = line.isDefault ? '*' : ' ';
|
|
438
|
-
const fix = line.fix ? `fix: ${line.fix}` : '';
|
|
439
480
|
return (` ${chalk.green(marker)} ${chalk.cyan(line.name.padEnd(nameW))} `
|
|
440
|
-
+ `${line.identityLabel.padEnd(identityW)} `
|
|
441
|
-
+ `${verdictText(line.verdict).padEnd(stateW)} `
|
|
442
|
-
+ `${line.where.padEnd(whereW)} `
|
|
443
481
|
+ `${padToWidth(line.usage, usageW)} `
|
|
444
|
-
+
|
|
482
|
+
+ line.notes.join(chalk.gray(' · '))).trimEnd();
|
|
445
483
|
}
|
|
446
484
|
function pushGroup(out, title, lines, widths, harnessHeadings) {
|
|
447
485
|
if (lines.length === 0)
|
|
448
486
|
return;
|
|
449
487
|
if (harnessHeadings)
|
|
450
488
|
out.push(chalk.bold(title));
|
|
451
|
-
out.push(chalk.gray(` ${'ACCOUNT'.padEnd(widths.nameW + 2)}${'IDENTITY'.padEnd(widths.identityW + 2)}${'STATE'.padEnd(widths.stateW + 2)}${'WHERE'.padEnd(widths.whereW + 2)}${'USAGE'.padEnd(widths.usageW + 2)}FIX`));
|
|
452
489
|
for (const line of lines) {
|
|
453
|
-
out.push(formatListingLine(line, widths.nameW, widths.
|
|
490
|
+
out.push(formatListingLine(line, widths.nameW, widths.usageW));
|
|
454
491
|
}
|
|
455
492
|
out.push('');
|
|
456
493
|
}
|
|
@@ -501,9 +538,6 @@ export function renderAccountRows(rows, opts) {
|
|
|
501
538
|
const allLines = [...grouped.values()].flat().concat(orphans);
|
|
502
539
|
const widths = {
|
|
503
540
|
nameW: Math.max(7, ...allLines.map((line) => line.name.length)),
|
|
504
|
-
identityW: Math.max(8, ...allLines.map((line) => line.identityLabel.length)),
|
|
505
|
-
stateW: Math.max(5, ...allLines.map((line) => verdictText(line.verdict).length)),
|
|
506
|
-
whereW: Math.max(5, ...allLines.map((line) => line.where.length)),
|
|
507
541
|
usageW: Math.max(5, ...allLines.map((line) => stringWidth(line.usage))),
|
|
508
542
|
};
|
|
509
543
|
for (const [harness, lines] of grouped) {
|
|
@@ -519,7 +553,7 @@ export function renderAccountRows(rows, opts) {
|
|
|
519
553
|
const count = visibleNative.filter((row) => !!row.fix).length
|
|
520
554
|
+ visibleProviders.filter((row) => !!row.fix).length;
|
|
521
555
|
out.push(chalk.gray(`${count} accounts need you · add: agents accounts add <harness>`));
|
|
522
|
-
out.push(chalk.gray(
|
|
556
|
+
out.push(chalk.gray(ACCOUNT_LISTING_LEGEND));
|
|
523
557
|
}
|
|
524
558
|
return out.join('\n').trimEnd();
|
|
525
559
|
}
|
|
@@ -624,12 +624,13 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
624
624
|
parts.push(chalk.gray(plan.padEnd(planWidth)));
|
|
625
625
|
}
|
|
626
626
|
if (snapshot) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
627
|
+
// A blocking marker renders AFTER the bars: leading it pushed every gauge
|
|
628
|
+
// out of its column, so one throttled account misaligned the whole table.
|
|
629
|
+
const blocked = snapshot.unavailable?.reason === 'out_of_credits'
|
|
630
|
+
? chalk.red('out of credits')
|
|
631
|
+
: snapshot.unavailable?.reason === 'session_limit' && snapshot.unavailable.resetsAt
|
|
632
|
+
? chalk.yellow(`session-limited (${formatResetHint(snapshot.unavailable.resetsAt)})`)
|
|
633
|
+
: null;
|
|
633
634
|
// Compact rows show BLOCKING windows — the same set
|
|
634
635
|
// deriveUsageStatusFromSnapshot uses for the rate-limited badge — so an
|
|
635
636
|
// account throttled by its month window (Droid meters on 5h/week/month)
|
|
@@ -698,6 +699,8 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
698
699
|
else if (opts?.unverified) {
|
|
699
700
|
parts.push(chalk.yellow('unverified'));
|
|
700
701
|
}
|
|
702
|
+
if (blocked)
|
|
703
|
+
parts.push(blocked);
|
|
701
704
|
}
|
|
702
705
|
else if (opts?.headless) {
|
|
703
706
|
// No bars at all: still name the scope gap so "usage pending" is not
|
|
@@ -2357,7 +2360,7 @@ function formatAgeShort(diffMs) {
|
|
|
2357
2360
|
* Staleness suffix for a last-known window the freshness gate dropped. A window
|
|
2358
2361
|
* whose reset/period boundary passed while the sample itself is still inside its
|
|
2359
2362
|
* `windowMinutes` rolled OVER — the number describes a period that is done, so
|
|
2360
|
-
* name it ("period ended 1h
|
|
2363
|
+
* name it ("period ended 1h", e.g. Grok's weekly billing period). A window
|
|
2361
2364
|
* that aged past its own `windowMinutes` (Claude's 5h session read never
|
|
2362
2365
|
* refreshed in time) is a stale sample of a still-rolling window, so report the
|
|
2363
2366
|
* capture age ("6h old"). Falls back to the reset age, then a bare "stale".
|
|
@@ -2368,25 +2371,26 @@ function formatStaleWindowSuffix(window, capturedAt, now) {
|
|
|
2368
2371
|
window.windowMinutes !== null &&
|
|
2369
2372
|
capturedAt.getTime() + window.windowMinutes * 60 * 1000 <= now.getTime();
|
|
2370
2373
|
if (resetPassed && !captureExpired) {
|
|
2371
|
-
return `
|
|
2374
|
+
return `period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())}`;
|
|
2372
2375
|
}
|
|
2373
2376
|
if (capturedAt)
|
|
2374
2377
|
return `${formatAgeShort(now.getTime() - capturedAt.getTime())} old`;
|
|
2375
2378
|
if (resetPassed)
|
|
2376
|
-
return `
|
|
2379
|
+
return `period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())}`;
|
|
2377
2380
|
return 'stale';
|
|
2378
2381
|
}
|
|
2379
2382
|
/**
|
|
2380
|
-
* Render a dropped-but-last-known window as "S: ▍░░░░ 30
|
|
2381
|
-
* and percentage exactly as a live bar, then
|
|
2382
|
-
*
|
|
2383
|
-
*
|
|
2383
|
+
* Render a dropped-but-last-known window as "S: ▍░░░░ 30%* (6h old)": the gauge
|
|
2384
|
+
* and percentage exactly as a live bar, then the `*` stale marker the listing
|
|
2385
|
+
* legend explains and a dim age, so the number is always visible and
|
|
2386
|
+
* unmistakably not current. VIEW-ONLY — these windows are never in
|
|
2387
|
+
* `snapshot.windows`, so routing never sees them.
|
|
2384
2388
|
*/
|
|
2385
2389
|
function renderStaleUsageWindow(window, capturedAt, shortLabel, now) {
|
|
2386
2390
|
const bar = renderCompactUsageBar(window.usedPercent);
|
|
2387
2391
|
const pct = colorUsage(`${Math.round(window.usedPercent)}%`, window.usedPercent);
|
|
2388
2392
|
const suffix = formatStaleWindowSuffix(window, capturedAt, now);
|
|
2389
|
-
return `${chalk.gray(`${shortLabel}:`)} ${bar} ${pct}
|
|
2393
|
+
return `${chalk.gray(`${shortLabel}:`)} ${bar} ${pct}${chalk.dim('*')} ${chalk.dim(`(${suffix})`)}`;
|
|
2390
2394
|
}
|
|
2391
2395
|
/** Format a reset timestamp as a human-readable relative or absolute time. */
|
|
2392
2396
|
function formatResetAt(date) {
|