@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,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable session bundle — the on-the-wire format behind `agents sessions
|
|
3
|
+
* export` / `import` (RUSH-1710 / RUSH-1711).
|
|
4
|
+
*
|
|
5
|
+
* A bundle is a self-describing NDJSON stream: the FIRST line is a
|
|
6
|
+
* {@link BundleHeader}, every subsequent line is one {@link BundleRecord} (one
|
|
7
|
+
* constituent file of a session). NDJSON — not tar — because the bundle has to
|
|
8
|
+
* pipe cleanly over `agents ssh … export --stdout | … import -` (RUSH-1712)
|
|
9
|
+
* without any external archiver on either box, stays inspectable with `head`,
|
|
10
|
+
* and lets each file body carry its own encryption envelope.
|
|
11
|
+
*
|
|
12
|
+
* This module owns the FORMAT and the import PLACEMENT only; selecting which
|
|
13
|
+
* sessions to export (which needs the session DB) lives in the export command.
|
|
14
|
+
* Placement reuses the sync mirror model verbatim: a foreign machine's session
|
|
15
|
+
* lands at {@link mirrorPath}(spec, originMachine, relKey), exactly where the
|
|
16
|
+
* cross-machine sync writes it — so the existing scanner indexes it as a
|
|
17
|
+
* machine-tagged row and "local always wins" falls out of the scanner's
|
|
18
|
+
* live-home-first dedup with no extra logic here.
|
|
19
|
+
*/
|
|
20
|
+
import { type SyncAgentSpec } from './sync/agents.js';
|
|
21
|
+
export declare const BUNDLE_KIND = "agents-session-bundle";
|
|
22
|
+
export declare const BUNDLE_VERSION = 1;
|
|
23
|
+
/** First line of a bundle: what it is and how the bodies are encoded. */
|
|
24
|
+
export interface BundleHeader {
|
|
25
|
+
kind: typeof BUNDLE_KIND;
|
|
26
|
+
version: number;
|
|
27
|
+
/** ISO timestamp the bundle was produced. */
|
|
28
|
+
exportedAt: string;
|
|
29
|
+
/** Machine that produced the bundle (informational; per-record `machine` is authoritative for placement). */
|
|
30
|
+
origin: string;
|
|
31
|
+
/** True when record bodies are AES-256-GCM envelopes (see transcript-crypto). */
|
|
32
|
+
encrypted: boolean;
|
|
33
|
+
/** True when bodies were secret-scrubbed before hashing/sealing. */
|
|
34
|
+
redacted: boolean;
|
|
35
|
+
/** Number of file records that follow. */
|
|
36
|
+
count: number;
|
|
37
|
+
/** Distinct session count across those records. */
|
|
38
|
+
sessions: number;
|
|
39
|
+
}
|
|
40
|
+
/** One constituent file of one session. Dir-shaped sessions emit several, sharing `sessionId`. */
|
|
41
|
+
export interface BundleRecord {
|
|
42
|
+
/** SYNC_AGENTS id (claude, codex, kimi, …). */
|
|
43
|
+
agent: string;
|
|
44
|
+
/** ORIGIN machine of this session — where placement mirrors it to. */
|
|
45
|
+
machine: string;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
/** Storage-relative key within the agent's subdir (preserved on import). */
|
|
48
|
+
relKey: string;
|
|
49
|
+
/** Byte length of the plaintext body. */
|
|
50
|
+
size: number;
|
|
51
|
+
/** SHA-256 of the plaintext body — identity + byte-exact dedup. */
|
|
52
|
+
hash: string;
|
|
53
|
+
/** Human label carried from SessionMeta, if any. */
|
|
54
|
+
label?: string;
|
|
55
|
+
/** True when `body` is an encryption envelope rather than plaintext. */
|
|
56
|
+
encrypted: boolean;
|
|
57
|
+
/** File content: plaintext, or a transcript-crypto envelope when `encrypted`. */
|
|
58
|
+
body: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ParsedBundle {
|
|
61
|
+
header: BundleHeader;
|
|
62
|
+
records: BundleRecord[];
|
|
63
|
+
}
|
|
64
|
+
/** A single file selected for export, resolved to an absolute on-disk path. */
|
|
65
|
+
export interface FileToExport {
|
|
66
|
+
agent: string;
|
|
67
|
+
/** Origin machine of the session (self for live-home, the peer for a mirror). */
|
|
68
|
+
machine: string;
|
|
69
|
+
sessionId: string;
|
|
70
|
+
relKey: string;
|
|
71
|
+
absPath: string;
|
|
72
|
+
label?: string;
|
|
73
|
+
}
|
|
74
|
+
export interface BuildRecordOpts {
|
|
75
|
+
/** Scrub secrets from the body before hashing/sealing (default-on at the command layer). */
|
|
76
|
+
redact: boolean;
|
|
77
|
+
/** Non-null → seal each body with this key; null → plaintext bodies. */
|
|
78
|
+
encryptKey: Buffer | null;
|
|
79
|
+
}
|
|
80
|
+
/** Look up the sync spec for an agent id (undefined → agent not sync-representable). */
|
|
81
|
+
export declare function specForAgent(agentId: string): SyncAgentSpec | undefined;
|
|
82
|
+
/** True when an agent's sessions can be represented in a bundle (has a sync spec). */
|
|
83
|
+
export declare function isExportableAgent(agentId: string): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Read one file and turn it into a bundle record. The hash and size are always
|
|
86
|
+
* computed over the PLAINTEXT (post-redaction) body, so they equal what lands on
|
|
87
|
+
* disk after import — keeping dedup byte-exact whether or not the bundle is
|
|
88
|
+
* encrypted.
|
|
89
|
+
*/
|
|
90
|
+
export declare function buildRecord(file: FileToExport, opts: BuildRecordOpts): BundleRecord;
|
|
91
|
+
/** Build the header for a set of records. */
|
|
92
|
+
export declare function makeHeader(args: {
|
|
93
|
+
origin: string;
|
|
94
|
+
exportedAt: string;
|
|
95
|
+
encrypted: boolean;
|
|
96
|
+
redacted: boolean;
|
|
97
|
+
records: BundleRecord[];
|
|
98
|
+
}): BundleHeader;
|
|
99
|
+
/**
|
|
100
|
+
* Merge record sets from several bundles (e.g. a fan-out pull across hosts),
|
|
101
|
+
* deduping by agent + origin machine + session + file so the same session seen
|
|
102
|
+
* from two peers lands once. First occurrence wins.
|
|
103
|
+
*/
|
|
104
|
+
export declare function mergeRecords(sets: BundleRecord[][]): BundleRecord[];
|
|
105
|
+
/** Serialize a bundle to its NDJSON wire form (header line + one line per record). */
|
|
106
|
+
export declare function serializeBundle(header: BundleHeader, records: BundleRecord[]): string;
|
|
107
|
+
/** Parse an NDJSON bundle, validating the header kind + version. Throws on malformed input. */
|
|
108
|
+
export declare function parseBundle(text: string): ParsedBundle;
|
|
109
|
+
/** Placement outcome for one record, computed against what is already on disk. */
|
|
110
|
+
export type ImportStatus = 'new' | 'dup' | 'conflict' | 'unknown';
|
|
111
|
+
export interface ImportPlanItem {
|
|
112
|
+
record: BundleRecord;
|
|
113
|
+
/** Absolute path the file lands at (empty for `unknown` agents). */
|
|
114
|
+
targetPath: string;
|
|
115
|
+
status: ImportStatus;
|
|
116
|
+
}
|
|
117
|
+
export interface PlanImportOpts {
|
|
118
|
+
/** Key to open encrypted record bodies (null → only plaintext bodies readable). */
|
|
119
|
+
decryptKey: Buffer | null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Compute where each record lands and whether it duplicates / conflicts with an
|
|
123
|
+
* existing file. Pure w.r.t. the filesystem it reads (no writes). Dedup is
|
|
124
|
+
* byte-exact: a target that already holds an identical body is `dup`; a target
|
|
125
|
+
* that holds a DIFFERENT body is `conflict` (only overwritten with --overwrite).
|
|
126
|
+
* An agent with no sync spec is `unknown` and never placed.
|
|
127
|
+
*/
|
|
128
|
+
export declare function planImport(bundle: ParsedBundle, opts: PlanImportOpts): ImportPlanItem[];
|
|
129
|
+
export interface WriteResult {
|
|
130
|
+
/** New files written. */
|
|
131
|
+
placed: number;
|
|
132
|
+
/** Byte-exact dups skipped. */
|
|
133
|
+
skipped: number;
|
|
134
|
+
/** Conflicts replaced (only with overwrite). */
|
|
135
|
+
overwritten: number;
|
|
136
|
+
/** Conflicts left in place (overwrite off). */
|
|
137
|
+
conflicts: number;
|
|
138
|
+
/** Records for agents with no sync spec. */
|
|
139
|
+
unknown: number;
|
|
140
|
+
}
|
|
141
|
+
export interface WriteImportOpts {
|
|
142
|
+
overwrite: boolean;
|
|
143
|
+
decryptKey: Buffer | null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Materialize a plan to disk. `dup` records are always skipped (local wins);
|
|
147
|
+
* `conflict` records are replaced only when `overwrite` is set; `unknown` records
|
|
148
|
+
* are counted and skipped.
|
|
149
|
+
*/
|
|
150
|
+
export declare function writeImport(plan: ImportPlanItem[], opts: WriteImportOpts): WriteResult;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable session bundle — the on-the-wire format behind `agents sessions
|
|
3
|
+
* export` / `import` (RUSH-1710 / RUSH-1711).
|
|
4
|
+
*
|
|
5
|
+
* A bundle is a self-describing NDJSON stream: the FIRST line is a
|
|
6
|
+
* {@link BundleHeader}, every subsequent line is one {@link BundleRecord} (one
|
|
7
|
+
* constituent file of a session). NDJSON — not tar — because the bundle has to
|
|
8
|
+
* pipe cleanly over `agents ssh … export --stdout | … import -` (RUSH-1712)
|
|
9
|
+
* without any external archiver on either box, stays inspectable with `head`,
|
|
10
|
+
* and lets each file body carry its own encryption envelope.
|
|
11
|
+
*
|
|
12
|
+
* This module owns the FORMAT and the import PLACEMENT only; selecting which
|
|
13
|
+
* sessions to export (which needs the session DB) lives in the export command.
|
|
14
|
+
* Placement reuses the sync mirror model verbatim: a foreign machine's session
|
|
15
|
+
* lands at {@link mirrorPath}(spec, originMachine, relKey), exactly where the
|
|
16
|
+
* cross-machine sync writes it — so the existing scanner indexes it as a
|
|
17
|
+
* machine-tagged row and "local always wins" falls out of the scanner's
|
|
18
|
+
* live-home-first dedup with no extra logic here.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import { SYNC_AGENTS, mirrorPath } from './sync/agents.js';
|
|
23
|
+
import { hashContent } from './sync/manifest.js';
|
|
24
|
+
import { redactSecrets } from '../redact.js';
|
|
25
|
+
import { encryptTranscript, decryptTranscriptBody } from './sync/transcript-crypto.js';
|
|
26
|
+
export const BUNDLE_KIND = 'agents-session-bundle';
|
|
27
|
+
export const BUNDLE_VERSION = 1;
|
|
28
|
+
/** Look up the sync spec for an agent id (undefined → agent not sync-representable). */
|
|
29
|
+
export function specForAgent(agentId) {
|
|
30
|
+
return SYNC_AGENTS.find(s => s.id === agentId);
|
|
31
|
+
}
|
|
32
|
+
/** True when an agent's sessions can be represented in a bundle (has a sync spec). */
|
|
33
|
+
export function isExportableAgent(agentId) {
|
|
34
|
+
return specForAgent(agentId) !== undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read one file and turn it into a bundle record. The hash and size are always
|
|
38
|
+
* computed over the PLAINTEXT (post-redaction) body, so they equal what lands on
|
|
39
|
+
* disk after import — keeping dedup byte-exact whether or not the bundle is
|
|
40
|
+
* encrypted.
|
|
41
|
+
*/
|
|
42
|
+
export function buildRecord(file, opts) {
|
|
43
|
+
let body = fs.readFileSync(file.absPath, 'utf-8');
|
|
44
|
+
if (opts.redact)
|
|
45
|
+
body = redactSecrets(body);
|
|
46
|
+
const hash = hashContent(body);
|
|
47
|
+
const size = Buffer.byteLength(body, 'utf-8');
|
|
48
|
+
let stored = body;
|
|
49
|
+
let encrypted = false;
|
|
50
|
+
if (opts.encryptKey) {
|
|
51
|
+
stored = encryptTranscript(body, opts.encryptKey);
|
|
52
|
+
encrypted = true;
|
|
53
|
+
}
|
|
54
|
+
const rec = {
|
|
55
|
+
agent: file.agent,
|
|
56
|
+
machine: file.machine,
|
|
57
|
+
sessionId: file.sessionId,
|
|
58
|
+
relKey: file.relKey,
|
|
59
|
+
size,
|
|
60
|
+
hash,
|
|
61
|
+
encrypted,
|
|
62
|
+
body: stored,
|
|
63
|
+
};
|
|
64
|
+
if (file.label)
|
|
65
|
+
rec.label = file.label;
|
|
66
|
+
return rec;
|
|
67
|
+
}
|
|
68
|
+
/** Build the header for a set of records. */
|
|
69
|
+
export function makeHeader(args) {
|
|
70
|
+
const sessions = new Set(args.records.map(r => `${r.agent}:${r.machine}:${r.sessionId}`)).size;
|
|
71
|
+
return {
|
|
72
|
+
kind: BUNDLE_KIND,
|
|
73
|
+
version: BUNDLE_VERSION,
|
|
74
|
+
exportedAt: args.exportedAt,
|
|
75
|
+
origin: args.origin,
|
|
76
|
+
encrypted: args.encrypted,
|
|
77
|
+
redacted: args.redacted,
|
|
78
|
+
count: args.records.length,
|
|
79
|
+
sessions,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Merge record sets from several bundles (e.g. a fan-out pull across hosts),
|
|
84
|
+
* deduping by agent + origin machine + session + file so the same session seen
|
|
85
|
+
* from two peers lands once. First occurrence wins.
|
|
86
|
+
*/
|
|
87
|
+
export function mergeRecords(sets) {
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const set of sets) {
|
|
91
|
+
for (const r of set) {
|
|
92
|
+
const key = `${r.agent}:${r.machine}:${r.sessionId}:${r.relKey}`;
|
|
93
|
+
if (seen.has(key))
|
|
94
|
+
continue;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
out.push(r);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Serialize a bundle to its NDJSON wire form (header line + one line per record). */
|
|
102
|
+
export function serializeBundle(header, records) {
|
|
103
|
+
const lines = [JSON.stringify(header)];
|
|
104
|
+
for (const r of records)
|
|
105
|
+
lines.push(JSON.stringify(r));
|
|
106
|
+
return lines.join('\n') + '\n';
|
|
107
|
+
}
|
|
108
|
+
/** Parse an NDJSON bundle, validating the header kind + version. Throws on malformed input. */
|
|
109
|
+
export function parseBundle(text) {
|
|
110
|
+
const lines = text.split('\n').filter(l => l.trim().length > 0);
|
|
111
|
+
if (lines.length === 0)
|
|
112
|
+
throw new Error('Empty session bundle.');
|
|
113
|
+
let header;
|
|
114
|
+
try {
|
|
115
|
+
header = JSON.parse(lines[0]);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new Error('Malformed session bundle: first line is not JSON.');
|
|
119
|
+
}
|
|
120
|
+
if (!header || header.kind !== BUNDLE_KIND) {
|
|
121
|
+
throw new Error(`Not an agents session bundle (kind=${header?.kind ?? 'missing'}).`);
|
|
122
|
+
}
|
|
123
|
+
if (header.version !== BUNDLE_VERSION) {
|
|
124
|
+
throw new Error(`Unsupported bundle version ${header.version} — this CLI reads v${BUNDLE_VERSION}.`);
|
|
125
|
+
}
|
|
126
|
+
const records = [];
|
|
127
|
+
for (let i = 1; i < lines.length; i++) {
|
|
128
|
+
try {
|
|
129
|
+
records.push(JSON.parse(lines[i]));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new Error(`Malformed session bundle: record on line ${i + 1} is not JSON.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { header, records };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Compute where each record lands and whether it duplicates / conflicts with an
|
|
139
|
+
* existing file. Pure w.r.t. the filesystem it reads (no writes). Dedup is
|
|
140
|
+
* byte-exact: a target that already holds an identical body is `dup`; a target
|
|
141
|
+
* that holds a DIFFERENT body is `conflict` (only overwritten with --overwrite).
|
|
142
|
+
* An agent with no sync spec is `unknown` and never placed.
|
|
143
|
+
*/
|
|
144
|
+
export function planImport(bundle, opts) {
|
|
145
|
+
return bundle.records.map((record) => {
|
|
146
|
+
const spec = specForAgent(record.agent);
|
|
147
|
+
if (!spec)
|
|
148
|
+
return { record, targetPath: '', status: 'unknown' };
|
|
149
|
+
const body = decryptTranscriptBody(record.body, opts.decryptKey);
|
|
150
|
+
const bodyHash = hashContent(body);
|
|
151
|
+
const targetPath = mirrorPath(spec, record.machine, record.relKey);
|
|
152
|
+
let status = 'new';
|
|
153
|
+
if (fs.existsSync(targetPath)) {
|
|
154
|
+
const existing = fs.readFileSync(targetPath, 'utf-8');
|
|
155
|
+
status = hashContent(existing) === bodyHash ? 'dup' : 'conflict';
|
|
156
|
+
}
|
|
157
|
+
return { record, targetPath, status };
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Materialize a plan to disk. `dup` records are always skipped (local wins);
|
|
162
|
+
* `conflict` records are replaced only when `overwrite` is set; `unknown` records
|
|
163
|
+
* are counted and skipped.
|
|
164
|
+
*/
|
|
165
|
+
export function writeImport(plan, opts) {
|
|
166
|
+
const res = { placed: 0, skipped: 0, overwritten: 0, conflicts: 0, unknown: 0 };
|
|
167
|
+
for (const item of plan) {
|
|
168
|
+
if (item.status === 'unknown') {
|
|
169
|
+
res.unknown++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (item.status === 'dup') {
|
|
173
|
+
res.skipped++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (item.status === 'conflict' && !opts.overwrite) {
|
|
177
|
+
res.conflicts++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const body = decryptTranscriptBody(item.record.body, opts.decryptKey);
|
|
181
|
+
fs.mkdirSync(path.dirname(item.targetPath), { recursive: true });
|
|
182
|
+
fs.writeFileSync(item.targetPath, body, 'utf-8');
|
|
183
|
+
if (item.status === 'conflict')
|
|
184
|
+
res.overwritten++;
|
|
185
|
+
else
|
|
186
|
+
res.placed++;
|
|
187
|
+
}
|
|
188
|
+
return res;
|
|
189
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ParsedBundle } from './bundle.js';
|
|
2
|
+
export interface RemotePullResult {
|
|
3
|
+
bundles: ParsedBundle[];
|
|
4
|
+
errors: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Run `agents sessions export …exportArgs --stdout` on each host and parse the
|
|
8
|
+
* streamed bundle. A host that fails (unreachable, remote error, bad output) is
|
|
9
|
+
* collected in `errors` and skipped — one asleep peer never aborts the pull.
|
|
10
|
+
* `exportArgs` must NOT contain --host (the remote export runs for itself only).
|
|
11
|
+
*/
|
|
12
|
+
export declare function pullBundlesFromHosts(hosts: string[], exportArgs: string[]): Promise<RemotePullResult>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-device session transfer over the EXISTING SSH fleet transport
|
|
3
|
+
* (RUSH-1712) — no R2, no daemon. `agents sessions export --host <h>` and
|
|
4
|
+
* `agents sessions import --from-host <h>` both run `agents sessions export
|
|
5
|
+
* … --stdout` ON the peer and stream the bundle back over the same SSH path the
|
|
6
|
+
* cross-machine listing already uses (resolveExplicitTargets + ssh-exec), then
|
|
7
|
+
* either write it (export) or import it (import) locally.
|
|
8
|
+
*
|
|
9
|
+
* This deliberately reuses ssh-exec / resolve-target rather than adding a second
|
|
10
|
+
* transport: the raw form `agents ssh boxA 'agents sessions export --stdout' |
|
|
11
|
+
* agents sessions import -` works with plain export/import; this module is just
|
|
12
|
+
* the one-shot wrapper around it.
|
|
13
|
+
*/
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { sshExec } from '../ssh-exec.js';
|
|
16
|
+
import { shellQuote } from '../ssh-exec.js';
|
|
17
|
+
import { resolveExplicitTargets } from '../devices/resolve-target.js';
|
|
18
|
+
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
19
|
+
import { parseBundle } from './bundle.js';
|
|
20
|
+
/** Remote export can traverse many sessions; give it a generous ceiling. */
|
|
21
|
+
const REMOTE_EXPORT_TIMEOUT_MS = 300_000;
|
|
22
|
+
/** Build `agents <args>` for the peer's login shell (bash or PowerShell). */
|
|
23
|
+
function remoteAgentsCommand(args, os) {
|
|
24
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
25
|
+
return buildWindowsAgentsCommand({ args });
|
|
26
|
+
}
|
|
27
|
+
const inner = ['agents', ...args].map((t, i) => (i === 0 ? t : shellQuote(t))).join(' ');
|
|
28
|
+
return `bash -lc ${shellQuote(inner)}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Run `agents sessions export …exportArgs --stdout` on each host and parse the
|
|
32
|
+
* streamed bundle. A host that fails (unreachable, remote error, bad output) is
|
|
33
|
+
* collected in `errors` and skipped — one asleep peer never aborts the pull.
|
|
34
|
+
* `exportArgs` must NOT contain --host (the remote export runs for itself only).
|
|
35
|
+
*/
|
|
36
|
+
export async function pullBundlesFromHosts(hosts, exportArgs) {
|
|
37
|
+
const targets = await resolveExplicitTargets(hosts);
|
|
38
|
+
const bundles = [];
|
|
39
|
+
const errors = [];
|
|
40
|
+
for (const t of targets) {
|
|
41
|
+
const cmd = remoteAgentsCommand(['sessions', 'export', ...exportArgs, '--stdout'], t.os);
|
|
42
|
+
process.stderr.write(chalk.dim(`Pulling sessions from ${t.name}…\n`));
|
|
43
|
+
const res = sshExec(t.target, cmd, { timeoutMs: REMOTE_EXPORT_TIMEOUT_MS });
|
|
44
|
+
if (res.timedOut) {
|
|
45
|
+
errors.push(`${t.name}: timed out after ${Math.round(REMOTE_EXPORT_TIMEOUT_MS / 1000)}s`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (res.code !== 0) {
|
|
49
|
+
const tail = res.stderr.trim().split('\n').filter(Boolean).pop();
|
|
50
|
+
errors.push(`${t.name}: remote export failed (${res.code ?? 'ssh error'})${tail ? ': ' + tail : ''}`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
bundles.push(parseBundle(res.stdout));
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
errors.push(`${t.name}: ${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { bundles, errors };
|
|
61
|
+
}
|
|
@@ -12,23 +12,54 @@
|
|
|
12
12
|
* also exists locally always wins — the mirror only ever fills in sessions
|
|
13
13
|
* originated on other machines.
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
/** One constituent file of a session — a session has exactly one for file-shaped
|
|
16
|
+
* agents (Claude, Codex, …), and many for directory-shaped ones (Kimi). */
|
|
17
|
+
export interface SessionFile {
|
|
16
18
|
/** Absolute path on this machine. */
|
|
17
19
|
absPath: string;
|
|
18
|
-
/** Globally-unique session id (the grouping key across machines). */
|
|
19
|
-
sessionId: string;
|
|
20
20
|
/** Path relative to the agent's subdir root — preserved in the mirror layout. */
|
|
21
21
|
relKey: string;
|
|
22
22
|
}
|
|
23
|
+
export interface LocalTranscript {
|
|
24
|
+
/** Globally-unique session id (the grouping key across machines). */
|
|
25
|
+
sessionId: string;
|
|
26
|
+
/** Every file that makes up this session. Length 1 for file-shaped agents. */
|
|
27
|
+
files: SessionFile[];
|
|
28
|
+
}
|
|
23
29
|
export interface SyncAgentSpec {
|
|
24
30
|
id: string;
|
|
25
31
|
/** Config subdir under the agent home that holds transcripts. */
|
|
26
32
|
subdir: string;
|
|
27
33
|
/** File extension to walk for this agent (defaults to .jsonl). */
|
|
28
34
|
ext?: string;
|
|
35
|
+
/**
|
|
36
|
+
* A session is a DIRECTORY of files (e.g. Kimi: state.json + agents/…/wire.jsonl
|
|
37
|
+
* + per-tool task sidecars), not a single transcript. When set, every file under
|
|
38
|
+
* the session dir (matching `exts`, passing `fileFilter`) syncs, is stored under
|
|
39
|
+
* its own R2 sub-key, and is mirrored at its own relative path — instead of the
|
|
40
|
+
* file-shaped "one transcript per session" model.
|
|
41
|
+
*/
|
|
42
|
+
dirShaped?: boolean;
|
|
43
|
+
/** Extensions a dir-shaped agent walks (defaults to `[ext ?? '.jsonl']`). */
|
|
44
|
+
exts?: string[];
|
|
45
|
+
/** Optional per-file exclusion for dir-shaped agents (lock/scratch files, …). */
|
|
46
|
+
fileFilter?(relKey: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Extensions whose files are append-only event logs and therefore CRDT-mergeable
|
|
49
|
+
* (G-Set union across forked copies). A dir-shaped session usually mixes an
|
|
50
|
+
* append-only conversation log (`wire.jsonl`) with mutable metadata blobs
|
|
51
|
+
* (`state.json`) — line-unioning the latter would corrupt it, so any file NOT
|
|
52
|
+
* matching an entry here is reconciled last-writer-wins instead. Undefined (the
|
|
53
|
+
* file-shaped default) means every file is mergeable — the single `.jsonl`
|
|
54
|
+
* transcript keeps its existing union behaviour.
|
|
55
|
+
*/
|
|
56
|
+
mergeableExts?: string[];
|
|
29
57
|
/** Derive the session id from a storage-relative key. */
|
|
30
58
|
sessionIdFromRelKey(relKey: string): string;
|
|
31
59
|
}
|
|
60
|
+
/** True when a file at `relKey` is an append-only log that CRDT-unions across
|
|
61
|
+
* forks; false when it must be reconciled last-writer-wins (mutable blob). */
|
|
62
|
+
export declare function isMergeableFile(spec: SyncAgentSpec, relKey: string): boolean;
|
|
32
63
|
export declare const SYNC_AGENTS: SyncAgentSpec[];
|
|
33
64
|
/**
|
|
34
65
|
* List this machine's own transcript files for an agent, EXCLUDING the sync
|
|
@@ -38,10 +69,27 @@ export declare const SYNC_AGENTS: SyncAgentSpec[];
|
|
|
38
69
|
export declare function listLocalTranscripts(spec: SyncAgentSpec): LocalTranscript[];
|
|
39
70
|
/** Session ids this machine holds locally (live home), used to skip mirror writes. */
|
|
40
71
|
export declare function localSessionIds(spec: SyncAgentSpec): Set<string>;
|
|
41
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Absolute mirror path for a remote machine's transcript — lands in a scan root.
|
|
74
|
+
*
|
|
75
|
+
* `machine` and `relKey` come from a peer's manifest (untrusted: any peer with
|
|
76
|
+
* write access to the shared bucket controls them). Unlike the push side, which
|
|
77
|
+
* already drops `relKey` starting with `..` when building the manifest, the pull
|
|
78
|
+
* side would otherwise `fs.writeFileSync` at this path with peer-controlled
|
|
79
|
+
* content. `machine` is constrained to a single segment and `relKey` (which may
|
|
80
|
+
* legitimately nest, e.g. `projects/x/y.jsonl`) is contained beneath the
|
|
81
|
+
* per-machine mirror root, so a crafted `relKey` like `../../../.ssh/authorized_keys`
|
|
82
|
+
* cannot write outside `~/.agents/.history/backups/<agent>/<machine>/<subdir>`.
|
|
83
|
+
*/
|
|
42
84
|
export declare function mirrorPath(spec: SyncAgentSpec, machine: string, relKey: string): string;
|
|
43
|
-
/**
|
|
44
|
-
|
|
85
|
+
/**
|
|
86
|
+
* R2 object key for a transcript.
|
|
87
|
+
* - file-shaped (relKey omitted): sessions/<machine>/<agent>/<sessionId>.jsonl —
|
|
88
|
+
* unchanged, so existing claude/codex/droid objects keep their keys.
|
|
89
|
+
* - dir-shaped (relKey given): sessions/<machine>/<agent>/<sessionId>/<relKey> —
|
|
90
|
+
* one object per constituent file of the session directory.
|
|
91
|
+
*/
|
|
92
|
+
export declare function objectKey(machine: string, agentId: string, sessionId: string, relKey?: string): string;
|
|
45
93
|
/** R2 object key for a machine's manifest. */
|
|
46
94
|
export declare function manifestKey(machine: string): string;
|
|
47
95
|
/** Prefix under which all machine manifests live (for discovery). */
|
|
Binary file
|
|
@@ -18,12 +18,23 @@ export interface ManifestEntry {
|
|
|
18
18
|
/** Latest event timestamp in the transcript. */
|
|
19
19
|
lastTs: string;
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* sessionId -> the session's file entries.
|
|
23
|
+
* - file-shaped agents (claude/codex/droid) store a single `ManifestEntry`,
|
|
24
|
+
* byte-identical to the pre-multi-file format, so a machine on an older CLI
|
|
25
|
+
* reads them unchanged.
|
|
26
|
+
* - dir-shaped agents (kimi) store a `ManifestEntry[]`, one per constituent file.
|
|
27
|
+
* A machine that predates multi-file support skips these (an unknown agent is
|
|
28
|
+
* skipped whole; a kimi-aware-but-file-shaped reader fails to find the flat
|
|
29
|
+
* object key and simply retries — it never crashes or corrupts).
|
|
30
|
+
*/
|
|
31
|
+
export type AgentManifest = Record<string, ManifestEntry | ManifestEntry[]>;
|
|
32
|
+
/** Normalize a manifest value to its file-entry list (single entry -> length-1). */
|
|
33
|
+
export declare function manifestEntries(value: ManifestEntry | ManifestEntry[]): ManifestEntry[];
|
|
23
34
|
export interface Manifest {
|
|
24
35
|
machine: string;
|
|
25
36
|
updatedAt: string;
|
|
26
|
-
/** agentId -> (sessionId -> entry) */
|
|
37
|
+
/** agentId -> (sessionId -> entry | entry[]) */
|
|
27
38
|
agents: Record<string, AgentManifest>;
|
|
28
39
|
}
|
|
29
40
|
export declare function emptyManifest(machine: string, updatedAt: string): Manifest;
|
|
@@ -13,6 +13,10 @@ import * as fs from 'fs';
|
|
|
13
13
|
import * as path from 'path';
|
|
14
14
|
import * as crypto from 'crypto';
|
|
15
15
|
import { getCacheDir } from '../../state.js';
|
|
16
|
+
/** Normalize a manifest value to its file-entry list (single entry -> length-1). */
|
|
17
|
+
export function manifestEntries(value) {
|
|
18
|
+
return Array.isArray(value) ? value : [value];
|
|
19
|
+
}
|
|
16
20
|
export function emptyManifest(machine, updatedAt) {
|
|
17
21
|
return { machine, updatedAt, agents: {} };
|
|
18
22
|
}
|
|
@@ -55,10 +55,31 @@ export interface PendingSession {
|
|
|
55
55
|
*/
|
|
56
56
|
export declare function selectSessionsToFetch(copies: Map<string, Map<string, RemoteCopy[]>>, localIdsByAgent: Map<string, Set<string>>, pullState: PullState): PendingSession[];
|
|
57
57
|
/**
|
|
58
|
-
* Resolve the mirror destination +
|
|
58
|
+
* Resolve the mirror destination + reconciled content for ONE file across its
|
|
59
|
+
* copies (every copy here is the same file — same relKey — held by a different
|
|
60
|
+
* machine). Pure.
|
|
61
|
+
*
|
|
59
62
|
* The canonical path comes from the lexicographically-smallest machine so every
|
|
60
|
-
* puller derives an identical location
|
|
63
|
+
* puller derives an identical location. The content depends on the file's kind
|
|
64
|
+
* (see `isMergeableFile`):
|
|
65
|
+
* - append-only logs (a transcript `.jsonl`) take the CRDT G-Set union — every
|
|
66
|
+
* machine converges to byte-identical output regardless of order.
|
|
67
|
+
* - mutable blobs (Kimi `state.json`) can't be line-unioned without corruption,
|
|
68
|
+
* so they resolve **last-writer-wins**: the copy with the latest event
|
|
69
|
+
* timestamp, tie-broken by content hash so the pick is deterministic fleet-wide.
|
|
70
|
+
*/
|
|
71
|
+
/**
|
|
72
|
+
* The `lastTs` a manifest entry carries for one file. Append-only logs (a
|
|
73
|
+
* conversation `.jsonl`) embed per-line event timestamps, so their recency is
|
|
74
|
+
* the latest line timestamp (`transcriptStats`). Mutable blobs (Kimi
|
|
75
|
+
* `state.json`, the per-tool `tasks/*.json` sidecars) carry no event timestamp —
|
|
76
|
+
* their own `updatedAt`/`createdAt` fields are agent-specific and unreliable — so
|
|
77
|
+
* their "last written" signal is the file mtime. Without this, `transcriptStats`
|
|
78
|
+
* returns `''` for every blob and the last-writer-wins branch in
|
|
79
|
+
* `resolveMirrorWrite` silently degrades to "highest-hash-wins", which can pick a
|
|
80
|
+
* stale copy over the genuinely newer one.
|
|
61
81
|
*/
|
|
82
|
+
export declare function deriveLastTs(spec: SyncAgentSpec, relKey: string, content: string, mtimeMs: number): string;
|
|
62
83
|
export declare function resolveMirrorWrite(spec: SyncAgentSpec, copies: RemoteCopy[], contents: string[]): {
|
|
63
84
|
dest: string;
|
|
64
85
|
content: string;
|