@phnx-labs/agents-cli 1.20.62 → 1.20.64

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 (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `agents sessions import <bundle|->` — restore an export bundle (RUSH-1711).
3
+ *
4
+ * The inverse of `sessions export`: read a bundle (file or stdin), validate it,
5
+ * and place each transcript where the cross-machine sync would — a mirror keyed
6
+ * by the session's ORIGIN machine (see bundle.ts / mirrorPath). Placement dedups
7
+ * byte-exact against what is already on disk and never clobbers this machine's
8
+ * own live sessions ("local always wins" falls out of the scanner's
9
+ * live-home-first dedup), so a re-import or an overlapping bundle is safe.
10
+ */
11
+ import * as fs from 'fs';
12
+ import chalk from 'chalk';
13
+ import { loadR2Config } from '../lib/session/sync/config.js';
14
+ import { resolveSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
15
+ import { parseBundle, planImport, writeImport, mergeRecords, makeHeader, } from '../lib/session/bundle.js';
16
+ import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
17
+ import { setHelpSections } from '../lib/help.js';
18
+ export function registerSessionsImportCommand(sessionsCmd) {
19
+ const cmd = sessionsCmd
20
+ .command('import [bundle]')
21
+ .description('Restore an export bundle (file, - for stdin, or --from-host <h>) into the local session store, deduping against what you already have.')
22
+ .option('--dry-run', 'Show what would be placed without writing anything')
23
+ .option('--overwrite', 'Replace local files that differ from the bundle (default: keep local)')
24
+ .option('--decrypt [key]', 'Decrypt an encrypted bundle (key optional if the r2.backups sync key is configured)')
25
+ .option('--from-host <target...>', 'Pull sessions live from remote peer(s) over SSH instead of a file (repeatable)');
26
+ setHelpSections(cmd, {
27
+ examples: `# Preview what a bundle would restore
28
+ agents sessions import week.bundle --dry-run
29
+
30
+ # Restore it
31
+ agents sessions import week.bundle
32
+
33
+ # Pull straight off another machine (one command, over SSH)
34
+ agents sessions import --from-host yosemite-s1 --since 7d
35
+
36
+ # Or the equivalent raw pipe
37
+ agents ssh boxB 'agents sessions export --since 7d --stdout' | agents sessions import -`,
38
+ notes: `Sessions land under the cross-machine mirror keyed by their origin machine, so
39
+ they show up in 'agents sessions' tagged with that machine and never overwrite
40
+ your own local sessions. Byte-exact duplicates are skipped. --from-host reuses
41
+ the same SSH transport as the cross-machine listing (no R2, no daemon).`,
42
+ });
43
+ cmd.action(async (bundlePath, options, command) => {
44
+ const g = command.optsWithGlobals();
45
+ await runImport(bundlePath, { ...options, agent: g.agent }, g, command);
46
+ });
47
+ }
48
+ async function runImport(bundlePath, options, g, command) {
49
+ // 1. Obtain the bundle — from remote peer(s), stdin, or a file.
50
+ let bundle;
51
+ if (options.fromHost && options.fromHost.length > 0) {
52
+ bundle = await pullForImport(options.fromHost, bundlePath, g, command);
53
+ }
54
+ else {
55
+ if (!bundlePath) {
56
+ process.stderr.write(chalk.red('Provide a bundle path, - for stdin, or --from-host <host>.\n'));
57
+ process.exit(1);
58
+ }
59
+ let text;
60
+ try {
61
+ text = bundlePath === '-' ? await readStdin() : fs.readFileSync(bundlePath, 'utf-8');
62
+ }
63
+ catch (err) {
64
+ process.stderr.write(chalk.red(`Cannot read bundle: ${err.message}\n`));
65
+ process.exit(1);
66
+ }
67
+ try {
68
+ bundle = parseBundle(text);
69
+ }
70
+ catch (err) {
71
+ process.stderr.write(chalk.red(`${err.message}\n`));
72
+ process.exit(1);
73
+ }
74
+ }
75
+ // 2. Optional agent filter.
76
+ if (options.agent) {
77
+ bundle = { header: bundle.header, records: bundle.records.filter(r => r.agent === options.agent) };
78
+ if (bundle.records.length === 0) {
79
+ process.stderr.write(chalk.yellow(`No records for agent '${options.agent}' in this bundle.\n`));
80
+ process.exit(1);
81
+ }
82
+ }
83
+ // 3. Resolve the decryption key if the bundle is encrypted.
84
+ const decryptKey = bundle.header.encrypted ? resolveDecryptKey(options.decrypt) : null;
85
+ // 4. Plan.
86
+ let plan;
87
+ try {
88
+ plan = planImport(bundle, { decryptKey });
89
+ }
90
+ catch (err) {
91
+ process.stderr.write(chalk.red(`${err.message}\n`));
92
+ process.exit(1);
93
+ }
94
+ if (options.dryRun) {
95
+ printDryRun(plan, bundle);
96
+ return;
97
+ }
98
+ // 5. Write.
99
+ const res = writeImport(plan, { overwrite: options.overwrite === true, decryptKey });
100
+ const parts = [];
101
+ if (res.placed)
102
+ parts.push(`${res.placed} placed`);
103
+ if (res.overwritten)
104
+ parts.push(`${res.overwritten} overwritten`);
105
+ if (res.skipped)
106
+ parts.push(`${res.skipped} duplicate${res.skipped === 1 ? '' : 's'} skipped`);
107
+ if (res.conflicts)
108
+ parts.push(chalk.yellow(`${res.conflicts} conflict${res.conflicts === 1 ? '' : 's'} kept local (use --overwrite)`));
109
+ if (res.unknown)
110
+ parts.push(chalk.yellow(`${res.unknown} unknown-agent skipped`));
111
+ process.stderr.write(chalk.green(`Imported: ${parts.join(', ') || 'nothing to do'}.\n`));
112
+ }
113
+ /** Drain all of stdin to a string. Works for pipes (non-seekable) and redirects
114
+ * alike — unlike readFileSync(0), which fails on a pipe. */
115
+ async function readStdin() {
116
+ const chunks = [];
117
+ for await (const chunk of process.stdin)
118
+ chunks.push(Buffer.from(chunk));
119
+ return Buffer.concat(chunks).toString('utf-8');
120
+ }
121
+ /**
122
+ * --from-host: run `agents sessions export …` on each peer over SSH and merge
123
+ * the streamed bundles into one for import. The optional positional acts as a
124
+ * remote selector (id/query); the parent selection flags (--since, -a, --all,
125
+ * -n) forward too.
126
+ */
127
+ async function pullForImport(hosts, selector, g, command) {
128
+ const args = [];
129
+ if (selector && selector !== '-')
130
+ args.push(selector);
131
+ if (g.since)
132
+ args.push('--since', g.since);
133
+ const agent = command.optsWithGlobals().agent;
134
+ if (agent)
135
+ args.push('-a', agent);
136
+ if (g.all !== false)
137
+ args.push('--all');
138
+ if (command.parent?.getOptionValueSource?.('limit') === 'cli' && g.limit)
139
+ args.push('-n', String(g.limit));
140
+ const { bundles, errors } = await pullBundlesFromHosts(hosts, args);
141
+ for (const e of errors)
142
+ process.stderr.write(chalk.yellow(` ${e}\n`));
143
+ const records = mergeRecords(bundles.map(b => b.records));
144
+ if (records.length === 0) {
145
+ process.stderr.write(chalk.red('No sessions pulled from the given host(s).\n'));
146
+ process.exit(1);
147
+ }
148
+ const header = makeHeader({
149
+ origin: hosts.join(','),
150
+ exportedAt: new Date().toISOString(),
151
+ encrypted: false,
152
+ redacted: bundles.some(b => b.header.redacted),
153
+ records,
154
+ });
155
+ return { header, records };
156
+ }
157
+ /**
158
+ * Decrypt-key resolution: an explicit `--decrypt <key>` (base64 or hex) wins;
159
+ * otherwise fall back to the fleet-shared R2_SYNC_ENC_KEY from the r2.backups
160
+ * bundle. Fails loudly when an encrypted bundle has no usable key.
161
+ */
162
+ function resolveDecryptKey(decrypt) {
163
+ if (typeof decrypt === 'string' && decrypt.trim()) {
164
+ const raw = decrypt.trim();
165
+ const key = /^[0-9a-f]{64}$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
166
+ if (key.length !== 32) {
167
+ process.stderr.write(chalk.red(`--decrypt key must decode to 32 bytes (got ${key.length}).\n`));
168
+ process.exit(1);
169
+ }
170
+ return key;
171
+ }
172
+ try {
173
+ const key = resolveSyncEncKey(loadR2Config());
174
+ if (key)
175
+ return key;
176
+ }
177
+ catch {
178
+ // sync bundle not configured
179
+ }
180
+ process.stderr.write(chalk.red('This bundle is encrypted but no key is available. Pass --decrypt <key>, ' +
181
+ 'or configure the r2.backups sync bundle so its shared key is used.\n'));
182
+ process.exit(1);
183
+ }
184
+ /** Print the dry-run table, grouped by session. Reads disk, writes nothing. */
185
+ function printDryRun(plan, bundle) {
186
+ // Group file-level plan items by session for a readable table.
187
+ const bySession = new Map();
188
+ for (const item of plan) {
189
+ const key = `${item.record.agent}:${item.record.machine}:${item.record.sessionId}`;
190
+ let row = bySession.get(key);
191
+ if (!row)
192
+ bySession.set(key, (row = { agent: item.record.agent, machine: item.record.machine, sessionId: item.record.sessionId, statuses: new Set(), files: 0 }));
193
+ row.statuses.add(item.status);
194
+ row.files++;
195
+ }
196
+ process.stdout.write(chalk.bold(`Bundle: ${bundle.header.sessions} session(s), ${bundle.header.count} file(s), origin ${bundle.header.origin}${bundle.header.encrypted ? ', encrypted' : ''}\n\n`));
197
+ const header = `${pad('SESSION', 22)}${pad('AGENT', 10)}${pad('ORIGIN', 16)}${pad('FILES', 7)}STATUS`;
198
+ process.stdout.write(chalk.dim(header) + '\n');
199
+ for (const row of bySession.values()) {
200
+ const status = aggregateStatus(row.statuses);
201
+ process.stdout.write(pad(row.sessionId.slice(0, 20), 22) +
202
+ pad(row.agent, 10) +
203
+ pad(row.machine, 16) +
204
+ pad(String(row.files), 7) +
205
+ colorStatus(status) + '\n');
206
+ }
207
+ process.stdout.write(chalk.dim('\n(dry run — nothing was written)\n'));
208
+ }
209
+ function aggregateStatus(statuses) {
210
+ if (statuses.has('conflict'))
211
+ return 'conflict';
212
+ if (statuses.has('unknown'))
213
+ return 'unknown';
214
+ if (statuses.has('new'))
215
+ return statuses.has('dup') ? 'partial' : 'new';
216
+ return 'dup';
217
+ }
218
+ function colorStatus(status) {
219
+ switch (status) {
220
+ case 'new': return chalk.green(status);
221
+ case 'dup': return chalk.dim(status);
222
+ case 'partial': return chalk.cyan(status);
223
+ case 'conflict': return chalk.yellow(status);
224
+ case 'unknown': return chalk.red(status);
225
+ default: return status;
226
+ }
227
+ }
228
+ function pad(s, w) {
229
+ return s.length >= w ? s.slice(0, w - 1) + ' ' : s + ' '.repeat(w - s.length);
230
+ }
@@ -10,6 +10,7 @@ interface SyncCmdOptions {
10
10
  enable?: boolean;
11
11
  disable?: boolean;
12
12
  status?: boolean;
13
+ setup?: boolean;
13
14
  }
14
15
  export declare function runSessionsSync(options: SyncCmdOptions): Promise<void>;
15
16
  export declare function registerSessionsSyncCommand(sessionsCmd: Command): void;
@@ -26,6 +26,11 @@ export async function runSessionsSync(options) {
26
26
  chalk.dim(' — the daemon resumes on its next cycle. (Same as: agents beta enable session-sync)'));
27
27
  return;
28
28
  }
29
+ if (options.setup) {
30
+ const { promptAndProvisionSessionSync } = await import('./sync-provision.js');
31
+ await promptAndProvisionSessionSync({ explicit: true });
32
+ return;
33
+ }
29
34
  if (options.status) {
30
35
  const enabled = isBetaEnabled(SYNC_BETA);
31
36
  const configured = isSyncConfigured();
@@ -47,7 +52,8 @@ export async function runSessionsSync(options) {
47
52
  ` agents secrets add ${SYNC_BUNDLE} R2_ACCOUNT_ID\n` +
48
53
  ` agents secrets add ${SYNC_BUNDLE} R2_BUCKET_NAME\n` +
49
54
  ` agents secrets add ${SYNC_BUNDLE} R2_ACCESS_KEY_ID\n` +
50
- ` agents secrets add ${SYNC_BUNDLE} R2_SECRET_ACCESS_KEY`);
55
+ ` agents secrets add ${SYNC_BUNDLE} R2_SECRET_ACCESS_KEY\n` +
56
+ chalk.dim('\nOr let a guided prompt mint the bundle for you: ') + chalk.cyan('agents sessions sync --setup'));
51
57
  process.exitCode = 1;
52
58
  return;
53
59
  }
