@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.
- package/CHANGELOG.md +17 -0
- package/README.md +9 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +55 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/routines.js +20 -14
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +279 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/ssh.js +98 -3
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/view.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +18 -0
- package/dist/lib/agents.js +27 -17
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +158 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +24 -0
- package/dist/lib/daemon.js +6 -1
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +21 -2
- package/dist/lib/routines.js +35 -12
- package/dist/lib/runner.js +255 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/session/bundle.d.ts +150 -0
- package/dist/lib/session/bundle.js +189 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/sync/agents.d.ts +54 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/sync.d.ts +23 -2
- package/dist/lib/session/sync/sync.js +177 -74
- package/dist/lib/ssh-tunnel.js +13 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/types.d.ts +9 -0
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +162 -22
- 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,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();
|
package/dist/commands/ssh.js
CHANGED
|
@@ -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
|
|
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
|
|
249
|
-
console.log(
|
|
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>')
|
package/dist/commands/usage.d.ts
CHANGED
|
@@ -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
|
*/
|