@phnx-labs/agents-cli 1.20.63 → 1.20.65

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 (197) hide show
  1. package/CHANGELOG.md +56 -3
  2. package/README.md +46 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.js +149 -77
  10. package/dist/commands/feed.d.ts +4 -0
  11. package/dist/commands/feed.js +52 -19
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/lease.d.ts +23 -0
  14. package/dist/commands/lease.js +201 -0
  15. package/dist/commands/mailboxes.d.ts +20 -0
  16. package/dist/commands/mailboxes.js +390 -0
  17. package/dist/commands/mcp.js +55 -5
  18. package/dist/commands/monitors.d.ts +12 -0
  19. package/dist/commands/monitors.js +740 -0
  20. package/dist/commands/output.js +2 -2
  21. package/dist/commands/routines.js +43 -16
  22. package/dist/commands/secrets.d.ts +16 -0
  23. package/dist/commands/secrets.js +215 -64
  24. package/dist/commands/serve.js +31 -0
  25. package/dist/commands/sessions-export.d.ts +2 -0
  26. package/dist/commands/sessions-export.js +284 -0
  27. package/dist/commands/sessions-import.d.ts +2 -0
  28. package/dist/commands/sessions-import.js +230 -0
  29. package/dist/commands/sessions.d.ts +16 -0
  30. package/dist/commands/sessions.js +40 -6
  31. package/dist/commands/ssh.js +143 -5
  32. package/dist/commands/usage.d.ts +2 -0
  33. package/dist/commands/usage.js +7 -2
  34. package/dist/commands/versions.js +7 -3
  35. package/dist/commands/view.d.ts +27 -1
  36. package/dist/commands/view.js +32 -9
  37. package/dist/commands/webhook.js +10 -2
  38. package/dist/index.js +35 -14
  39. package/dist/lib/agents.d.ts +36 -0
  40. package/dist/lib/agents.js +80 -20
  41. package/dist/lib/auto-dispatch-provider.js +7 -2
  42. package/dist/lib/auto-dispatch.d.ts +3 -0
  43. package/dist/lib/auto-dispatch.js +3 -0
  44. package/dist/lib/browser/chrome.js +2 -2
  45. package/dist/lib/browser/drivers/ssh.js +19 -2
  46. package/dist/lib/cloud/antigravity.js +2 -2
  47. package/dist/lib/cloud/host.d.ts +59 -0
  48. package/dist/lib/cloud/host.js +224 -0
  49. package/dist/lib/cloud/registry.js +4 -0
  50. package/dist/lib/cloud/types.d.ts +6 -4
  51. package/dist/lib/comms-render.d.ts +37 -0
  52. package/dist/lib/comms-render.js +89 -0
  53. package/dist/lib/computer-rpc.js +3 -1
  54. package/dist/lib/crabbox/cli.d.ts +72 -0
  55. package/dist/lib/crabbox/cli.js +162 -9
  56. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  57. package/dist/lib/crabbox/runtimes.js +35 -2
  58. package/dist/lib/daemon.d.ts +20 -4
  59. package/dist/lib/daemon.js +68 -20
  60. package/dist/lib/devices/fleet.d.ts +3 -2
  61. package/dist/lib/devices/fleet.js +9 -0
  62. package/dist/lib/devices/health.d.ts +77 -0
  63. package/dist/lib/devices/health.js +186 -0
  64. package/dist/lib/devices/registry.d.ts +15 -0
  65. package/dist/lib/devices/registry.js +9 -0
  66. package/dist/lib/exec.d.ts +19 -2
  67. package/dist/lib/exec.js +41 -13
  68. package/dist/lib/fleet/apply.d.ts +63 -0
  69. package/dist/lib/fleet/apply.js +214 -0
  70. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  71. package/dist/lib/fleet/auth-sync.js +142 -0
  72. package/dist/lib/fleet/manifest.d.ts +29 -0
  73. package/dist/lib/fleet/manifest.js +127 -0
  74. package/dist/lib/fleet/types.d.ts +129 -0
  75. package/dist/lib/fleet/types.js +13 -0
  76. package/dist/lib/git.d.ts +27 -0
  77. package/dist/lib/git.js +34 -2
  78. package/dist/lib/hosts/dispatch.d.ts +29 -8
  79. package/dist/lib/hosts/dispatch.js +46 -18
  80. package/dist/lib/hosts/passthrough.js +2 -0
  81. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  82. package/dist/lib/hosts/providers/devices.js +98 -0
  83. package/dist/lib/hosts/registry.d.ts +10 -16
  84. package/dist/lib/hosts/registry.js +17 -50
  85. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  86. package/dist/lib/hosts/remote-cmd.js +71 -0
  87. package/dist/lib/hosts/run-target.d.ts +84 -0
  88. package/dist/lib/hosts/run-target.js +99 -0
  89. package/dist/lib/hosts/types.d.ts +23 -5
  90. package/dist/lib/hosts/types.js +22 -4
  91. package/dist/lib/linear-autoclose.d.ts +30 -0
  92. package/dist/lib/linear-autoclose.js +22 -0
  93. package/dist/lib/mailbox.d.ts +39 -0
  94. package/dist/lib/mailbox.js +112 -0
  95. package/dist/lib/mcp.d.ts +27 -1
  96. package/dist/lib/mcp.js +126 -12
  97. package/dist/lib/monitors/config.d.ts +161 -0
  98. package/dist/lib/monitors/config.js +372 -0
  99. package/dist/lib/monitors/dispatch.d.ts +28 -0
  100. package/dist/lib/monitors/dispatch.js +91 -0
  101. package/dist/lib/monitors/engine.d.ts +61 -0
  102. package/dist/lib/monitors/engine.js +201 -0
  103. package/dist/lib/monitors/sources/command.d.ts +11 -0
  104. package/dist/lib/monitors/sources/command.js +31 -0
  105. package/dist/lib/monitors/sources/device.d.ts +13 -0
  106. package/dist/lib/monitors/sources/device.js +35 -0
  107. package/dist/lib/monitors/sources/file.d.ts +14 -0
  108. package/dist/lib/monitors/sources/file.js +57 -0
  109. package/dist/lib/monitors/sources/http.d.ts +10 -0
  110. package/dist/lib/monitors/sources/http.js +34 -0
  111. package/dist/lib/monitors/sources/index.d.ts +14 -0
  112. package/dist/lib/monitors/sources/index.js +31 -0
  113. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  114. package/dist/lib/monitors/sources/poll.js +9 -0
  115. package/dist/lib/monitors/sources/types.d.ts +18 -0
  116. package/dist/lib/monitors/sources/types.js +9 -0
  117. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  118. package/dist/lib/monitors/sources/webhook.js +47 -0
  119. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  120. package/dist/lib/monitors/sources/ws.js +45 -0
  121. package/dist/lib/monitors/state.d.ts +69 -0
  122. package/dist/lib/monitors/state.js +144 -0
  123. package/dist/lib/paths.d.ts +13 -0
  124. package/dist/lib/paths.js +26 -4
  125. package/dist/lib/platform/exec.d.ts +16 -0
  126. package/dist/lib/platform/exec.js +17 -0
  127. package/dist/lib/plugins.js +101 -2
  128. package/dist/lib/redact.d.ts +14 -1
  129. package/dist/lib/redact.js +47 -1
  130. package/dist/lib/remote-agents-json.js +7 -1
  131. package/dist/lib/rotate.d.ts +6 -3
  132. package/dist/lib/rotate.js +0 -1
  133. package/dist/lib/routines.d.ts +37 -2
  134. package/dist/lib/routines.js +54 -12
  135. package/dist/lib/runner.d.ts +1 -0
  136. package/dist/lib/runner.js +348 -13
  137. package/dist/lib/sandbox.d.ts +9 -1
  138. package/dist/lib/sandbox.js +11 -2
  139. package/dist/lib/secrets/agent.d.ts +48 -10
  140. package/dist/lib/secrets/agent.js +123 -15
  141. package/dist/lib/secrets/bundles.d.ts +26 -0
  142. package/dist/lib/secrets/bundles.js +59 -8
  143. package/dist/lib/secrets/mcp.js +4 -2
  144. package/dist/lib/secrets/remote.d.ts +17 -0
  145. package/dist/lib/secrets/remote.js +40 -0
  146. package/dist/lib/self-update.d.ts +20 -0
  147. package/dist/lib/self-update.js +54 -1
  148. package/dist/lib/serve/control.d.ts +95 -0
  149. package/dist/lib/serve/control.js +260 -0
  150. package/dist/lib/serve/server.d.ts +35 -1
  151. package/dist/lib/serve/server.js +106 -76
  152. package/dist/lib/serve/stream.d.ts +43 -0
  153. package/dist/lib/serve/stream.js +116 -0
  154. package/dist/lib/serve/token.d.ts +35 -0
  155. package/dist/lib/serve/token.js +85 -0
  156. package/dist/lib/session/bundle.d.ts +164 -0
  157. package/dist/lib/session/bundle.js +200 -0
  158. package/dist/lib/session/remote-bundle.d.ts +12 -0
  159. package/dist/lib/session/remote-bundle.js +61 -0
  160. package/dist/lib/session/remote-list.js +5 -1
  161. package/dist/lib/session/state.d.ts +7 -25
  162. package/dist/lib/session/state.js +16 -6
  163. package/dist/lib/session/sync/agents.d.ts +54 -6
  164. package/dist/lib/session/sync/agents.js +0 -0
  165. package/dist/lib/session/sync/config.js +8 -2
  166. package/dist/lib/session/sync/manifest.d.ts +14 -3
  167. package/dist/lib/session/sync/manifest.js +4 -0
  168. package/dist/lib/session/sync/sync.d.ts +23 -2
  169. package/dist/lib/session/sync/sync.js +177 -74
  170. package/dist/lib/session/types.d.ts +30 -0
  171. package/dist/lib/ssh-tunnel.d.ts +19 -1
  172. package/dist/lib/ssh-tunnel.js +99 -8
  173. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  174. package/dist/lib/staleness/detectors/subagents.js +5 -192
  175. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  176. package/dist/lib/staleness/writers/subagents.js +11 -102
  177. package/dist/lib/startup/command-registry.d.ts +4 -0
  178. package/dist/lib/startup/command-registry.js +9 -0
  179. package/dist/lib/state.d.ts +5 -0
  180. package/dist/lib/state.js +12 -0
  181. package/dist/lib/subagents-registry.d.ts +85 -0
  182. package/dist/lib/subagents-registry.js +393 -0
  183. package/dist/lib/subagents.d.ts +8 -8
  184. package/dist/lib/subagents.js +32 -663
  185. package/dist/lib/sync-umbrella.d.ts +1 -0
  186. package/dist/lib/sync-umbrella.js +14 -3
  187. package/dist/lib/tmux/session.d.ts +7 -0
  188. package/dist/lib/tmux/session.js +3 -1
  189. package/dist/lib/triggers/webhook.d.ts +18 -0
  190. package/dist/lib/triggers/webhook.js +105 -0
  191. package/dist/lib/types.d.ts +35 -1
  192. package/dist/lib/usage.d.ts +42 -3
  193. package/dist/lib/usage.js +163 -21
  194. package/dist/lib/versions.js +14 -11
  195. package/dist/lib/workflows.d.ts +20 -0
  196. package/dist/lib/workflows.js +24 -0
  197. package/package.json +2 -1
