@phnx-labs/agents-cli 1.20.62 → 1.20.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -0,0 +1,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,21 +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
- export interface LocalTranscript {
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;
33
+ /** File extension to walk for this agent (defaults to .jsonl). */
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[];
27
57
  /** Derive the session id from a storage-relative key. */
28
58
  sessionIdFromRelKey(relKey: string): string;
29
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;
30
63
  export declare const SYNC_AGENTS: SyncAgentSpec[];
31
64
  /**
32
65
  * List this machine's own transcript files for an agent, EXCLUDING the sync
@@ -36,10 +69,27 @@ export declare const SYNC_AGENTS: SyncAgentSpec[];
36
69
  export declare function listLocalTranscripts(spec: SyncAgentSpec): LocalTranscript[];
37
70
  /** Session ids this machine holds locally (live home), used to skip mirror writes. */
38
71
  export declare function localSessionIds(spec: SyncAgentSpec): Set<string>;
39
- /** Absolute mirror path for a remote machine's transcript — lands in a scan root. */
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
+ */
40
84
  export declare function mirrorPath(spec: SyncAgentSpec, machine: string, relKey: string): string;
41
- /** R2 object key for a transcript: sessions/<machine>/<agent>/<sessionId>.jsonl */
42
- export declare function objectKey(machine: string, agentId: string, sessionId: string): string;
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;
43
93
  /** R2 object key for a machine's manifest. */
44
94
  export declare function manifestKey(machine: string): string;
45
95
  /** Prefix under which all machine manifests live (for discovery). */
Binary file
@@ -12,6 +12,14 @@ export interface R2Config {
12
12
  secretAccessKey: string;
13
13
  /** S3-compatible endpoint for the account (no bucket, no trailing slash). */
14
14
  endpoint: string;
15
+ /**
16
+ * Shared 32-byte key (hex or base64) for client-side transcript encryption,
17
+ * held in the bundle as `R2_SYNC_ENC_KEY`. Optional and deliberately separate
18
+ * from the R2 credentials so rotating the access token never orphans already
19
+ * encrypted objects. When absent, transcripts upload unencrypted (with a loud
20
+ * per-cycle warning) — see transcript-crypto.ts + pushOwn.
21
+ */
22
+ syncEncKey?: string;
15
23
  }
16
24
  /** Window after a prompt-bearing resolution failure during which we skip
17
25
  * re-attempting (and thus re-prompting). SIGHUP / restart bypasses it. */
@@ -17,6 +17,7 @@ function resolveR2Config() {
17
17
  const bucket = env.R2_BUCKET_NAME?.trim();
18
18
  const accessKeyId = env.R2_ACCESS_KEY_ID?.trim();
19
19
  const secretAccessKey = env.R2_SECRET_ACCESS_KEY?.trim();
20
+ const syncEncKey = env.R2_SYNC_ENC_KEY?.trim() || undefined;
20
21
  const missing = [
21
22
  !accountId && 'R2_ACCOUNT_ID',
22
23
  !bucket && 'R2_BUCKET_NAME',
@@ -32,7 +33,11 @@ function resolveR2Config() {
32
33
  bucket: bucket,
33
34
  accessKeyId: accessKeyId,
34
35
  secretAccessKey: secretAccessKey,
35
- endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
36
+ // Default to the account's R2 endpoint; an explicit R2_ENDPOINT override
37
+ // points sync at any S3-compatible store (MinIO, another provider) — which
38
+ // is also how the feature is verified end-to-end without live R2.
39
+ endpoint: env.R2_ENDPOINT?.trim() || `https://${accountId}.r2.cloudflarestorage.com`,
40
+ syncEncKey,
36
41
  };
37
42
  }
38
43
  // ── Resolution cache ────────────────────────────────────────────────────────
@@ -18,12 +18,23 @@ export interface ManifestEntry {
18
18
  /** Latest event timestamp in the transcript. */
19
19
  lastTs: string;
20
20
  }
21
- /** sessionId -> entry */
22
- export type AgentManifest = Record<string, ManifestEntry>;
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
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Provisioning for cross-machine session sync: write the `r2.backups` secrets
3
+ * bundle and probe R2 connectivity. Pure logic — no prompts, no Commander — so
4
+ * it is unit-testable against the in-memory keychain seam. The interactive flow
5
+ * that collects the values lives in the command layer
6
+ * (`commands/sync-provision.ts`).
7
+ */
8
+ /** Values a caller has already collected for the sync bundle. */
9
+ export interface ProvisionInput {
10
+ accountId: string;
11
+ bucketName: string;
12
+ accessKeyId: string;
13
+ secretAccessKey: string;
14
+ /** Optional S3 endpoint override (MinIO / non-R2). Default derived from accountId. */
15
+ endpoint?: string;
16
+ /**
17
+ * Shared transcript-encryption key for a machine JOINING an existing fabric —
18
+ * paste the key the first machine generated. Omit on the first machine to mint
19
+ * a fresh one. Ignored if the bundle already carries a key (never overwritten).
20
+ */
21
+ encKey?: string;
22
+ }
23
+ export type EncKeyAction = 'generated' | 'reused' | 'provided';
24
+ /**
25
+ * Create or update the `r2.backups` bundle from already-collected values.
26
+ *
27
+ * The four R2 credentials are always (over)written. The encryption key is
28
+ * handled carefully: an existing key is REUSED, never overwritten (overwriting
29
+ * would orphan every transcript peers already encrypted under it); otherwise a
30
+ * caller-supplied `encKey` is stored, or a fresh one minted on the first machine.
31
+ */
32
+ export declare function writeSyncBundle(input: ProvisionInput): {
33
+ encKeyAction: EncKeyAction;
34
+ };
35
+ /** The shared encryption key currently stored (for display when handing it to peers). */
36
+ export declare function readStoredEncKey(): string | null;
37
+ /**
38
+ * Prove the configured bundle can read AND write its bucket: put a throwaway
39
+ * object, read it back, delete it. Returns a structured result instead of
40
+ * throwing so the caller (setup) never crashes on a bad credential. The probe
41
+ * key lives OUTSIDE the `sessions/` prefix so it can never be mistaken for a
42
+ * machine manifest by the pull path.
43
+ */
44
+ export declare function probeR2Connectivity(): Promise<{
45
+ ok: true;
46
+ } | {
47
+ ok: false;
48
+ error: string;
49
+ }>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Provisioning for cross-machine session sync: write the `r2.backups` secrets
3
+ * bundle and probe R2 connectivity. Pure logic — no prompts, no Commander — so
4
+ * it is unit-testable against the in-memory keychain seam. The interactive flow
5
+ * that collects the values lives in the command layer
6
+ * (`commands/sync-provision.ts`).
7
+ */
8
+ import { bundleExists, readBundle, writeBundle, bundleItemStore, keychainRef, } from '../../secrets/bundles.js';
9
+ import { secretsKeychainItem } from '../../secrets/index.js';
10
+ import { SYNC_BUNDLE, loadR2Config, clearR2ConfigCache } from './config.js';
11
+ import { R2Client } from './r2.js';
12
+ import { generateSyncEncKey, resolveSyncEncKey } from './transcript-crypto.js';
13
+ import { machineId } from '../../machine-id.js';
14
+ /**
15
+ * Create or update the `r2.backups` bundle from already-collected values.
16
+ *
17
+ * The four R2 credentials are always (over)written. The encryption key is
18
+ * handled carefully: an existing key is REUSED, never overwritten (overwriting
19
+ * would orphan every transcript peers already encrypted under it); otherwise a
20
+ * caller-supplied `encKey` is stored, or a fresh one minted on the first machine.
21
+ */
22
+ export function writeSyncBundle(input) {
23
+ // Validate a joining machine's pasted key up front so a bad paste fails here,
24
+ // not silently at the next sync.
25
+ if (input.encKey)
26
+ resolveSyncEncKey({ syncEncKey: input.encKey });
27
+ const bundle = bundleExists(SYNC_BUNDLE)
28
+ ? readBundle(SYNC_BUNDLE)
29
+ : { name: SYNC_BUNDLE, description: 'Cross-machine session-sync R2 credentials', vars: {} };
30
+ const store = bundleItemStore(bundle.backend);
31
+ const setKey = (key, value) => {
32
+ store.set(secretsKeychainItem(SYNC_BUNDLE, key), value);
33
+ bundle.vars[key] = keychainRef(key);
34
+ };
35
+ setKey('R2_ACCOUNT_ID', input.accountId);
36
+ setKey('R2_BUCKET_NAME', input.bucketName);
37
+ setKey('R2_ACCESS_KEY_ID', input.accessKeyId);
38
+ setKey('R2_SECRET_ACCESS_KEY', input.secretAccessKey);
39
+ if (input.endpoint)
40
+ setKey('R2_ENDPOINT', input.endpoint);
41
+ let encKeyAction;
42
+ if (bundle.vars['R2_SYNC_ENC_KEY']) {
43
+ encKeyAction = 'reused';
44
+ }
45
+ else if (input.encKey) {
46
+ setKey('R2_SYNC_ENC_KEY', input.encKey);
47
+ encKeyAction = 'provided';
48
+ }
49
+ else {
50
+ setKey('R2_SYNC_ENC_KEY', generateSyncEncKey());
51
+ encKeyAction = 'generated';
52
+ }
53
+ writeBundle(bundle);
54
+ clearR2ConfigCache(); // so the next loadR2Config() sees the just-written values
55
+ return { encKeyAction };
56
+ }
57
+ /** The shared encryption key currently stored (for display when handing it to peers). */
58
+ export function readStoredEncKey() {
59
+ clearR2ConfigCache();
60
+ try {
61
+ return loadR2Config().syncEncKey ?? null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Prove the configured bundle can read AND write its bucket: put a throwaway
69
+ * object, read it back, delete it. Returns a structured result instead of
70
+ * throwing so the caller (setup) never crashes on a bad credential. The probe
71
+ * key lives OUTSIDE the `sessions/` prefix so it can never be mistaken for a
72
+ * machine manifest by the pull path.
73
+ */
74
+ export async function probeR2Connectivity() {
75
+ try {
76
+ clearR2ConfigCache();
77
+ const cfg = loadR2Config();
78
+ const r2 = new R2Client(cfg);
79
+ const key = `.agents-provision-probe/${machineId()}.txt`;
80
+ const token = `agents-sync-probe-${machineId()}`;
81
+ await r2.put(key, token, 'text/plain');
82
+ const got = await r2.get(key);
83
+ await r2.delete(key);
84
+ if (got !== token)
85
+ return { ok: false, error: 'wrote a probe object but read back different bytes' };
86
+ return { ok: true };
87
+ }
88
+ catch (err) {
89
+ return { ok: false, error: err.message };
90
+ }
91
+ }
@@ -24,6 +24,9 @@ export interface SyncResult {
24
24
  merged: number;
25
25
  pullSkipped: number;
26
26
  errors: string[];
27
+ /** Non-fatal advisories (e.g. transcripts uploaded unencrypted). Unlike
28
+ * `errors` these do not set a failing exit code. */
29
+ warnings: string[];
27
30
  }
28
31
  export interface SyncOptions {
29
32
  verbose?: boolean;
@@ -52,10 +55,31 @@ export interface PendingSession {
52
55
  */
53
56
  export declare function selectSessionsToFetch(copies: Map<string, Map<string, RemoteCopy[]>>, localIdsByAgent: Map<string, Set<string>>, pullState: PullState): PendingSession[];
54
57
  /**
55
- * Resolve the mirror destination + merged content for one session. Pure.
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
+ *
56
62
  * The canonical path comes from the lexicographically-smallest machine so every
57
- * puller derives an identical location; the content is the CRDT union of copies.
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.
58
81
  */
82
+ export declare function deriveLastTs(spec: SyncAgentSpec, relKey: string, content: string, mtimeMs: number): string;
59
83
  export declare function resolveMirrorWrite(spec: SyncAgentSpec, copies: RemoteCopy[], contents: string[]): {
60
84
  dest: string;
61
85
  content: string;