@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
@@ -0,0 +1,35 @@
1
+ /** One issued token, stored by hash only. */
2
+ export interface ControlTokenRecord {
3
+ /** Short public id for display / revocation (not secret). */
4
+ id: string;
5
+ /** SHA-256 hex of the raw token. */
6
+ hash: string;
7
+ /** Human label, e.g. "muqsit-iphone". */
8
+ label: string;
9
+ createdAt: string;
10
+ }
11
+ /**
12
+ * Mint a new control token, persist its hash, and return the raw token ONCE.
13
+ * The caller must surface it to the operator immediately — it cannot be
14
+ * recovered later.
15
+ */
16
+ export declare function addControlToken(label: string): {
17
+ id: string;
18
+ token: string;
19
+ };
20
+ /**
21
+ * Ensure at least one token exists. Returns the raw token only when it had to
22
+ * mint one (first `--control` boot); otherwise `{ created: false }` and the
23
+ * existing tokens stand.
24
+ */
25
+ export declare function ensureControlToken(label?: string): {
26
+ created: true;
27
+ id: string;
28
+ token: string;
29
+ } | {
30
+ created: false;
31
+ };
32
+ /** True when `presented` matches a stored token hash (constant-time compare). */
33
+ export declare function verifyControlToken(presented: string | undefined): boolean;
34
+ /** List issued tokens (hashes only — safe to display). */
35
+ export declare function listControlTokens(): ControlTokenRecord[];
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Bearer-token store for the authenticated `agents serve --control` server.
3
+ *
4
+ * Only the SHA-256 *hash* of each token is written to disk — the raw token is
5
+ * shown once at creation (like an API key) and never persisted, so a leaked
6
+ * store file cannot be replayed. This is the anchor-side credential the iOS
7
+ * cockpit presents on every request; `agents devices pair-ios` (Phase 3) mints
8
+ * additional per-device tokens through {@link addControlToken}.
9
+ *
10
+ * Store path: `<cache>/serve/control-tokens.json` — under `.cache/`, which is
11
+ * gitignored, so no credential material lands in a version-controlled repo.
12
+ */
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { createHash, randomBytes, timingSafeEqual } from 'crypto';
16
+ import { getCacheDir } from '../state.js';
17
+ function storePath() {
18
+ return path.join(getCacheDir(), 'serve', 'control-tokens.json');
19
+ }
20
+ function readStore() {
21
+ try {
22
+ const raw = fs.readFileSync(storePath(), 'utf-8');
23
+ const parsed = JSON.parse(raw);
24
+ if (parsed && Array.isArray(parsed.tokens))
25
+ return parsed;
26
+ }
27
+ catch {
28
+ // Missing/corrupt store → start empty; a fresh token gets minted below.
29
+ }
30
+ return { tokens: [] };
31
+ }
32
+ function writeStore(store) {
33
+ const p = storePath();
34
+ fs.mkdirSync(path.dirname(p), { recursive: true });
35
+ // 0600: readable only by the owner — it holds token hashes. `mode` on
36
+ // writeFileSync is honored only at *creation*, so chmod every write to
37
+ // self-heal if the perms were ever widened externally (this is a credential
38
+ // file, even if only hashes).
39
+ fs.writeFileSync(p, JSON.stringify(store, null, 2), { mode: 0o600 });
40
+ fs.chmodSync(p, 0o600);
41
+ }
42
+ function sha256(s) {
43
+ return createHash('sha256').update(s).digest('hex');
44
+ }
45
+ /**
46
+ * Mint a new control token, persist its hash, and return the raw token ONCE.
47
+ * The caller must surface it to the operator immediately — it cannot be
48
+ * recovered later.
49
+ */
50
+ export function addControlToken(label) {
51
+ const store = readStore();
52
+ const id = randomBytes(4).toString('hex');
53
+ const token = randomBytes(32).toString('hex');
54
+ store.tokens.push({ id, hash: sha256(token), label, createdAt: new Date().toISOString() });
55
+ writeStore(store);
56
+ return { id, token };
57
+ }
58
+ /**
59
+ * Ensure at least one token exists. Returns the raw token only when it had to
60
+ * mint one (first `--control` boot); otherwise `{ created: false }` and the
61
+ * existing tokens stand.
62
+ */
63
+ export function ensureControlToken(label = 'default') {
64
+ const store = readStore();
65
+ if (store.tokens.length > 0)
66
+ return { created: false };
67
+ const { id, token } = addControlToken(label);
68
+ return { created: true, id, token };
69
+ }
70
+ /** True when `presented` matches a stored token hash (constant-time compare). */
71
+ export function verifyControlToken(presented) {
72
+ if (!presented)
73
+ return false;
74
+ const want = Buffer.from(sha256(presented), 'hex');
75
+ for (const rec of readStore().tokens) {
76
+ const have = Buffer.from(rec.hash, 'hex');
77
+ if (have.length === want.length && timingSafeEqual(have, want))
78
+ return true;
79
+ }
80
+ return false;
81
+ }
82
+ /** List issued tokens (hashes only — safe to display). */
83
+ export function listControlTokens() {
84
+ return readStore().tokens;
85
+ }
@@ -0,0 +1,164 @@
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
+ * Literal secret values to mask verbatim during redaction (value-aware pass):
81
+ * e.g. live credentials from an injected secrets bundle, so they never leak in
82
+ * an exported transcript regardless of format. Only used when `redact` is set.
83
+ */
84
+ knownSecrets?: readonly string[];
85
+ }
86
+ /** Look up the sync spec for an agent id (undefined → agent not sync-representable). */
87
+ export declare function specForAgent(agentId: string): SyncAgentSpec | undefined;
88
+ /** True when an agent's sessions can be represented in a bundle (has a sync spec). */
89
+ export declare function isExportableAgent(agentId: string): boolean;
90
+ /**
91
+ * Read one file and turn it into a bundle record. The hash and size are always
92
+ * computed over the PLAINTEXT (post-redaction) body, so they equal what lands on
93
+ * disk after import — keeping dedup byte-exact whether or not the bundle is
94
+ * encrypted.
95
+ */
96
+ export declare function buildRecord(file: FileToExport, opts: BuildRecordOpts): BundleRecord;
97
+ /** Build the header for a set of records. */
98
+ export declare function makeHeader(args: {
99
+ origin: string;
100
+ exportedAt: string;
101
+ encrypted: boolean;
102
+ redacted: boolean;
103
+ records: BundleRecord[];
104
+ }): BundleHeader;
105
+ /**
106
+ * Merge record sets from several bundles (e.g. a fan-out pull across hosts),
107
+ * deduping by agent + origin machine + session + file so the same session seen
108
+ * from two peers lands once. First occurrence wins.
109
+ */
110
+ export declare function mergeRecords(sets: BundleRecord[][]): BundleRecord[];
111
+ /** Serialize a bundle to its NDJSON wire form (header line + one line per record). */
112
+ export declare function serializeBundle(header: BundleHeader, records: BundleRecord[]): string;
113
+ /**
114
+ * Write a serialized bundle to disk owner-only (0600). A bundle carries raw
115
+ * transcript bodies — even redacted, never world/group-readable — and encryption
116
+ * is opt-in, so the file mode is the baseline confidentiality guard. `mode` on
117
+ * `writeFileSync` only applies when the file is created, so we `chmod` too to
118
+ * clamp an existing (possibly looser) file on overwrite.
119
+ */
120
+ export declare function writeBundleFile(outPath: string, wire: string): void;
121
+ /** Parse an NDJSON bundle, validating the header kind + version. Throws on malformed input. */
122
+ export declare function parseBundle(text: string): ParsedBundle;
123
+ /** Placement outcome for one record, computed against what is already on disk. */
124
+ export type ImportStatus = 'new' | 'dup' | 'conflict' | 'unknown';
125
+ export interface ImportPlanItem {
126
+ record: BundleRecord;
127
+ /** Absolute path the file lands at (empty for `unknown` agents). */
128
+ targetPath: string;
129
+ status: ImportStatus;
130
+ }
131
+ export interface PlanImportOpts {
132
+ /** Key to open encrypted record bodies (null → only plaintext bodies readable). */
133
+ decryptKey: Buffer | null;
134
+ }
135
+ /**
136
+ * Compute where each record lands and whether it duplicates / conflicts with an
137
+ * existing file. Pure w.r.t. the filesystem it reads (no writes). Dedup is
138
+ * byte-exact: a target that already holds an identical body is `dup`; a target
139
+ * that holds a DIFFERENT body is `conflict` (only overwritten with --overwrite).
140
+ * An agent with no sync spec is `unknown` and never placed.
141
+ */
142
+ export declare function planImport(bundle: ParsedBundle, opts: PlanImportOpts): ImportPlanItem[];
143
+ export interface WriteResult {
144
+ /** New files written. */
145
+ placed: number;
146
+ /** Byte-exact dups skipped. */
147
+ skipped: number;
148
+ /** Conflicts replaced (only with overwrite). */
149
+ overwritten: number;
150
+ /** Conflicts left in place (overwrite off). */
151
+ conflicts: number;
152
+ /** Records for agents with no sync spec. */
153
+ unknown: number;
154
+ }
155
+ export interface WriteImportOpts {
156
+ overwrite: boolean;
157
+ decryptKey: Buffer | null;
158
+ }
159
+ /**
160
+ * Materialize a plan to disk. `dup` records are always skipped (local wins);
161
+ * `conflict` records are replaced only when `overwrite` is set; `unknown` records
162
+ * are counted and skipped.
163
+ */
164
+ export declare function writeImport(plan: ImportPlanItem[], opts: WriteImportOpts): WriteResult;
@@ -0,0 +1,200 @@
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, opts.knownSecrets);
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
+ /**
109
+ * Write a serialized bundle to disk owner-only (0600). A bundle carries raw
110
+ * transcript bodies — even redacted, never world/group-readable — and encryption
111
+ * is opt-in, so the file mode is the baseline confidentiality guard. `mode` on
112
+ * `writeFileSync` only applies when the file is created, so we `chmod` too to
113
+ * clamp an existing (possibly looser) file on overwrite.
114
+ */
115
+ export function writeBundleFile(outPath, wire) {
116
+ fs.writeFileSync(outPath, wire, { encoding: 'utf-8', mode: 0o600 });
117
+ fs.chmodSync(outPath, 0o600);
118
+ }
119
+ /** Parse an NDJSON bundle, validating the header kind + version. Throws on malformed input. */
120
+ export function parseBundle(text) {
121
+ const lines = text.split('\n').filter(l => l.trim().length > 0);
122
+ if (lines.length === 0)
123
+ throw new Error('Empty session bundle.');
124
+ let header;
125
+ try {
126
+ header = JSON.parse(lines[0]);
127
+ }
128
+ catch {
129
+ throw new Error('Malformed session bundle: first line is not JSON.');
130
+ }
131
+ if (!header || header.kind !== BUNDLE_KIND) {
132
+ throw new Error(`Not an agents session bundle (kind=${header?.kind ?? 'missing'}).`);
133
+ }
134
+ if (header.version !== BUNDLE_VERSION) {
135
+ throw new Error(`Unsupported bundle version ${header.version} — this CLI reads v${BUNDLE_VERSION}.`);
136
+ }
137
+ const records = [];
138
+ for (let i = 1; i < lines.length; i++) {
139
+ try {
140
+ records.push(JSON.parse(lines[i]));
141
+ }
142
+ catch {
143
+ throw new Error(`Malformed session bundle: record on line ${i + 1} is not JSON.`);
144
+ }
145
+ }
146
+ return { header, records };
147
+ }
148
+ /**
149
+ * Compute where each record lands and whether it duplicates / conflicts with an
150
+ * existing file. Pure w.r.t. the filesystem it reads (no writes). Dedup is
151
+ * byte-exact: a target that already holds an identical body is `dup`; a target
152
+ * that holds a DIFFERENT body is `conflict` (only overwritten with --overwrite).
153
+ * An agent with no sync spec is `unknown` and never placed.
154
+ */
155
+ export function planImport(bundle, opts) {
156
+ return bundle.records.map((record) => {
157
+ const spec = specForAgent(record.agent);
158
+ if (!spec)
159
+ return { record, targetPath: '', status: 'unknown' };
160
+ const body = decryptTranscriptBody(record.body, opts.decryptKey);
161
+ const bodyHash = hashContent(body);
162
+ const targetPath = mirrorPath(spec, record.machine, record.relKey);
163
+ let status = 'new';
164
+ if (fs.existsSync(targetPath)) {
165
+ const existing = fs.readFileSync(targetPath, 'utf-8');
166
+ status = hashContent(existing) === bodyHash ? 'dup' : 'conflict';
167
+ }
168
+ return { record, targetPath, status };
169
+ });
170
+ }
171
+ /**
172
+ * Materialize a plan to disk. `dup` records are always skipped (local wins);
173
+ * `conflict` records are replaced only when `overwrite` is set; `unknown` records
174
+ * are counted and skipped.
175
+ */
176
+ export function writeImport(plan, opts) {
177
+ const res = { placed: 0, skipped: 0, overwritten: 0, conflicts: 0, unknown: 0 };
178
+ for (const item of plan) {
179
+ if (item.status === 'unknown') {
180
+ res.unknown++;
181
+ continue;
182
+ }
183
+ if (item.status === 'dup') {
184
+ res.skipped++;
185
+ continue;
186
+ }
187
+ if (item.status === 'conflict' && !opts.overwrite) {
188
+ res.conflicts++;
189
+ continue;
190
+ }
191
+ const body = decryptTranscriptBody(item.record.body, opts.decryptKey);
192
+ fs.mkdirSync(path.dirname(item.targetPath), { recursive: true });
193
+ fs.writeFileSync(item.targetPath, body, 'utf-8');
194
+ if (item.status === 'conflict')
195
+ res.overwritten++;
196
+ else
197
+ res.placed++;
198
+ }
199
+ return res;
200
+ }
@@ -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
+ }
@@ -18,7 +18,7 @@ import chalk from 'chalk';
18
18
  import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