@@ -68,6 +74,10 @@ export async function runSessionsSync(options) {
68
74
  console.log(chalk.green('synced') + ` ${result.machine}: ` + parts.join(', ') +
69
75
  chalk.dim(` (${result.pushSkipped + result.pullSkipped} unchanged)`));
70
76
  }
77
+ if (!options.json && result.warnings.length > 0) {
78
+ for (const w of result.warnings)
79
+ console.error(chalk.yellow(` warning: ${w}`));
80
+ }
71
81
  if (result.errors.length > 0) {
72
82
  for (const e of result.errors)
73
83
  console.error(chalk.yellow(` ! ${e}`));
@@ -82,14 +92,18 @@ export async function runSessionsSync(options) {
82
92
  export function registerSessionsSyncCommand(sessionsCmd) {
83
93
  const syncCmd = sessionsCmd
84
94
  .command('sync')
85
- .description('Sync session transcripts across machines via R2 (CRDT merge). Claude and Codex.')
95
+ .description('Sync session transcripts across machines via R2 (CRDT merge). Claude, Codex, Droid, Grok, Kimi, and OpenCode.')
86
96
  .option('-v, --verbose', 'Log each pushed and pulled session')
87
97
  .option('--json', 'Output the sync result as JSON')
98
+ .option('--setup', 'Guided provisioning: mint the r2.backups bundle, verify connectivity, and enable sync')
88
99
  .option('--enable', 'Opt in to automatic background sync (beta; alias for: agents beta enable session-sync)')
89
100
  .option('--disable', 'Opt out of automatic background sync (alias for: agents beta disable session-sync)')
90
101
  .option('--status', 'Show whether automatic sync is opted-in (beta) and configured');
91
102
  setHelpSections(syncCmd, {
92
103
  examples: `
104
+ # Guided first-time setup (mint R2 bundle, verify, enable)
105
+ agents sessions sync --setup
106
+
93
107
  # One sync cycle (push local changes, pull + merge from other machines)
94
108
  agents sessions sync
95
109
 
@@ -44,6 +44,9 @@ import { registerSessionsResumeCommand } from './sessions-resume.js';
44
44
  import { registerGoCommand } from './go.js';
45
45
  import { registerFocusCommand } from './focus.js';
46
46
  import { registerSessionsInjectCommand } from './sessions-inject.js';
47
+ import { registerSessionsExportCommand } from './sessions-export.js';
48
+ import { registerSessionsImportCommand } from './sessions-import.js';
49
+ import { runBrowserSessions } from '../lib/browser/sessions-list.js';
47
50
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
48
51
  /**
49
52
  * The prioritized harnesses that get a boolean shorthand flag (e.g. `--claude`
@@ -2065,7 +2068,8 @@ export function registerSessionsCommands(program) {
2065
2068
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
2066
2069
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
2067
2070
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
2068
- .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)');
2071
+ .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)')
2072
+ .option('--browser', 'List browser-profile captures (screenshots, PDFs, recordings, downloads) instead of agent transcripts — alias of `agents browser sessions`');
2069
2073
  setHelpSections(sessionsCmd, {
2070
2074
  examples: `
2071
2075
  # Search prior sessions in this project by topic, file path, or command
@@ -2107,6 +2111,11 @@ export function registerSessionsCommands(program) {
2107
2111
  `,
2108
2112
  });
2109
2113
  sessionsCmd.action(async (query, options) => {
2114
+ if (options.browser) {
2115
+ // Alias for `agents browser sessions`: a profile positional narrows to one profile.
2116
+ runBrowserSessions({ profile: query, json: options.json });
2117
+ return;
2118
+ }
2110
2119
  await sessionsAction(query, options);
2111
2120
  });
2112
2121
  registerSessionsTailCommand(sessionsCmd);
@@ -2115,6 +2124,8 @@ export function registerSessionsCommands(program) {
2115
2124
  registerGoCommand(sessionsCmd);
2116
2125
  registerFocusCommand(sessionsCmd);
2117
2126
  registerSessionsInjectCommand(sessionsCmd);
2127
+ registerSessionsExportCommand(sessionsCmd);
2128
+ registerSessionsImportCommand(sessionsCmd);
2118
2129
  }
2119
2130
  function formatNoSessionsMessage(showAll, project) {
2120
2131
  const projectQuery = project?.trim();
@@ -119,6 +119,15 @@ export async function runSetup(program, options = {}) {
119
119
  if (dev.ok && dev.synced > 0) {
120
120
  console.log(chalk.gray(`Discovered ${dev.synced} device${dev.synced === 1 ? '' : 's'} on your tailnet (agents devices list).`));
121
121
  }
122
+ // Offer guided cross-machine session-sync provisioning (interactive, opt-in,
123
+ // and never blocking — any failure/decline falls through to the rest of setup).
124
+ try {
125
+ const { promptAndProvisionSessionSync } = await import('./sync-provision.js');
126
+ await promptAndProvisionSessionSync({ explicit: false });
127
+ }
128
+ catch (err) {
129
+ console.log(chalk.yellow(`Session-sync setup skipped: ${err.message}`));
130
+ }
122
131
  // Offer to import existing unmanaged installations
123
132
  if (unmanaged.length > 0 && isInteractiveTerminal()) {
124
133
  console.log(chalk.bold('\nFound existing installations:\n'));
@@ -25,6 +25,8 @@ import { clearPendingSentinel } from '../lib/devices/pending.js';
25
25
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
26
26
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
27
27
  import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
28
+ import { planFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
29
+ import { fleetCapacity, fmtBytes, headroom, probeFleetStats, } from '../lib/devices/health.js';
28
30
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
29
31
  * command is running on so it stands out from the rest of the tailnet. */
30
32
  function deviceSummary(d, isSelf = false) {
@@ -40,6 +42,80 @@ function deviceSummary(d, isSelf = false) {
40
42
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
41
43
  return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}`;
42
44
  }
45
+ const HEADROOM_BADGE = {
46
+ idle: chalk.green('○ idle'),
47
+ light: chalk.green('● light'),
48
+ busy: chalk.yellow('● busy'),
49
+ loaded: chalk.red('● loaded'),
50
+ unknown: chalk.gray('· —'),
51
+ };
52
+ /** A right-aligned percentage cell, colored by severity (green/yellow/red). */
53
+ function pctCell(v, width) {
54
+ if (v === undefined)
55
+ return chalk.gray('—'.padStart(width));
56
+ const s = `${Math.round(v)}%`.padStart(width);
57
+ if (v < 40)
58
+ return chalk.green(s);
59
+ if (v < 75)
60
+ return chalk.yellow(s);
61
+ return chalk.red(s);
62
+ }
63
+ /**
64
+ * Render the device list. When `statsMap` is provided, resource columns are
65
+ * appended — normalized load, memory, a headroom badge, and (in `full` mode)
66
+ * core count and free/total memory — so it's obvious which boxes have room.
67
+ * Without it (probe skipped) the classic reachability line is used. A fleet
68
+ * capacity summary is appended whenever stats were gathered.
69
+ */
70
+ function renderDeviceTable(reg, names, self, statsMap, full = false) {
71
+ if (!statsMap)
72
+ return names.map((n) => deviceSummary(reg[n], n === self));
73
+ const lines = [];
74
+ const head = ' ' +
75
+ chalk.gray('device'.padEnd(16)) +
76
+ chalk.gray('platform'.padEnd(8)) +
77
+ ' ' +
78
+ (full ? chalk.gray('cores'.padStart(6)) : '') +
79
+ chalk.gray('load'.padStart(5)) +
80
+ chalk.gray('mem'.padStart(6)) +
81
+ (full ? ' ' + chalk.gray('free/total'.padEnd(12)) : '') +
82
+ ' ' +
83
+ chalk.gray('headroom');
84
+ lines.push(head);
85
+ for (const name of names) {
86
+ const d = reg[name];
87
+ const isSelf = name === self;
88
+ const marker = isSelf ? chalk.cyan('▸ ') : ' ';
89
+ const label = isSelf ? chalk.bold.cyan(name.padEnd(16)) : chalk.bold(name.padEnd(16));
90
+ const plat = String(d.platform).padEnd(8);
91
+ const offline = d.tailscale && !d.tailscale.online;
92
+ const stats = statsMap.get(name);
93
+ if (offline) {
94
+ lines.push(`${marker}${label}${plat} ${chalk.gray('offline')}`);
95
+ continue;
96
+ }
97
+ const relay = !isSelf && d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' relay') : '';
98
+ const cores = full ? chalk.gray(String(stats?.ncpu ?? '—').padStart(6)) : '';
99
+ const load = pctCell(stats?.loadPercent, 5);
100
+ const mem = pctCell(stats?.memPercent, 6);
101
+ const freeTotal = full
102
+ ? ' ' +
103
+ (stats?.reachable && stats.memTotalBytes
104
+ ? `${fmtBytes(stats.memFreeBytes)}/${fmtBytes(stats.memTotalBytes)}`.padEnd(12)
105
+ : chalk.gray('—'.padEnd(12)))
106
+ : '';
107
+ const badge = HEADROOM_BADGE[headroom(stats)];
108
+ const here = isSelf ? chalk.cyan(' ← this machine') : '';
109
+ lines.push(`${marker}${label}${plat} ${cores}${load}${mem}${freeTotal} ${badge}${relay}${here}`);
110
+ }
111
+ // Fleet capacity summary — total cores + how much RAM is free right now.
112
+ const cap = fleetCapacity(statsMap.values());
113
+ if (cap.reachable > 0) {
114
+ const freePct = cap.memTotalBytes > 0 ? Math.round((cap.memFreeBytes / cap.memTotalBytes) * 100) : 0;
115
+ lines.push(chalk.gray(` Fleet capacity: ${cap.cores} cores · ${fmtBytes(cap.memFreeBytes)} free / ${fmtBytes(cap.memTotalBytes)} RAM (${freePct}% free) across ${cap.reachable} reachable device${cap.reachable === 1 ? '' : 's'}`));
116
+ }
117
+ return lines;
118
+ }
43
119
  /** Resolve a device or exit with a clear error. */
44
120
  async function mustGetDevice(name) {
45
121
  const d = await getDevice(name);
@@ -118,11 +194,34 @@ async function runInteractiveDeviceSync() {
118
194
  ].filter(Boolean);
119
195
  console.log(parts.join(chalk.gray(' · ')));
120
196
  }
121
- /** Register the `agents devices` command tree. */
197
+ /** Print a per-device result table for fleet update/run. */
198
+ function printFleetResults(results) {
199
+ const nameW = Math.max(8, ...results.map((r) => r.name.length));
200
+ console.log(chalk.bold('DEVICE'.padEnd(nameW)) + ' ' +
201
+ chalk.bold('STATUS'.padEnd(8)) + ' ' +
202
+ chalk.bold('DETAIL'));
203
+ for (const r of results) {
204
+ const status = r.status === 'ok' ? chalk.green('ok'.padEnd(8)) :
205
+ r.status === 'skipped' ? chalk.gray('skipped'.padEnd(8)) :
206
+ chalk.red('failed'.padEnd(8));
207
+ const detail = r.status === 'skipped' ? chalk.gray(skipLabel(r.reason)) :
208
+ r.status === 'failed' ? chalk.red(r.detail || `exit ${r.code ?? '?'}`) :
209
+ chalk.gray(r.code === 0 ? 'exit 0' : '');
210
+ console.log(`${r.name.padEnd(nameW)} ${status} ${detail}`);
211
+ }
212
+ const ok = results.filter((r) => r.status === 'ok').length;
213
+ const failed = results.filter((r) => r.status === 'failed').length;
214
+ const skipped = results.filter((r) => r.status === 'skipped').length;
215
+ console.log(chalk.gray(`${ok} ok · ${failed} failed · ${skipped} skipped`));
216
+ if (failed > 0)
217
+ process.exitCode = 1;
218
+ }
219
+ /** Register the `agents devices` command tree (also aliased as `fleet`). */
122
220
  function registerDevicesCommands(program) {
123
221
  const devicesCmd = program
124
222
  .command('devices')
125
- .description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
223
+ .alias('fleet')
224
+ .description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale. Alias: fleet.')
126
225
  .addHelpText('after', `
127
226
  Typical workflow:
128
227
  agents devices sync # curate: pick which tailscale nodes to keep (TTY)
@@ -131,6 +230,10 @@ Typical workflow:
131
230
  agents devices ignore ipad165 # dismiss a node so it's never re-suggested
132
231
  agents devices set win-mini --auth password --bundle muqsit
133
232
  agents devices render --write # write ~/.ssh/config.d/agents include
233
+ agents fleet update # roll out latest agents-cli to every online device
234
+ agents fleet run uname -a # run a command on every online device
235
+
236
+ \`agents fleet\` is an alias for \`agents devices\` — same subcommands.
134
237
  `);
135
238
  devicesCmd
136
239
  .command('sync')
@@ -202,12 +305,15 @@ Typical workflow:
202
305
  devicesCmd
203
306
  .command('list')
204
307
  .alias('ls')
205
- .description('List registered devices with platform, address, and reachability.')
308
+ .description('List registered devices with platform, address, reachability, and live resource headroom.')
206
309
  .option('--json', 'output the registry as a JSON array (for scripts and hooks)')
310
+ .option('--no-stats', 'skip the live resource probe (instant; names/addresses only)')
311
+ .option('-f, --full', 'full mode: add per-device core count and free/total memory')
207
312
  .action(async (opts) => {
208
313
  const reg = await loadDevices();
209
314
  const names = Object.keys(reg).sort();
210
315
  if (opts.json) {
316
+ // Registry-only, always fast — the Factory extension polls this path.
211
317
  process.stdout.write(JSON.stringify(names.map((n) => reg[n]), null, 2) + '\n');
212
318
  return;
213
319
  }
@@ -216,9 +322,26 @@ Typical workflow:
216
322
  return;
217
323
  }
218
324
  const self = machineId();
325
+ let statsMap;
326
+ if (opts.stats !== false) {
327
+ // Probe only reachable devices, in parallel, bounded by the per-probe
328
+ // timeout — a slow box degrades to "—", it never hangs the table.
329
+ const probeable = planFleetTargets(reg)
330
+ .filter((t) => !t.skip)
331
+ .map((t) => t.device);
332
+ const spinner = isInteractiveTerminal()
333
+ ? ora(`Probing ${probeable.length} device${probeable.length === 1 ? '' : 's'}…`).start()
334
+ : undefined;
335
+ try {
336
+ statsMap = await probeFleetStats(probeable, { selfName: self });
337
+ }
338
+ finally {
339
+ spinner?.stop();
340
+ }
341
+ }
219
342
  console.log(chalk.bold(`Devices (${names.length})`));
220
- for (const name of names)
221
- console.log(deviceSummary(reg[name], name === self));
343
+ for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full))
344
+ console.log(line);
222
345
  });
223
346
  devicesCmd
224
347
  .command('show <name>')
@@ -307,6 +430,48 @@ Typical workflow:
307
430
  console.log(chalk.green(`Wrote ${file}`));
308
431
  console.log(chalk.gray('Add this to ~/.ssh/config (once): Include config.d/agents'));
309
432
  });