@@ -1,15 +1,46 @@
1
1
  import chalk from 'chalk';
2
2
  import { startServeServer, DEFAULT_SERVE_PORT, SERVE_HOST } from '../lib/serve/server.js';
3
+ import { startControlServer } from '../lib/serve/control.js';
4
+ import { ensureControlToken } from '../lib/serve/token.js';
3
5
  export function registerServeCommand(program) {
4
6
  program
5
7
  .command('serve')
6
8
  .description('Read-only local web companion: team diffs, routines, and cloud status (binds 127.0.0.1 only).')
7
9
  .option('-p, --port <n>', `Port to bind on ${SERVE_HOST}`, String(DEFAULT_SERVE_PORT))
8
10
  .option('--interval <ms>', 'SSE refresh cadence in milliseconds', '3000')
11
+ .option('--control', 'Authenticated control mode (RUSH-1731): adds bearer-gated POST /api/run and /api/session/:id/message so the iOS cockpit can dispatch + steer. Enables binding beyond loopback — pair a device with `agents devices pair-ios`.', false)
12
+ .option('--bind <addr>', 'Address to bind in --control mode (e.g. your tailnet IP, or 0.0.0.0). Ignored without --control; read-only serve is always loopback.', SERVE_HOST)
9
13
  .action(async (opts) => {
10
14
  const port = parseInt(opts.port ?? '', 10) || DEFAULT_SERVE_PORT;
11
15
  const intervalMs = parseInt(opts.interval ?? '', 10) || 3000;
12
16
  try {
17
+ if (opts.control) {
18
+ const bind = opts.bind || SERVE_HOST;
19
+ const minted = ensureControlToken('default');
20
+ const { server, port: bound } = await startControlServer(port, bind, {
21
+ cwd: process.cwd(),
22
+ intervalMs,
23
+ });
24
+ const url = `http://${bind}:${bound}`;
25
+ console.log(`${chalk.green('agents serve --control')} ${chalk.dim('→')} ${chalk.cyan(url)}`);
26
+ if (minted.created) {
27
+ console.log(chalk.yellow('New control token (shown once — store it in the cockpit now):'));
28
+ console.log(` ${chalk.bold(minted.token)} ${chalk.dim(`(id ${minted.id})`)}`);
29
+ }
30
+ else {
31
+ console.log(chalk.dim('Using existing control token(s). Issue another with `agents devices pair-ios`.'));
32
+ }
33
+ if (bind !== SERVE_HOST) {
34
+ console.log(chalk.dim('bearer-gated · bind beyond loopback — keep this on the tailnet, never public Funnel · Ctrl-C to stop'));
35
+ }
36
+ else {
37
+ console.log(chalk.dim('bearer-gated · loopback (pass --bind <tailnet-ip> to reach it from the phone) · Ctrl-C to stop'));
38
+ }
39
+ const shutdown = () => server.close(() => process.exit(0));
40
+ process.on('SIGINT', shutdown);
41
+ process.on('SIGTERM', shutdown);
42
+ return;
43
+ }
13
44
  const { server, port: bound } = await startServeServer(port, {
14
45
  cwd: process.cwd(),
15
46
  intervalMs,
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
@@ -0,0 +1,284 @@
1
+ /**
2
+ * `agents sessions export` — bundle N selected sessions into a portable,
3
+ * self-describing archive (RUSH-1710).
4
+ *
5
+ * The successor to background R2/CRDT sync for the durable-archive / hand-off
6
+ * case: instead of an always-on merge daemon, the user explicitly bundles the
7
+ * sessions they want to carry to an offline box or keep as an archive. The
8
+ * bundle format + placement live in ../lib/session/bundle.ts; this command owns
9
+ * only the SELECTION (which sessions) and the OUTPUT (file or stdout).
10
+ *
11
+ * Selection flags (`--since`, `-n/--limit`, `--all`, `-a/--agent`,
12
+ * `--no-redact`) are inherited from the parent `sessions` command and read via
13
+ * optsWithGlobals(), so they never shadow the parent's parsing; this command
14
+ * adds only the export-specific flags (`-o/--output`, `--stdout`, `--encrypt`).
15
+ *
16
+ * Rendered markdown/json of a single session is already served by
17
+ * `agents sessions <id> --markdown|--json`; export is specifically the portable,
18
+ * re-importable BUNDLE, so it does not re-expose those render formats.
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import chalk from 'chalk';
23
+ import { discoverSessions, resolveSessionById } from '../lib/session/discover.js';
24
+ import { filterSessionsByQuery, parseAgentFilter } from './sessions.js';
25
+ import { listLocalTranscripts, SYNC_AGENTS } from '../lib/session/sync/agents.js';
26
+ import { machineId } from '../lib/machine-id.js';
27
+ import { getHistoryDir } from '../lib/state.js';
28
+ import { loadR2Config } from '../lib/session/sync/config.js';
29
+ import { resolveSyncEncKey, generateSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
30
+ import { buildRecord, makeHeader, mergeRecords, serializeBundle, writeBundleFile, specForAgent, } from '../lib/session/bundle.js';
31
+ import { knownSecretValuesFromEnv } from '../lib/redact.js';
32
+ import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
33
+ import { setHelpSections } from '../lib/help.js';
34
+ /** Default cap when exporting a scope (not explicit ids) and the user gave no -n. */
35
+ const DEFAULT_LIMIT = 500;
36
+ export function registerSessionsExportCommand(sessionsCmd) {
37
+ const cmd = sessionsCmd
38
+ .command('export [selectors...]')
39
+ .description('Bundle sessions (by id, query, or the parent selection flags like --since/-a) into a portable archive.')
40
+ .option('-o, --output <path>', 'Write the bundle to this file')
41
+ .option('--stdout', 'Write the bundle to stdout (for piping into `sessions import -`)')
42
+ .option('--encrypt', 'Seal each transcript body with AES-256-GCM before writing');
43
+ setHelpSections(cmd, {
44
+ examples: `# Bundle the last week of sessions to a file
45
+ agents sessions export --since 7d -o week.bundle
46
+
47
+ # Bundle two specific sessions
48
+ agents sessions export 4f8a2b1c 9d3e7a55 -o pair.bundle
49
+
50
+ # Encrypt + pipe straight into another machine over SSH
51
+ agents sessions export --since 7d --stdout --encrypt | agents ssh boxB 'agents sessions import - --decrypt <key>'`,
52
+ notes: `Selection uses the same flags as 'agents sessions' (--since, -n/--limit, --all,
53
+ -a/--agent, --no-redact). Bundles are self-describing NDJSON: a header line + one
54
+ line per transcript file. Secrets are redacted by default. Dir-shaped sessions
55
+ (Kimi) carry all their files. Restore with 'agents sessions import'.`,
56
+ });
57
+ cmd.action(async (selectors, _options, command) => {
58
+ await runExport(selectors, command);
59
+ });
60
+ }
61
+ async function runExport(selectors, command) {
62
+ const g = command.optsWithGlobals();
63
+ // --host: export sessions that live on remote peer(s) — run export there and
64
+ // stream the bundle back over the existing SSH transport (RUSH-1712).
65
+ if (g.host && g.host.length > 0) {
66
+ await runRemoteExport(g, selectors, command);
67
+ return;
68
+ }
69
+ const explicitLimit = command.parent?.getOptionValueSource?.('limit') === 'cli';
70
+ const limit = explicitLimit ? Math.max(1, parseInt(String(g.limit), 10) || DEFAULT_LIMIT) : DEFAULT_LIMIT;
71
+ const agentFilter = parseAgentFilter(resolveAgentShorthand(g));
72
+ // 1. Discover candidate sessions in scope.
73
+ const metas = await discoverSessions({
74
+ all: g.all !== false,
75
+ agent: agentFilter.agent ?? undefined,
76
+ since: g.since,
77
+ limit,
78
+ });
79
+ // 2. Narrow to the selection (ids > query > everything-in-scope).
80
+ const selected = selectSessions(metas, selectors);
81
+ if (selected.length === 0) {
82
+ process.stderr.write(chalk.yellow('No sessions matched the selection.\n'));
83
+ process.exit(1);
84
+ }
85
+ if (!selectors.length && selected.length >= limit) {
86
+ process.stderr.write(chalk.yellow(`Note: capped at ${limit} sessions. Raise -n to bundle more.\n`));
87
+ }
88
+ // 3. Resolve each selected session to its on-disk file(s).
89
+ const index = buildLocalIndex();
90
+ const self = machineId();
91
+ const files = [];
92
+ const skippedAgents = new Set();
93
+ for (const meta of selected) {
94
+ const spec = specForAgent(meta.agent);
95
+ if (!spec) {
96
+ skippedAgents.add(meta.agent);
97
+ continue;
98
+ }
99
+ const machine = meta.machine || self;
100
+ const lt = index.get(`${meta.agent}:${meta.id}`);
101
+ if (lt) {
102
+ for (const f of lt.files) {
103
+ files.push({ agent: meta.agent, machine, sessionId: meta.id, relKey: f.relKey, absPath: f.absPath, label: meta.label });
104
+ }
105
+ }
106
+ else if (meta.filePath && fs.existsSync(meta.filePath)) {
107
+ // Not in the live-home index (e.g. a mirror of another machine): fall back
108
+ // to the single discovered file, deriving its subdir-relative key.
109
+ const relKey = relKeyFromPath(meta.filePath, meta.agent, machine, spec.subdir);
110
+ files.push({ agent: meta.agent, machine, sessionId: meta.id, relKey, absPath: meta.filePath, label: meta.label });
111
+ }
112
+ }
113
+ if (skippedAgents.size > 0) {
114
+ process.stderr.write(chalk.yellow(`Skipped agents with no portable format: ${[...skippedAgents].sort().join(', ')}.\n`));
115
+ }
116
+ if (files.length === 0) {
117
+ process.stderr.write(chalk.red('Selected sessions have no exportable transcript files.\n'));
118
+ process.exit(1);
119
+ }
120
+ // 4. Resolve encryption key (opt-in) + redaction (default on via parent --no-redact).
121
+ const encryptKey = g.encrypt ? resolveExportKey() : null;
122
+ const redact = g.redact !== false;
123
+ // Value-aware redaction: mask live credential values already in the
124
+ // environment (e.g. an injected secrets bundle) verbatim, whatever their
125
+ // format. Only meaningful when redacting.
126
+ const knownSecrets = redact ? knownSecretValuesFromEnv() : undefined;
127
+ // 5. Build records + header.
128
+ const records = [];
129
+ for (const f of files) {
130
+ try {
131
+ records.push(buildRecord(f, { redact, encryptKey, knownSecrets }));
132
+ }
133
+ catch (err) {
134
+ process.stderr.write(chalk.yellow(`Skipped ${f.agent}/${f.sessionId} (${f.relKey}): ${err.message}\n`));
135
+ }
136
+ }
137
+ if (records.length === 0) {
138
+ process.stderr.write(chalk.red('Nothing to export after reading files.\n'));
139
+ process.exit(1);
140
+ }
141
+ const header = makeHeader({
142
+ origin: self,
143
+ exportedAt: new Date().toISOString(),
144
+ encrypted: encryptKey !== null,
145
+ redacted: redact,
146
+ records,
147
+ });
148
+ emitBundle(header, records, g);
149
+ }
150
+ /**
151
+ * --host path: run `agents sessions export …` on each peer over SSH, stream the
152
+ * bundles back, merge (dedup by origin machine) and emit one local bundle.
153
+ * Encryption is not combined with a remote pull (each peer would seal under its
154
+ * own key); the SSH transport already encrypts the stream in transit.
155
+ */
156
+ async function runRemoteExport(g, selectors, command) {
157
+ if (g.encrypt) {
158
+ process.stderr.write(chalk.yellow('Note: --encrypt is ignored with --host (the SSH stream is already encrypted). Encrypt a local bundle instead.\n'));
159
+ }
160
+ const { bundles, errors } = await pullBundlesFromHosts(g.host, forwardExportArgs(g, selectors, command));
161
+ for (const e of errors)
162
+ process.stderr.write(chalk.yellow(` ${e}\n`));
163
+ const records = mergeRecords(bundles.map(b => b.records));
164
+ if (records.length === 0) {
165
+ process.stderr.write(chalk.red('No sessions pulled from the given host(s).\n'));
166
+ process.exit(1);
167
+ }
168
+ const header = makeHeader({
169
+ origin: g.host.join(','),
170
+ exportedAt: new Date().toISOString(),
171
+ encrypted: false,
172
+ redacted: g.redact !== false,
173
+ records,
174
+ });
175
+ emitBundle(header, records, g);
176
+ }
177
+ /** Reconstruct the export flags to forward to a peer's own `sessions export`. */
178
+ function forwardExportArgs(g, selectors, command) {
179
+ const args = [...selectors];
180
+ if (g.since)
181
+ args.push('--since', g.since);
182
+ const agent = resolveAgentShorthand(g);
183
+ if (agent)
184
+ args.push('-a', agent);
185
+ if (g.all !== false)
186
+ args.push('--all');
187
+ if (g.redact === false)
188
+ args.push('--no-redact');
189
+ if (command.parent?.getOptionValueSource?.('limit') === 'cli' && g.limit)
190
+ args.push('-n', String(g.limit));
191
+ return args;
192
+ }
193
+ /** Write the assembled bundle to stdout or a file. */
194
+ function emitBundle(header, records, g) {
195
+ const wire = serializeBundle(header, records);
196
+ if (g.stdout) {
197
+ process.stdout.write(wire);
198
+ return;
199
+ }
200
+ const outPath = g.output || defaultBundlePath();
201
+ writeBundleFile(outPath, wire);
202
+ process.stderr.write(chalk.green(`Exported ${header.sessions} session${header.sessions === 1 ? '' : 's'} ` +
203
+ `(${header.count} file${header.count === 1 ? '' : 's'}${header.encrypted ? ', encrypted' : ''}${header.redacted ? ', redacted' : ''}) ` +
204
+ `→ ${outPath}\n`));
205
+ }
206
+ /** Map the parent's agent shorthands (--claude, --codex, …) or -a/--agent to a filter string. */
207
+ function resolveAgentShorthand(g) {
208
+ if (g.agent)
209
+ return g.agent;
210
+ if (g.claude)
211
+ return 'claude';
212
+ if (g.codex)
213
+ return 'codex';
214
+ if (g.kimi)
215
+ return 'kimi';
216
+ if (g.grok)
217
+ return 'grok';
218
+ if (g.opencode)
219
+ return 'opencode';
220
+ if (g.antigravity)
221
+ return 'antigravity';
222
+ return undefined;
223
+ }
224
+ /** ids > query > everything-in-scope. */
225
+ function selectSessions(metas, selectors) {
226
+ if (selectors.length === 0)
227
+ return metas;
228
+ const byId = [];
229
+ const unmatched = [];
230
+ for (const sel of selectors) {
231
+ const hits = resolveSessionById(metas, sel);
232
+ if (hits.length > 0)
233
+ byId.push(...hits);
234
+ else
235
+ unmatched.push(sel);
236
+ }
237
+ if (byId.length > 0 && unmatched.length === 0) {
238
+ const seen = new Set();
239
+ return byId.filter(s => (seen.has(s.id) ? false : (seen.add(s.id), true)));
240
+ }
241
+ // Any selector that isn't an id → treat the whole thing as a text query.
242
+ return filterSessionsByQuery(metas, selectors.join(' '));
243
+ }
244
+ /** Build `${agent}:${sessionId}` → LocalTranscript across every sync agent (live home only). */
245
+ function buildLocalIndex() {
246
+ const index = new Map();
247
+ for (const spec of SYNC_AGENTS) {
248
+ for (const lt of listLocalTranscripts(spec)) {
249
+ index.set(`${spec.id}:${lt.sessionId}`, lt);
250
+ }
251
+ }
252
+ return index;
253
+ }
254
+ /** Derive the subdir-relative key for a mirror file path, else fall back to the basename. */
255
+ function relKeyFromPath(filePath, agent, machine, subdir) {
256
+ const prefix = path.join(getHistoryDir(), 'backups', agent, machine, subdir) + path.sep;
257
+ if (filePath.startsWith(prefix))
258
+ return filePath.slice(prefix.length);
259
+ return path.basename(filePath);
260
+ }
261
+ /**
262
+ * Resolve the AES key for --encrypt: prefer the fleet-shared R2_SYNC_ENC_KEY (so
263
+ * any machine on the sync bundle can decrypt), else mint an ephemeral key and
264
+ * print it once — it is NOT stored in the bundle.
265
+ */
266
+ function resolveExportKey() {
267
+ try {
268
+ const key = resolveSyncEncKey(loadR2Config());
269
+ if (key)
270
+ return key;
271
+ }
272
+ catch {
273
+ // sync bundle not configured — fall through to an ephemeral key
274
+ }
275
+ const b64 = generateSyncEncKey();
276
+ process.stderr.write(chalk.yellow(`Bundle encrypted with a fresh key (not in the bundle). Decrypt with:\n` +
277
+ ` agents sessions import <bundle> --decrypt ${b64}\n`));
278
+ return Buffer.from(b64, 'base64');
279
+ }
280
+ /** Default output file when neither -o nor --stdout is given. */
281
+ function defaultBundlePath() {
282
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
283
+ return path.join(process.cwd(), `agents-sessions-${stamp}.bundle`);
284
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSessionsImportCommand(sessionsCmd: Command): void;
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `agents sessions import <bundle|->` — restore an export bundle (RUSH-1711).
3
+ *
4
+ * The inverse of `sessions export`: read a bundle (file or stdin), validate it,
5
+ * and place each transcript where the cross-machine sync would — a mirror keyed
6
+ * by the session's ORIGIN machine (see bundle.ts / mirrorPath). Placement dedups
7
+ * byte-exact against what is already on disk and never clobbers this machine's
8
+ * own live sessions ("local always wins" falls out of the scanner's
9
+ * live-home-first dedup), so a re-import or an overlapping bundle is safe.
10
+ */
11
+ import * as fs from 'fs';
12
+ import chalk from 'chalk';
13
+ import { loadR2Config } from '../lib/session/sync/config.js';
14
+ import { resolveSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
15
+ import { parseBundle, planImport, writeImport, mergeRecords, makeHeader, } from '../lib/session/bundle.js';
16
+ import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
17
+ import { setHelpSections } from '../lib/help.js';
18
+ export function registerSessionsImportCommand(sessionsCmd) {
19
+ const cmd = sessionsCmd
20
+ .command('import [bundle]')
21
+ .description('Restore an export bundle (file, - for stdin, or --from-host <h>) into the local session store, deduping against what you already have.')
22
+ .option('--dry-run', 'Show what would be placed without writing anything')
23
+ .option('--overwrite', 'Replace local files that differ from the bundle (default: keep local)')
24
+ .option('--decrypt [key]', 'Decrypt an encrypted bundle (key optional if the r2.backups sync key is configured)')
25
+ .option('--from-host <target...>', 'Pull sessions live from remote peer(s) over SSH instead of a file (repeatable)');
26
+ setHelpSections(cmd, {
27
+ examples: `# Preview what a bundle would restore
28
+ agents sessions import week.bundle --dry-run
29
+
30
+ # Restore it
31
+ agents sessions import week.bundle
32
+
33
+ # Pull straight off another machine (one command, over SSH)
34
+ agents sessions import --from-host yosemite-s1 --since 7d
35
+
36
+ # Or the equivalent raw pipe
37
+ agents ssh boxB 'agents sessions export --since 7d --stdout' | agents sessions import -`,
38
+ notes: `Sessions land under the cross-machine mirror keyed by their origin machine, so
39
+ they show up in 'agents sessions' tagged with that machine and never overwrite
40
+ your own local sessions. Byte-exact duplicates are skipped. --from-host reuses
41
+ the same SSH transport as the cross-machine listing (no R2, no daemon).`,
42
+ });
43
+ cmd.action(async (bundlePath, options, command) => {
44
+ const g = command.optsWithGlobals();
45
+ await runImport(bundlePath, { ...options, agent: g.agent }, g, command);
46
+ });
47
+ }
48
+ async function runImport(bundlePath, options, g, command) {
49
+ // 1. Obtain the bundle — from remote peer(s), stdin, or a file.
50
+ let bundle;
51
+ if (options.fromHost && options.fromHost.length > 0) {
52
+ bundle = await pullForImport(options.fromHost, bundlePath, g, command);
53
+ }
54
+ else {
55
+ if (!bundlePath) {
56
+ process.stderr.write(chalk.red('Provide a bundle path, - for stdin, or --from-host <host>.\n'));
57
+ process.exit(1);
58
+ }
59
+ let text;
60
+ try {
61
+ text = bundlePath === '-' ? await readStdin() : fs.readFileSync(bundlePath, 'utf-8');
62
+ }
63
+ catch (err) {
64
+ process.stderr.write(chalk.red(`Cannot read bundle: ${err.message}\n`));
65
+ process.exit(1);
66
+ }
67
+ try {
68
+ bundle = parseBundle(text);
69
+ }
70
+ catch (err) {
71
+ process.stderr.write(chalk.red(`${err.message}\n`));
72
+ process.exit(1);
73
+ }
74
+ }
75
+ // 2. Optional agent filter.
76
+ if (options.agent) {
77
+ bundle = { header: bundle.header, records: bundle.records.filter(r => r.agent === options.agent) };
78
+ if (bundle.records.length === 0) {
79
+ process.stderr.write(chalk.yellow(`No records for agent '${options.agent}' in this bundle.\n`));
80
+ process.exit(1);
81
+ }
82
+ }
83
+ // 3. Resolve the decryption key if the bundle is encrypted.
84
+ const decryptKey = bundle.header.encrypted ? resolveDecryptKey(options.decrypt) : null;
85
+ // 4. Plan.
86
+ let plan;
87
+ try {
88
+ plan = planImport(bundle, { decryptKey });
89
+ }
90
+ catch (err) {
91
+ process.stderr.write(chalk.red(`${err.message}\n`));
92
+ process.exit(1);
93
+ }
94
+ if (options.dryRun) {
95
+ printDryRun(plan, bundle);
96
+ return;
97
+ }
98
+ // 5. Write.
99
+ const res = writeImport(plan, { overwrite: options.overwrite === true, decryptKey });
100
+ const parts = [];
101
+ if (res.placed)
102
+ parts.push(`${res.placed} placed`);
103
+ if (res.overwritten)
104
+ parts.push(`${res.overwritten} overwritten`);
105
+ if (res.skipped)
106
+ parts.push(`${res.skipped} duplicate${res.skipped === 1 ? '' : 's'} skipped`);
107
+ if (res.conflicts)
108
+ parts.push(chalk.yellow(`${res.conflicts} conflict${res.conflicts === 1 ? '' : 's'} kept local (use --overwrite)`));
109
+ if (res.unknown)
110
+ parts.push(chalk.yellow(`${res.unknown} unknown-agent skipped`));
111
+ process.stderr.write(chalk.green(`Imported: ${parts.join(', ') || 'nothing to do'}.\n`));
112
+ }
113
+ /** Drain all of stdin to a string. Works for pipes (non-seekable) and redirects
114
+ * alike — unlike readFileSync(0), which fails on a pipe. */
115
+ async function readStdin() {
116
+ const chunks = [];
117
+ for await (const chunk of process.stdin)
118
+ chunks.push(Buffer.from(chunk));
119
+ return Buffer.concat(chunks).toString('utf-8');
120
+ }
121
+ /**
122
+ * --from-host: run `agents sessions export …` on each peer over SSH and merge
123
+ * the streamed bundles into one for import. The optional positional acts as a
124
+ * remote selector (id/query); the parent selection flags (--since, -a, --all,
125
+ * -n) forward too.
126
+ */
127
+ async function pullForImport(hosts, selector, g, command) {
128
+ const args = [];
129
+ if (selector && selector !== '-')
130
+ args.push(selector);
131
+ if (g.since)
132
+ args.push('--since', g.since);
133
+ const agent = command.optsWithGlobals().agent;
134
+ if (agent)
135
+ args.push('-a', agent);
136
+ if (g.all !== false)
137
+ args.push('--all');
138
+ if (command.parent?.getOptionValueSource?.('limit') === 'cli' && g.limit)
139
+ args.push('-n', String(g.limit));
140
+ const { bundles, errors } = await pullBundlesFromHosts(hosts, args);
141
+ for (const e of errors)
142
+ process.stderr.write(chalk.yellow(` ${e}\n`));
143
+ const records = mergeRecords(bundles.map(b => b.records));
144
+ if (records.length === 0) {
145
+ process.stderr.write(chalk.red('No sessions pulled from the given host(s).\n'));
146
+ process.exit(1);
147
+ }
148
+ const header = makeHeader({
149
+ origin: hosts.join(','),
150
+ exportedAt: new Date().toISOString(),
151
+ encrypted: false,
152
+ redacted: bundles.some(b => b.header.redacted),
153
+ records,
154
+ });
155
+ return { header, records };
156
+ }
157
+ /**
158
+ * Decrypt-key resolution: an explicit `--decrypt <key>` (base64 or hex) wins;
159
+ * otherwise fall back to the fleet-shared R2_SYNC_ENC_KEY from the r2.backups
160
+ * bundle. Fails loudly when an encrypted bundle has no usable key.
161
+ */
162
+ function resolveDecryptKey(decrypt) {
163
+ if (typeof decrypt === 'string' && decrypt.trim()) {
164
+ const raw = decrypt.trim();
165
+ const key = /^[0-9a-f]{64}$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
166
+ if (key.length !== 32) {
167
+ process.stderr.write(chalk.red(`--decrypt key must decode to 32 bytes (got ${key.length}).\n`));
168
+ process.exit(1);
169
+ }
170
+ return key;
171
+ }
172
+ try {
173
+ const key = resolveSyncEncKey(loadR2Config());
174
+ if (key)
175
+ return key;
176
+ }
177
+ catch {
178
+ // sync bundle not configured
179
+ }
180
+ process.stderr.write(chalk.red('This bundle is encrypted but no key is available. Pass --decrypt <key>, ' +
181
+ 'or configure the r2.backups sync bundle so its shared key is used.\n'));
182
+ process.exit(1);
183
+ }
184
+ /** Print the dry-run table, grouped by session. Reads disk, writes nothing. */
185
+ function printDryRun(plan, bundle) {
186
+ // Group file-level plan items by session for a readable table.
187
+ const bySession = new Map();
188
+ for (const item of plan) {
189
+ const key = `${item.record.agent}:${item.record.machine}:${item.record.sessionId}`;
190
+ let row = bySession.get(key);
191
+ if (!row)
192
+ bySession.set(key, (row = { agent: item.record.agent, machine: item.record.machine, sessionId: item.record.sessionId, statuses: new Set(), files: 0 }));
193
+ row.statuses.add(item.status);
194
+ row.files++;
195
+ }
196
+ process.stdout.write(chalk.bold(`Bundle: ${bundle.header.sessions} session(s), ${bundle.header.count} file(s), origin ${bundle.header.origin}${bundle.header.encrypted ? ', encrypted' : ''}\n\n`));
197
+ const header = `${pad('SESSION', 22)}${pad('AGENT', 10)}${pad('ORIGIN', 16)}${pad('FILES', 7)}STATUS`;
198
+ process.stdout.write(chalk.dim(header) + '\n');
199
+ for (const row of bySession.values()) {
200
+ const status = aggregateStatus(row.statuses);
201
+ process.stdout.write(pad(row.sessionId.slice(0, 20), 22) +
202
+ pad(row.agent, 10) +
203
+ pad(row.machine, 16) +
204
+ pad(String(row.files), 7) +
205
+ colorStatus(status) + '\n');
206
+ }
207
+ process.stdout.write(chalk.dim('\n(dry run — nothing was written)\n'));
208
+ }
209
+ function aggregateStatus(statuses) {
210
+ if (statuses.has('conflict'))
211
+ return 'conflict';
212
+ if (statuses.has('unknown'))
213
+ return 'unknown';
214
+ if (statuses.has('new'))
215
+ return statuses.has('dup') ? 'partial' : 'new';
216
+ return 'dup';
217
+ }
218
+ function colorStatus(status) {
219
+ switch (status) {
220
+ case 'new': return chalk.green(status);
221
+ case 'dup': return chalk.dim(status);
222
+ case 'partial': return chalk.cyan(status);
223
+ case 'conflict': return chalk.yellow(status);
224
+ case 'unknown': return chalk.red(status);
225
+ default: return status;
226
+ }
227
+ }
228
+ function pad(s, w) {
229
+ return s.length >= w ? s.slice(0, w - 1) + ' ' : s + ' '.repeat(w - s.length);
230
+ }
@@ -201,6 +201,22 @@ export declare function pickSessionInteractive(sessions: SessionMeta[], message?
201
201
  * binary is missing (ENOENT).
202
202
  */
203
203
  export declare function resumeSessionInPlace(session: SessionMeta): Promise<void>;
204
+ /**
205
+ * Map a resume argv to the spawn(command, args, {shell}) triple.
206
+ *
207
+ * On Windows the agent launcher is a `.cmd`/PATHEXT shim and needs shell:true.
208
+ * With shell:true, Node concatenates args into the cmd.exe line unescaped
209
+ * (DEP0190 + injection). A session.id derived from a filename can carry
210
+ * metacharacters (`&|<>`); compose a fully-quoted line and pass an EMPTY args
211
+ * array so cmd.exe cannot reparse them (RUSH-1753). See composeWin32CommandLine.
212
+ *
213
+ * `platform` is injectable so the win32 shell path is unit-testable on any host.
214
+ */
215
+ export declare function resumeSpawnInvocation(cmd: string[], platform?: NodeJS.Platform): {
216
+ command: string;
217
+ args: string[];
218
+ shell: boolean;
219
+ };
204
220
  /**
205
221
  * Build the shell command that resumes a picked session.
206
222
  *