@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.
- package/CHANGELOG.md +62 -0
- package/README.md +19 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +96 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- 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/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +92 -29
- 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-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +12 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +170 -5
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- 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/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +7 -2
- package/dist/lib/agents.d.ts +44 -0
- package/dist/lib/agents.js +85 -35
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- 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 +8 -1
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +50 -12
- package/dist/lib/routines.js +82 -27
- 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 +56 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +26 -2
- package/dist/lib/session/sync/sync.js +192 -69
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- 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 +4 -0
- package/dist/lib/startup/command-registry.js +16 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- 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/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- 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,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
|
|
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();
|
package/dist/commands/setup.js
CHANGED
|
@@ -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'));
|
package/dist/commands/ssh.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
.
|
|
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
|
|
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
|
|
221
|
-
console.log(
|
|
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>;
|