433
+ devicesCmd
434
+ .command('update')
435
+ .description('Roll out agents-cli to every online registered device (`agents upgrade --yes` on each). Offline devices are skipped.')
436
+ .argument('[version]', 'Target version or dist-tag (default: latest)')
437
+ .action(async (version) => {
438
+ let cmd;
439
+ try {
440
+ cmd = upgradeCommand(version);
441
+ }
442
+ catch (err) {
443
+ console.error(chalk.red(err?.message ?? err));
444
+ process.exit(1);
445
+ }
446
+ const reg = await loadDevices();
447
+ const targets = planFleetTargets(reg);
448
+ if (targets.length === 0) {
449
+ console.log(chalk.gray("No devices. Run 'agents devices sync' first."));
450
+ return;
451
+ }
452
+ console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
453
+ const results = runFleet(targets, cmd);
454
+ printFleetResults(results);
455
+ });
456
+ devicesCmd
457
+ .command('run <cmd...>')
458
+ .description('Run a command on every online registered device. Offline devices are skipped. Alias surface: agents fleet run …')
459
+ .allowUnknownOption()
460
+ .action(async (cmd) => {
461
+ if (!cmd.length) {
462
+ console.error(chalk.red('Usage: agents fleet run <cmd...>'));
463
+ process.exit(1);
464
+ }
465
+ const reg = await loadDevices();
466
+ const targets = planFleetTargets(reg);
467
+ if (targets.length === 0) {
468
+ console.log(chalk.gray("No devices. Run 'agents devices sync' first."));
469
+ return;
470
+ }
471
+ console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
472
+ const results = runFleet(targets, cmd);
473
+ printFleetResults(results);
474
+ });
310
475
  }
