@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +19 -1
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.js +135 -233
  5. package/dist/commands/resume.d.ts +6 -21
  6. package/dist/commands/resume.js +18 -55
  7. package/dist/commands/run-device-picker.d.ts +58 -0
  8. package/dist/commands/run-device-picker.js +222 -0
  9. package/dist/commands/sessions-resume.d.ts +4 -0
  10. package/dist/commands/sessions-resume.js +132 -49
  11. package/dist/commands/sessions.js +32 -5
  12. package/dist/commands/setup-computer.js +2 -2
  13. package/dist/index.js +0 -0
  14. package/dist/lib/accounting/account-launch.d.ts +54 -0
  15. package/dist/lib/accounting/account-launch.js +117 -0
  16. package/dist/lib/accounting/account-pool-collect.js +2 -1
  17. package/dist/lib/accounting/account-pool.d.ts +2 -0
  18. package/dist/lib/accounting/account-pool.js +1 -0
  19. package/dist/lib/accounting/rotate.d.ts +32 -6
  20. package/dist/lib/accounting/rotate.js +70 -42
  21. package/dist/lib/accounting/usage.d.ts +71 -0
  22. package/dist/lib/accounting/usage.js +160 -11
  23. package/dist/lib/exec-account-home.d.ts +3 -1
  24. package/dist/lib/exec-account-home.js +2 -2
  25. package/dist/lib/exec.d.ts +27 -1
  26. package/dist/lib/exec.js +150 -27
  27. package/dist/lib/models.d.ts +1 -1
  28. package/dist/lib/models.js +4 -4
  29. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  30. package/dist/lib/session/actor-sidecar.js +3 -0
  31. package/dist/lib/session/claude-accounts.d.ts +12 -73
  32. package/dist/lib/session/claude-accounts.js +32 -70
  33. package/dist/lib/session/db.d.ts +1 -1
  34. package/dist/lib/session/db.js +18 -5
  35. package/dist/lib/session/discover.d.ts +4 -0
  36. package/dist/lib/session/discover.js +116 -15
  37. package/dist/lib/session/recovery.d.ts +30 -34
  38. package/dist/lib/session/recovery.js +212 -76
  39. package/dist/lib/session/types.d.ts +2 -0
  40. package/dist/lib/teams/placement-probe.js +1 -1
  41. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  42. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  43. package/dist/session-tracker/dist/hook.sh +191 -0
  44. package/dist/session-tracker/dist/index.d.ts +19 -0
  45. package/dist/session-tracker/dist/index.js +67 -0
  46. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  47. package/dist/session-tracker/dist/install-hook.js +245 -0
  48. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  49. package/dist/session-tracker/dist/prune-state.js +7 -0
  50. package/dist/session-tracker/dist/reader.d.ts +7 -0
  51. package/dist/session-tracker/dist/reader.js +151 -0
  52. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  53. package/dist/session-tracker/dist/state-file.js +119 -0
  54. package/dist/session-tracker/dist/types.d.ts +32 -0
  55. package/dist/session-tracker/dist/types.js +1 -0
  56. package/dist/session-tracker/dist/writer.d.ts +12 -0
  57. package/dist/session-tracker/dist/writer.js +27 -0
  58. 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
- * The source a dead-remote local fallback resumes from: the same session, but
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
  /**
@@ -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
- * The source a dead-remote local fallback resumes from: the same session, but
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.version ? `${session.agent}@${session.version}` : session.agent;
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(), pinnedHere ? { local: true } : {});
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
- // Prefer-device, fall back to local (PHNX-3626): the owning device is
188
- // unreachable — either not a dialable registered device ('no-target') or
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 → resuming locally (/continue replay from the synced transcript)\n`));
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;