@phnx-labs/agents-cli 1.20.63 → 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 (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +55 -28
  5. package/dist/commands/feed.d.ts +4 -0
  6. package/dist/commands/feed.js +27 -8
  7. package/dist/commands/lease.d.ts +23 -0
  8. package/dist/commands/lease.js +201 -0
  9. package/dist/commands/mailboxes.d.ts +20 -0
  10. package/dist/commands/mailboxes.js +390 -0
  11. package/dist/commands/routines.js +20 -14
  12. package/dist/commands/sessions-export.d.ts +2 -0
  13. package/dist/commands/sessions-export.js +279 -0
  14. package/dist/commands/sessions-import.d.ts +2 -0
  15. package/dist/commands/sessions-import.js +230 -0
  16. package/dist/commands/sessions.js +4 -0
  17. package/dist/commands/ssh.js +98 -3
  18. package/dist/commands/usage.d.ts +2 -0
  19. package/dist/commands/usage.js +7 -2
  20. package/dist/commands/view.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/agents.d.ts +18 -0
  23. package/dist/lib/agents.js +27 -17
  24. package/dist/lib/browser/drivers/ssh.js +19 -2
  25. package/dist/lib/comms-render.d.ts +37 -0
  26. package/dist/lib/comms-render.js +89 -0
  27. package/dist/lib/crabbox/cli.d.ts +72 -0
  28. package/dist/lib/crabbox/cli.js +158 -9
  29. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  30. package/dist/lib/crabbox/runtimes.js +24 -0
  31. package/dist/lib/daemon.js +6 -1
  32. package/dist/lib/devices/health.d.ts +77 -0
  33. package/dist/lib/devices/health.js +186 -0
  34. package/dist/lib/mailbox.d.ts +39 -0
  35. package/dist/lib/mailbox.js +112 -0
  36. package/dist/lib/paths.d.ts +13 -0
  37. package/dist/lib/paths.js +26 -4
  38. package/dist/lib/routines.d.ts +21 -2
  39. package/dist/lib/routines.js +35 -12
  40. package/dist/lib/runner.js +255 -13
  41. package/dist/lib/sandbox.d.ts +9 -1
  42. package/dist/lib/sandbox.js +11 -2
  43. package/dist/lib/session/bundle.d.ts +150 -0
  44. package/dist/lib/session/bundle.js +189 -0
  45. package/dist/lib/session/remote-bundle.d.ts +12 -0
  46. package/dist/lib/session/remote-bundle.js +61 -0
  47. package/dist/lib/session/sync/agents.d.ts +54 -6
  48. package/dist/lib/session/sync/agents.js +0 -0
  49. package/dist/lib/session/sync/manifest.d.ts +14 -3
  50. package/dist/lib/session/sync/manifest.js +4 -0
  51. package/dist/lib/session/sync/sync.d.ts +23 -2
  52. package/dist/lib/session/sync/sync.js +177 -74
  53. package/dist/lib/ssh-tunnel.js +13 -1
  54. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  55. package/dist/lib/staleness/detectors/subagents.js +5 -192
  56. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  57. package/dist/lib/staleness/writers/subagents.js +11 -102
  58. package/dist/lib/startup/command-registry.d.ts +2 -0
  59. package/dist/lib/startup/command-registry.js +5 -0
  60. package/dist/lib/subagents-registry.d.ts +85 -0
  61. package/dist/lib/subagents-registry.js +393 -0
  62. package/dist/lib/subagents.d.ts +8 -8
  63. package/dist/lib/subagents.js +32 -663
  64. package/dist/lib/sync-umbrella.d.ts +1 -0
  65. package/dist/lib/sync-umbrella.js +14 -3
  66. package/dist/lib/types.d.ts +9 -0
  67. package/dist/lib/usage.d.ts +42 -3
  68. package/dist/lib/usage.js +162 -22
  69. package/package.json +1 -1
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `agents sessions export` — bundle N selected sessions into a portable,
3
+ * self-describing archive (RUSH-1710).
4
+ *
5
+ * The successor to background R2/CRDT sync for the durable-archive / hand-off
6
+ * case: instead of an always-on merge daemon, the user explicitly bundles the
7
+ * sessions they want to carry to an offline box or keep as an archive. The
8
+ * bundle format + placement live in ../lib/session/bundle.ts; this command owns
9
+ * only the SELECTION (which sessions) and the OUTPUT (file or stdout).
10
+ *
11
+ * Selection flags (`--since`, `-n/--limit`, `--all`, `-a/--agent`,
12
+ * `--no-redact`) are inherited from the parent `sessions` command and read via
13
+ * optsWithGlobals(), so they never shadow the parent's parsing; this command
14
+ * adds only the export-specific flags (`-o/--output`, `--stdout`, `--encrypt`).
15
+ *
16
+ * Rendered markdown/json of a single session is already served by
17
+ * `agents sessions <id> --markdown|--json`; export is specifically the portable,
18
+ * re-importable BUNDLE, so it does not re-expose those render formats.
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import chalk from 'chalk';
23
+ import { discoverSessions, resolveSessionById } from '../lib/session/discover.js';
24
+ import { filterSessionsByQuery, parseAgentFilter } from './sessions.js';
25
+ import { listLocalTranscripts, SYNC_AGENTS } from '../lib/session/sync/agents.js';
26
+ import { machineId } from '../lib/machine-id.js';
27
+ import { getHistoryDir } from '../lib/state.js';
28
+ import { loadR2Config } from '../lib/session/sync/config.js';
29
+ import { resolveSyncEncKey, generateSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
30
+ import { buildRecord, makeHeader, mergeRecords, serializeBundle, specForAgent, } from '../lib/session/bundle.js';
31
+ import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
32
+ import { setHelpSections } from '../lib/help.js';
33
+ /** Default cap when exporting a scope (not explicit ids) and the user gave no -n. */
34
+ const DEFAULT_LIMIT = 500;
35
+ export function registerSessionsExportCommand(sessionsCmd) {
36
+ const cmd = sessionsCmd
37
+ .command('export [selectors...]')
38
+ .description('Bundle sessions (by id, query, or the parent selection flags like --since/-a) into a portable archive.')
39
+ .option('-o, --output <path>', 'Write the bundle to this file')
40
+ .option('--stdout', 'Write the bundle to stdout (for piping into `sessions import -`)')
41
+ .option('--encrypt', 'Seal each transcript body with AES-256-GCM before writing');
42
+ setHelpSections(cmd, {
43
+ examples: `# Bundle the last week of sessions to a file
44
+ agents sessions export --since 7d -o week.bundle
45
+
46
+ # Bundle two specific sessions
47
+ agents sessions export 4f8a2b1c 9d3e7a55 -o pair.bundle
48
+
49
+ # Encrypt + pipe straight into another machine over SSH
50
+ agents sessions export --since 7d --stdout --encrypt | agents ssh boxB 'agents sessions import - --decrypt <key>'`,
51
+ notes: `Selection uses the same flags as 'agents sessions' (--since, -n/--limit, --all,
52
+ -a/--agent, --no-redact). Bundles are self-describing NDJSON: a header line + one
53
+ line per transcript file. Secrets are redacted by default. Dir-shaped sessions
54
+ (Kimi) carry all their files. Restore with 'agents sessions import'.`,
55
+ });
56
+ cmd.action(async (selectors, _options, command) => {
57
+ await runExport(selectors, command);
58
+ });
59
+ }
60
+ async function runExport(selectors, command) {
61
+ const g = command.optsWithGlobals();
62
+ // --host: export sessions that live on remote peer(s) — run export there and
63
+ // stream the bundle back over the existing SSH transport (RUSH-1712).
64
+ if (g.host && g.host.length > 0) {
65
+ await runRemoteExport(g, selectors, command);
66
+ return;
67
+ }
68
+ const explicitLimit = command.parent?.getOptionValueSource?.('limit') === 'cli';
69
+ const limit = explicitLimit ? Math.max(1, parseInt(String(g.limit), 10) || DEFAULT_LIMIT) : DEFAULT_LIMIT;
70
+ const agentFilter = parseAgentFilter(resolveAgentShorthand(g));
71
+ // 1. Discover candidate sessions in scope.
72
+ const metas = await discoverSessions({
73
+ all: g.all !== false,
74
+ agent: agentFilter.agent ?? undefined,
75
+ since: g.since,
76
+ limit,
77
+ });
78
+ // 2. Narrow to the selection (ids > query > everything-in-scope).
79
+ const selected = selectSessions(metas, selectors);
80
+ if (selected.length === 0) {
81
+ process.stderr.write(chalk.yellow('No sessions matched the selection.\n'));
82
+ process.exit(1);
83
+ }
84
+ if (!selectors.length && selected.length >= limit) {
85
+ process.stderr.write(chalk.yellow(`Note: capped at ${limit} sessions. Raise -n to bundle more.\n`));
86
+ }
87
+ // 3. Resolve each selected session to its on-disk file(s).
88
+ const index = buildLocalIndex();
89
+ const self = machineId();
90
+ const files = [];
91
+ const skippedAgents = new Set();
92
+ for (const meta of selected) {
93
+ const spec = specForAgent(meta.agent);
94
+ if (!spec) {
95
+ skippedAgents.add(meta.agent);
96
+ continue;
97
+ }
98
+ const machine = meta.machine || self;
99
+ const lt = index.get(`${meta.agent}:${meta.id}`);
100
+ if (lt) {
101
+ for (const f of lt.files) {
102
+ files.push({ agent: meta.agent, machine, sessionId: meta.id, relKey: f.relKey, absPath: f.absPath, label: meta.label });
103
+ }
104
+ }
105
+ else if (meta.filePath && fs.existsSync(meta.filePath)) {
106
+ // Not in the live-home index (e.g. a mirror of another machine): fall back
107
+ // to the single discovered file, deriving its subdir-relative key.
108
+ const relKey = relKeyFromPath(meta.filePath, meta.agent, machine, spec.subdir);
109
+ files.push({ agent: meta.agent, machine, sessionId: meta.id, relKey, absPath: meta.filePath, label: meta.label });
110
+ }
111
+ }
112
+ if (skippedAgents.size > 0) {
113
+ process.stderr.write(chalk.yellow(`Skipped agents with no portable format: ${[...skippedAgents].sort().join(', ')}.\n`));
114
+ }
115
+ if (files.length === 0) {
116
+ process.stderr.write(chalk.red('Selected sessions have no exportable transcript files.\n'));
117
+ process.exit(1);
118
+ }
119
+ // 4. Resolve encryption key (opt-in) + redaction (default on via parent --no-redact).
120
+ const encryptKey = g.encrypt ? resolveExportKey() : null;
121
+ const redact = g.redact !== false;
122
+ // 5. Build records + header.
123
+ const records = [];
124
+ for (const f of files) {
125
+ try {
126
+ records.push(buildRecord(f, { redact, encryptKey }));
127
+ }
128
+ catch (err) {
129
+ process.stderr.write(chalk.yellow(`Skipped ${f.agent}/${f.sessionId} (${f.relKey}): ${err.message}\n`));
130
+ }
131
+ }
132
+ if (records.length === 0) {
133
+ process.stderr.write(chalk.red('Nothing to export after reading files.\n'));
134
+ process.exit(1);
135
+ }
136
+ const header = makeHeader({
137
+ origin: self,
138
+ exportedAt: new Date().toISOString(),
139
+ encrypted: encryptKey !== null,
140
+ redacted: redact,
141
+ records,
142
+ });
143
+ emitBundle(header, records, g);
144
+ }
145
+ /**
146
+ * --host path: run `agents sessions export …` on each peer over SSH, stream the
147
+ * bundles back, merge (dedup by origin machine) and emit one local bundle.
148
+ * Encryption is not combined with a remote pull (each peer would seal under its
149
+ * own key); the SSH transport already encrypts the stream in transit.
150
+ */
151
+ async function runRemoteExport(g, selectors, command) {
152
+ if (g.encrypt) {
153
+ process.stderr.write(chalk.yellow('Note: --encrypt is ignored with --host (the SSH stream is already encrypted). Encrypt a local bundle instead.\n'));
154
+ }
155
+ const { bundles, errors } = await pullBundlesFromHosts(g.host, forwardExportArgs(g, selectors, command));
156
+ for (const e of errors)
157
+ process.stderr.write(chalk.yellow(` ${e}\n`));
158
+ const records = mergeRecords(bundles.map(b => b.records));
159
+ if (records.length === 0) {
160
+ process.stderr.write(chalk.red('No sessions pulled from the given host(s).\n'));
161
+ process.exit(1);
162
+ }
163
+ const header = makeHeader({
164
+ origin: g.host.join(','),
165
+ exportedAt: new Date().toISOString(),
166
+ encrypted: false,
167
+ redacted: g.redact !== false,
168
+ records,
169
+ });
170
+ emitBundle(header, records, g);
171
+ }
172
+ /** Reconstruct the export flags to forward to a peer's own `sessions export`. */
173
+ function forwardExportArgs(g, selectors, command) {
174
+ const args = [...selectors];
175
+ if (g.since)
176
+ args.push('--since', g.since);
177
+ const agent = resolveAgentShorthand(g);
178
+ if (agent)
179
+ args.push('-a', agent);
180
+ if (g.all !== false)
181
+ args.push('--all');
182
+ if (g.redact === false)
183
+ args.push('--no-redact');
184
+ if (command.parent?.getOptionValueSource?.('limit') === 'cli' && g.limit)
185
+ args.push('-n', String(g.limit));
186
+ return args;
187
+ }
188
+ /** Write the assembled bundle to stdout or a file. */
189
+ function emitBundle(header, records, g) {
190
+ const wire = serializeBundle(header, records);
191
+ if (g.stdout) {
192
+ process.stdout.write(wire);
193
+ return;
194
+ }
195
+ const outPath = g.output || defaultBundlePath();
196
+ fs.writeFileSync(outPath, wire, 'utf-8');
197
+ process.stderr.write(chalk.green(`Exported ${header.sessions} session${header.sessions === 1 ? '' : 's'} ` +
198
+ `(${header.count} file${header.count === 1 ? '' : 's'}${header.encrypted ? ', encrypted' : ''}${header.redacted ? ', redacted' : ''}) ` +
199
+ `→ ${outPath}\n`));
200
+ }
201
+ /** Map the parent's agent shorthands (--claude, --codex, …) or -a/--agent to a filter string. */
202
+ function resolveAgentShorthand(g) {
203
+ if (g.agent)
204
+ return g.agent;
205
+ if (g.claude)
206
+ return 'claude';
207
+ if (g.codex)
208
+ return 'codex';
209
+ if (g.kimi)
210
+ return 'kimi';
211
+ if (g.grok)
212
+ return 'grok';
213
+ if (g.opencode)
214
+ return 'opencode';
215
+ if (g.antigravity)
216
+ return 'antigravity';
217
+ return undefined;
218
+ }
219
+ /** ids > query > everything-in-scope. */
220
+ function selectSessions(metas, selectors) {
221
+ if (selectors.length === 0)
222
+ return metas;
223
+ const byId = [];
224
+ const unmatched = [];
225
+ for (const sel of selectors) {
226
+ const hits = resolveSessionById(metas, sel);
227
+ if (hits.length > 0)
228
+ byId.push(...hits);
229
+ else
230
+ unmatched.push(sel);
231
+ }
232
+ if (byId.length > 0 && unmatched.length === 0) {
233
+ const seen = new Set();
234
+ return byId.filter(s => (seen.has(s.id) ? false : (seen.add(s.id), true)));
235
+ }
236
+ // Any selector that isn't an id → treat the whole thing as a text query.
237
+ return filterSessionsByQuery(metas, selectors.join(' '));
238
+ }
239
+ /** Build `${agent}:${sessionId}` → LocalTranscript across every sync agent (live home only). */
240
+ function buildLocalIndex() {
241
+ const index = new Map();
242
+ for (const spec of SYNC_AGENTS) {
243
+ for (const lt of listLocalTranscripts(spec)) {
244
+ index.set(`${spec.id}:${lt.sessionId}`, lt);
245
+ }
246
+ }
247
+ return index;
248
+ }
249
+ /** Derive the subdir-relative key for a mirror file path, else fall back to the basename. */
250
+ function relKeyFromPath(filePath, agent, machine, subdir) {
251
+ const prefix = path.join(getHistoryDir(), 'backups', agent, machine, subdir) + path.sep;
252
+ if (filePath.startsWith(prefix))
253
+ return filePath.slice(prefix.length);
254
+ return path.basename(filePath);
255
+ }
256
+ /**
257
+ * Resolve the AES key for --encrypt: prefer the fleet-shared R2_SYNC_ENC_KEY (so
258
+ * any machine on the sync bundle can decrypt), else mint an ephemeral key and
259
+ * print it once — it is NOT stored in the bundle.
260
+ */
261
+ function resolveExportKey() {
262
+ try {
263
+ const key = resolveSyncEncKey(loadR2Config());
264
+ if (key)
265
+ return key;
266
+ }
267
+ catch {
268
+ // sync bundle not configured — fall through to an ephemeral key
269
+ }
270
+ const b64 = generateSyncEncKey();
271
+ process.stderr.write(chalk.yellow(`Bundle encrypted with a fresh key (not in the bundle). Decrypt with:\n` +
272
+ ` agents sessions import <bundle> --decrypt ${b64}\n`));
273
+ return Buffer.from(b64, 'base64');
274
+ }
275
+ /** Default output file when neither -o nor --stdout is given. */
276
+ function defaultBundlePath() {
277
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
278
+ return path.join(process.cwd(), `agents-sessions-${stamp}.bundle`);
279
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSessionsImportCommand(sessionsCmd: Command): void;
@@ -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
+ }
@@ -44,6 +44,8 @@ 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';
47
49
  import { runBrowserSessions } from '../lib/browser/sessions-list.js';
48
50
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
49
51
  /**
@@ -2122,6 +2124,8 @@ export function registerSessionsCommands(program) {
2122
2124
  registerGoCommand(sessionsCmd);
2123
2125
  registerFocusCommand(sessionsCmd);
2124
2126
  registerSessionsInjectCommand(sessionsCmd);
2127
+ registerSessionsExportCommand(sessionsCmd);
2128
+ registerSessionsImportCommand(sessionsCmd);
2125
2129
  }
2126
2130
  function formatNoSessionsMessage(showAll, project) {
2127
2131
  const projectQuery = project?.trim();
@@ -26,6 +26,7 @@ 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
28
  import { planFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
29
+ import { fleetCapacity, fmtBytes, headroom, probeFleetStats, } from '../lib/devices/health.js';
29
30
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
30
31
  * command is running on so it stands out from the rest of the tailnet. */
31
32
  function deviceSummary(d, isSelf = false) {
@@ -41,6 +42,80 @@ function deviceSummary(d, isSelf = false) {
41
42
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
42
43
  return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}`;
43
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
+ }
44
119
  /** Resolve a device or exit with a clear error. */
45
120
  async function mustGetDevice(name) {
46
121
  const d = await getDevice(name);
@@ -230,12 +305,15 @@ Typical workflow:
230
305
  devicesCmd
231
306
  .command('list')
232
307
  .alias('ls')
233
- .description('List registered devices with platform, address, and reachability.')
308
+ .description('List registered devices with platform, address, reachability, and live resource headroom.')
234
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')
235
312
  .action(async (opts) => {
236
313
  const reg = await loadDevices();
237
314
  const names = Object.keys(reg).sort();
238
315
  if (opts.json) {
316
+ // Registry-only, always fast — the Factory extension polls this path.
239
317
  process.stdout.write(JSON.stringify(names.map((n) => reg[n]), null, 2) + '\n');
240
318
  return;
241
319
  }
@@ -244,9 +322,26 @@ Typical workflow:
244
322
  return;
245
323
  }
246
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
+ }
247
342
  console.log(chalk.bold(`Devices (${names.length})`));
248
- for (const name of names)
249
- console.log(deviceSummary(reg[name], name === self));
343
+ for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full))
344
+ console.log(line);
250
345
  });
251
346
  devicesCmd
252
347
  .command('show <name>')
@@ -4,6 +4,8 @@
4
4
  * Lists every installed agent with the best available usage snapshot:
5
5
  * - claude: live OAuth API call (cached for 2 minutes)
6
6
  * - codex: parsed from latest session log's rate_limits event
7
+ * - kimi: live Kimi Code /usages API call (cached for 2 minutes)
8
+ * - droid: live Factory billing/limits API call (cached for 2 minutes)
7
9
  * - others: marked as "not exposed by CLI" (Gemini, OpenCode, Cursor, etc.
8
10
  * don't publish per-account usage today)
9
11
  */