19
  import { sshTargetFor } from '../devices/connect.js';
20
20
  import { resolveExplicitTargets } from '../devices/resolve-target.js';
21
- import { loadDevices } from '../devices/registry.js';
21
+ import { loadDevices, isControlDevice } from '../devices/registry.js';
22
22
  import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
23
23
  import { machineId, normalizeHost } from './sync/config.js';
24
24
  import { NO_FANOUT_ENV } from './remote-active.js';
@@ -129,6 +129,10 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
129
129
  continue;
130
130
  if (normalizeHost(d.name) === self)
131
131
  continue;
132
+ // Control-only devices (a phone/tablet running the cockpit) drive the fleet
133
+ // but never run agents — never dial them, whatever their platform reads as.
134
+ if (isControlDevice(d))
135
+ continue;
132
136
  // Only machines that can actually run the CLI. iOS/tablet nodes register as
133
137
  // `unknown` platform and can never answer, so skip them rather than burn a
134
138
  // full ConnectTimeout on each.
@@ -14,7 +14,8 @@
14
14
  * markers. Codex has no such tools, so it falls back to last-role + question
15
15
  * shape + mtime — same function, driven off the normalized events.
16
16
  */
17
- import type { SessionAttachment, SessionEvent } from './types.js';
17
+ import type { SessionAttachment, SessionEvent, TodoItem, TodoProgress } from './types.js';
18
+ export type { TodoItem, TodoProgress };
18
19
  export type SessionActivity = 'working' | 'waiting_input' | 'idle';
