@phnx-labs/agents-cli 1.22.104 → 1.22.106
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 +18 -0
- package/README.md +19 -1
- package/dist/browser.js +0 -0
- package/dist/commands/exec.js +135 -233
- package/dist/commands/resume.d.ts +6 -21
- package/dist/commands/resume.js +18 -55
- package/dist/commands/run-device-picker.d.ts +58 -0
- package/dist/commands/run-device-picker.js +222 -0
- package/dist/commands/sessions-resume.d.ts +4 -0
- package/dist/commands/sessions-resume.js +132 -49
- package/dist/commands/sessions.js +32 -5
- package/dist/commands/setup-computer.js +2 -2
- package/dist/index.js +0 -0
- package/dist/lib/accounting/account-launch.d.ts +54 -0
- package/dist/lib/accounting/account-launch.js +117 -0
- package/dist/lib/accounting/account-pool-collect.js +2 -1
- package/dist/lib/accounting/account-pool.d.ts +2 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +32 -6
- package/dist/lib/accounting/rotate.js +70 -42
- package/dist/lib/accounting/usage.d.ts +71 -0
- package/dist/lib/accounting/usage.js +160 -11
- package/dist/lib/exec-account-home.d.ts +3 -1
- package/dist/lib/exec-account-home.js +2 -2
- package/dist/lib/exec.d.ts +27 -1
- package/dist/lib/exec.js +150 -27
- package/dist/lib/models.d.ts +1 -1
- package/dist/lib/models.js +4 -4
- package/dist/lib/session/actor-sidecar.d.ts +3 -11
- package/dist/lib/session/actor-sidecar.js +3 -0
- package/dist/lib/session/claude-accounts.d.ts +12 -73
- package/dist/lib/session/claude-accounts.js +32 -70
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +18 -5
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +116 -15
- package/dist/lib/session/recovery.d.ts +30 -34
- package/dist/lib/session/recovery.js +212 -76
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/teams/placement-probe.js +1 -1
- package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
- package/dist/session-tracker/dist/adapters/claude.js +45 -0
- package/dist/session-tracker/dist/hook.sh +191 -0
- package/dist/session-tracker/dist/index.d.ts +19 -0
- package/dist/session-tracker/dist/index.js +67 -0
- package/dist/session-tracker/dist/install-hook.d.ts +19 -0
- package/dist/session-tracker/dist/install-hook.js +245 -0
- package/dist/session-tracker/dist/prune-state.d.ts +2 -0
- package/dist/session-tracker/dist/prune-state.js +7 -0
- package/dist/session-tracker/dist/reader.d.ts +7 -0
- package/dist/session-tracker/dist/reader.js +151 -0
- package/dist/session-tracker/dist/state-file.d.ts +10 -0
- package/dist/session-tracker/dist/state-file.js +119 -0
- package/dist/session-tracker/dist/types.d.ts +32 -0
- package/dist/session-tracker/dist/types.js +1 -0
- package/dist/session-tracker/dist/writer.d.ts +12 -0
- package/dist/session-tracker/dist/writer.js +27 -0
- package/package.json +1 -1
|
@@ -1,28 +1,20 @@
|
|
|
1
1
|
import type { SessionMeta } from '../lib/session/types.js';
|
|
2
2
|
export declare const RESUME_SOURCE_ENV = "AGENTS_RESUME_SOURCE_JSON";
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
* with `machine` rewritten to THIS box so the delegated `agents run --resume`
|
|
6
|
-
* resolves recovery locally (`sessionRecoveryPeer` returns undefined) instead of
|
|
7
|
-
* bouncing back to the unreachable owner. Because no local version home owns the
|
|
8
|
-
* peer's transcript, that local recovery lands on a labelled `/continue` replay
|
|
9
|
-
* from the synced mirror — the only way to continue a session whose owning device
|
|
10
|
-
* is gone. Owner-approved prefer-device, fall-back-local (PHNX-3626).
|
|
11
|
-
*
|
|
12
|
-
* Safe against the RUSH-2022 "silent local resume forks live state" hazard by
|
|
13
|
-
* PRECONDITION: this is reached only after `runOnPeer` proved the owner
|
|
14
|
-
* unreachable, so there is no live process on the peer to fork, and the fallback
|
|
15
|
-
* is announced with a log line — never silent.
|
|
16
|
-
*/
|
|
3
|
+
/** Inspect local archived context after the owner hop fails. Recovery still
|
|
4
|
+
* requires readable content and explicit replay consent before starting a run. */
|
|
17
5
|
export declare function resumeLocalFallbackSource(session: SessionMeta, self?: string): SessionMeta;
|
|
18
6
|
export interface StrictResumeOptions {
|
|
7
|
+
agent?: string;
|
|
19
8
|
mode?: string;
|
|
9
|
+
account?: string;
|
|
10
|
+
model?: string;
|
|
20
11
|
interactive?: boolean;
|
|
21
12
|
headless?: boolean;
|
|
22
13
|
cwd?: string;
|
|
23
14
|
quiet?: boolean;
|
|
24
15
|
/** Run on THIS machine even when the session belongs to a peer (escape hatch). */
|
|
25
16
|
here?: boolean;
|
|
17
|
+
local?: boolean;
|
|
26
18
|
}
|
|
27
19
|
/**
|
|
28
20
|
* The argv to re-run this resume on the machine that owns the session.
|
|
@@ -40,13 +32,6 @@ export declare function buildResumeRunArgs(session: {
|
|
|
40
32
|
agent: string;
|
|
41
33
|
version?: string;
|
|
42
34
|
}, prompt: string | undefined, options: StrictResumeOptions): string[];
|
|
43
|
-
/** Recreate a remote Claude launch whose forced id never materialized a transcript. */
|
|
44
|
-
export declare function buildProvisionalRunArgs(session: {
|
|
45
|
-
id: string;
|
|
46
|
-
agent: string;
|
|
47
|
-
version?: string;
|
|
48
|
-
cwd?: string;
|
|
49
|
-
}, prompt: string | undefined, options: StrictResumeOptions): string[];
|
|
50
35
|
/** True when the caller asked for the strict resume path (prompt and/or flags). */
|
|
51
36
|
export declare function wantsStrictResume(prompt: string | undefined, options: StrictResumeOptions): boolean;
|
|
52
37
|
/**
|
package/dist/commands/resume.js
CHANGED
|
@@ -9,20 +9,8 @@ import { resolveSessionMetadataValue } from './sessions.js';
|
|
|
9
9
|
import { sessionOwnerDevice, consumeResumePinned, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
|
|
10
10
|
import { machineId } from '../lib/machine-id.js';
|
|
11
11
|
export const RESUME_SOURCE_ENV = 'AGENTS_RESUME_SOURCE_JSON';
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
* with `machine` rewritten to THIS box so the delegated `agents run --resume`
|
|
15
|
-
* resolves recovery locally (`sessionRecoveryPeer` returns undefined) instead of
|
|
16
|
-
* bouncing back to the unreachable owner. Because no local version home owns the
|
|
17
|
-
* peer's transcript, that local recovery lands on a labelled `/continue` replay
|
|
18
|
-
* from the synced mirror — the only way to continue a session whose owning device
|
|
19
|
-
* is gone. Owner-approved prefer-device, fall-back-local (PHNX-3626).
|
|
20
|
-
*
|
|
21
|
-
* Safe against the RUSH-2022 "silent local resume forks live state" hazard by
|
|
22
|
-
* PRECONDITION: this is reached only after `runOnPeer` proved the owner
|
|
23
|
-
* unreachable, so there is no live process on the peer to fork, and the fallback
|
|
24
|
-
* is announced with a log line — never silent.
|
|
25
|
-
*/
|
|
12
|
+
/** Inspect local archived context after the owner hop fails. Recovery still
|
|
13
|
+
* requires readable content and explicit replay consent before starting a run. */
|
|
26
14
|
export function resumeLocalFallbackSource(session, self = machineId()) {
|
|
27
15
|
return { ...session, machine: self };
|
|
28
16
|
}
|
|
@@ -36,6 +24,12 @@ export function resumeLocalFallbackSource(session, self = machineId()) {
|
|
|
36
24
|
*/
|
|
37
25
|
export function buildResumeRemoteArgs(sessionId, prompt, options) {
|
|
38
26
|
const args = ['sessions', 'resume', sessionId, ...(prompt === undefined ? [] : [prompt])];
|
|
27
|
+
if (options.agent)
|
|
28
|
+
args.push('--agent', options.agent);
|
|
29
|
+
if (options.account)
|
|
30
|
+
args.push('--account', options.account);
|
|
31
|
+
if (options.model)
|
|
32
|
+
args.push('--model', options.model);
|
|
39
33
|
if (options.mode)
|
|
40
34
|
args.push('--mode', options.mode);
|
|
41
35
|
if (options.interactive)
|
|
@@ -51,8 +45,12 @@ export function buildResumeRemoteArgs(sessionId, prompt, options) {
|
|
|
51
45
|
/** Translate `agents sessions resume <id>` to the canonical `agents run` surface after
|
|
52
46
|
* resolving the source identity. The run command remains the only executor. */
|
|
53
47
|
export function buildResumeRunArgs(session, prompt, options) {
|
|
54
|
-
const spec = session.
|
|
48
|
+
const spec = session.agent;
|
|
55
49
|
const args = ['run', spec, ...(prompt === undefined ? [] : [prompt]), '--resume', session.id];
|
|
50
|
+
if (options.account)
|
|
51
|
+
args.push('--account', options.account);
|
|
52
|
+
if (options.model)
|
|
53
|
+
args.push('--model', options.model);
|
|
56
54
|
if (options.mode)
|
|
57
55
|
args.push('--mode', options.mode);
|
|
58
56
|
if (options.interactive)
|
|
@@ -65,23 +63,6 @@ export function buildResumeRunArgs(session, prompt, options) {
|
|
|
65
63
|
args.push('--quiet');
|
|
66
64
|
return args;
|
|
67
65
|
}
|
|
68
|
-
/** Recreate a remote Claude launch whose forced id never materialized a transcript. */
|
|
69
|
-
export function buildProvisionalRunArgs(session, prompt, options) {
|
|
70
|
-
const spec = session.version ? `${session.agent}@${session.version}` : session.agent;
|
|
71
|
-
const args = ['run', spec, ...(prompt === undefined ? [] : [prompt]), '--session-id', session.id];
|
|
72
|
-
if (options.mode)
|
|
73
|
-
args.push('--mode', options.mode);
|
|
74
|
-
if (options.interactive)
|
|
75
|
-
args.push('--interactive');
|
|
76
|
-
if (options.headless)
|
|
77
|
-
args.push('--headless');
|
|
78
|
-
const cwd = options.cwd ?? session.cwd;
|
|
79
|
-
if (cwd)
|
|
80
|
-
args.push('--cwd', cwd);
|
|
81
|
-
if (options.quiet)
|
|
82
|
-
args.push('--quiet');
|
|
83
|
-
return args;
|
|
84
|
-
}
|
|
85
66
|
function consumeResumeSource() {
|
|
86
67
|
const raw = process.env[RESUME_SOURCE_ENV];
|
|
87
68
|
delete process.env[RESUME_SOURCE_ENV];
|
|
@@ -124,7 +105,7 @@ export async function runStrictResume(sessionId, prompt, options) {
|
|
|
124
105
|
}
|
|
125
106
|
// An owner hop must inspect only the owner's index. Fleet fan-out here can
|
|
126
107
|
// rediscover the dispatcher's synthetic row and bounce the same id forever.
|
|
127
|
-
const outcome = await resolveSessionMetadataValue(sessionId.trim(),
|
|
108
|
+
const outcome = await resolveSessionMetadataValue(sessionId.trim(), { agent: options.agent, local: pinnedHere || options.local });
|
|
128
109
|
if (outcome.kind === 'partial') {
|
|
129
110
|
// RUSH-2492: an unreachable peer is a warning, not a hard failure. The
|
|
130
111
|
// resolver already resolves an id found on the reachable fleet (SES-9a),
|
|
@@ -138,19 +119,6 @@ export async function runStrictResume(sessionId, prompt, options) {
|
|
|
138
119
|
return;
|
|
139
120
|
}
|
|
140
121
|
if (outcome.kind === 'not-found') {
|
|
141
|
-
if (routedHop && routedSource?.filePath === '' && routedSource.agent === 'claude') {
|
|
142
|
-
const args = buildProvisionalRunArgs(routedSource, prompt, options);
|
|
143
|
-
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
144
|
-
stdio: 'inherit',
|
|
145
|
-
env: process.env,
|
|
146
|
-
});
|
|
147
|
-
const exitCode = await new Promise((resolve) => {
|
|
148
|
-
child.once('error', () => resolve(127));
|
|
149
|
-
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
150
|
-
});
|
|
151
|
-
process.exitCode = exitCode;
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
122
|
console.error(chalk.red(`No session matching "${sessionId}".`));
|
|
155
123
|
process.exitCode = 1;
|
|
156
124
|
return;
|
|
@@ -184,21 +152,16 @@ export async function runStrictResume(sessionId, prompt, options) {
|
|
|
184
152
|
sessionId: outcome.session.id,
|
|
185
153
|
});
|
|
186
154
|
if (rc === 'no-target' || rc === 'unreachable') {
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// registered but offline/asleep so the SSH connection itself failed
|
|
190
|
-
// ('unreachable'). Either way there is no live harness to reach OR to fork,
|
|
191
|
-
// so continue the session HERE from its synced mirror rather than
|
|
192
|
-
// dead-ending. The local recovery resolves this to a labelled `/continue`
|
|
193
|
-
// replay (no local home owns the peer's transcript), the honest degradation.
|
|
155
|
+
// The owner may still be running. Only verified local content can be
|
|
156
|
+
// offered as a separate conversation after explicit replay consent.
|
|
194
157
|
if (!options.quiet) {
|
|
195
|
-
process.stderr.write(chalk.yellow(`[agents] session ${outcome.session.shortId} belongs to ${owner}, which is unreachable
|
|
158
|
+
process.stderr.write(chalk.yellow(`[agents] session ${outcome.session.shortId} belongs to ${owner}, which is unreachable; checking for local transcript content before offering replay\n`));
|
|
196
159
|
}
|
|
197
160
|
process.exitCode = await delegateLocalResume(resumeLocalFallbackSource(outcome.session), prompt, options);
|
|
198
161
|
}
|
|
199
162
|
return;
|
|
200
163
|
}
|
|
201
|
-
process.exitCode = await delegateLocalResume(outcome.session, prompt, options);
|
|
164
|
+
process.exitCode = await delegateLocalResume(pinnedHere ? resumeLocalFallbackSource(outcome.session) : outcome.session, prompt, options);
|
|
202
165
|
}
|
|
203
166
|
/**
|
|
204
167
|
* Spawn the delegated local `agents run --resume` for a session this box owns
|
|
@@ -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
|
+
}
|
|
@@ -20,9 +20,13 @@ export interface ResumeOptions extends StrictResumeOptions {
|
|
|
20
20
|
splits?: boolean;
|
|
21
21
|
attachOnly?: boolean;
|
|
22
22
|
local?: boolean;
|
|
23
|
+
/** Original run argv; only the bare resume selector changes after picking. */
|
|
24
|
+
runArgs?: string[];
|
|
23
25
|
}
|
|
24
26
|
export declare function registerSessionsResumeCommand(sessionsCmd: Command): void;
|
|
25
27
|
export declare function sessionsResumeAction(query: string | undefined, prompt: string | undefined, options: ResumeOptions): Promise<void>;
|
|
28
|
+
/** Preserve run options and lifecycle intent when the picker opens its selected rows. */
|
|
29
|
+
export declare function buildSelectedResumeArgs(id: string, prompt: string | undefined, options: ResumeOptions): string[];
|
|
26
30
|
/** IDs and tmux aliases are actions, not picker search text. Human phrases keep
|
|
27
31
|
* the existing pre-filtered picker, while an explicit identity resumes directly. */
|
|
28
32
|
export declare function isDirectResumeSelector(query: string): boolean;
|