311
476
  /** Register the `agents ssh` smart wrapper. */
312
477
  function registerSshWrapper(program) {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Interactive provisioning flow for cross-machine session sync. Collects R2
3
+ * credentials, writes the `r2.backups` bundle (via the pure lib helper), probes
4
+ * connectivity, and opts the machine into the `session-sync` beta. Shared by
5
+ * `agents setup` (offered, opt-in) and `agents sessions sync --setup` (explicit).
6
+ *
7
+ * The prompt/UI lives here in the command layer; the credential-writing and
8
+ * connectivity logic is the pure, unit-tested `lib/session/sync/provision.ts`.
9
+ */
10
+ export interface ProvisionFlowOptions {
11
+ /**
12
+ * True when invoked explicitly (`agents sessions sync --setup`): we go straight
13
+ * into provisioning. False when offered inside `agents setup`: we ask first,
14
+ * default to No, and silently skip when already configured.
15
+ */
16
+ explicit?: boolean;
17
+ }
18
+ /**
19
+ * Run the guided session-sync setup. Never throws for expected outcomes
20
+ * (non-interactive shell, user declines, cancels, or a failed probe) — returns
21
+ * quietly so a first-run `agents setup` is never blocked by the optional step.
22
+ */
23
+ export declare function promptAndProvisionSessionSync(opts?: ProvisionFlowOptions): Promise<void>;