19
20
  export type AwaitingReason = 'question' | 'plan_review' | 'permission';
20
21
  /** One discrete choice the agent offered the user. */
@@ -41,28 +42,6 @@ export interface StructuredQuestion {
41
42
  reason: AwaitingReason;
42
43
  options?: QuestionOption[];
43
44
  }
44
- /** One `TodoWrite` checklist item, as Claude's plan tool emits it. */
45
- export interface TodoItem {
46
- content: string;
47
- status: 'pending' | 'in_progress' | 'completed';
48
- /** Present-continuous label shown while this item is the active step. */
49
- activeForm?: string;
50
- }
51
- /**
52
- * Live plan progress derived from the most recent `TodoWrite` in the transcript
53
- * (RUSH-1380). Lets the Factory Floor show "N/M done" + the current step for any
54
- * session — including remote / device-dispatched agents that carry no local
55
- * tool-call stream — instead of only a coarse working/idle verb.
56
- */
57
- export interface TodoProgress {
58
- items: TodoItem[];
59
- /** Count of completed items. */
60
- done: number;
61
- /** Total items. */
62
- total: number;
63
- /** The in-progress item's activeForm (falls back to its content). The live step. */
64
- activeForm?: string;
65
- }
66
45
  export interface DetectedPr {
67
46
  url: string;
68
47
  number?: number;
@@ -140,8 +119,11 @@ export interface StateContext {
140
119
  activeWindowMs?: number;
141
120
  }
142
121
  /**
143
- * Derive live plan progress from a `TodoWrite` tool call's args. Returns undefined
144
- * when there is no usable list, so a session with no plan carries no `todos` field.
122
+ * Derive live plan progress from a checklist tool call's args. Accepts both
123
+ * Claude's `TodoWrite` (`todos: [{content,status,activeForm}]`) and Codex's
124
+ * `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the single source
125
+ * of checklist state for every agent. Returns undefined when there is no usable
126
+ * list, so a session with no plan carries no `todos` field.
145
127
  */
146
128
  export declare function extractTodoProgress(args?: Record<string, any>): TodoProgress | undefined;
147
129
  /** Detect a worktree from the session cwd, per the `.agents/worktrees/<slug>/` convention. */