sealkeep 0.9.0 → 0.11.1

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 (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +252 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +1 -0
  16. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
  17. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  18. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  20. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  21. package/dist/site/index.html +1808 -1904
  22. package/dist/site/llms.txt +67 -0
  23. package/dist/site/trust/architecture-data-flow.html +53 -0
  24. package/dist/site/trust/audit-roadmap.html +37 -0
  25. package/dist/site/trust/deployment-responsibility.html +11 -0
  26. package/dist/site/trust/dpa-sample.html +30 -0
  27. package/dist/site/trust/release-provenance.html +21 -0
  28. package/dist/site/trust/subprocessors.html +15 -0
  29. package/dist/site/trust/threat-model.html +34 -0
  30. package/dist/site/trust/toms.html +41 -0
  31. package/dist/site/trust-document.css +32 -0
  32. package/dist/site/trust.html +73 -0
  33. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  34. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  35. package/dist/site/visual/index.html +18 -0
  36. package/dist/site.zip +0 -0
  37. package/dist/src/activity.d.ts +9 -0
  38. package/dist/src/activity.js +90 -1
  39. package/dist/src/adapters.d.ts +175 -5
  40. package/dist/src/adapters.js +961 -73
  41. package/dist/src/agent-context.d.ts +135 -0
  42. package/dist/src/agent-context.js +1059 -0
  43. package/dist/src/archive-copies.d.ts +47 -0
  44. package/dist/src/archive-copies.js +179 -0
  45. package/dist/src/audit.d.ts +1 -1
  46. package/dist/src/audit.js +29 -4
  47. package/dist/src/autopilot.d.ts +52 -7
  48. package/dist/src/autopilot.js +143 -25
  49. package/dist/src/background-bandwidth.d.ts +46 -0
  50. package/dist/src/background-bandwidth.js +301 -0
  51. package/dist/src/background-cpu.d.ts +82 -0
  52. package/dist/src/background-cpu.js +212 -0
  53. package/dist/src/background-worker-error.d.ts +12 -0
  54. package/dist/src/background-worker-error.js +18 -0
  55. package/dist/src/branding.d.ts +24 -1
  56. package/dist/src/branding.js +31 -1
  57. package/dist/src/bridge.d.ts +233 -0
  58. package/dist/src/bridge.js +604 -0
  59. package/dist/src/byte-stream.d.ts +91 -0
  60. package/dist/src/byte-stream.js +385 -0
  61. package/dist/src/chunk-store.d.ts +41 -8
  62. package/dist/src/chunk-store.js +161 -65
  63. package/dist/src/cli.js +1698 -163
  64. package/dist/src/cloud.d.ts +841 -31
  65. package/dist/src/cloud.js +3196 -277
  66. package/dist/src/context-background.d.ts +37 -0
  67. package/dist/src/context-background.js +309 -0
  68. package/dist/src/context-drain-child.d.ts +1 -0
  69. package/dist/src/context-drain-child.js +98 -0
  70. package/dist/src/context-reader.d.ts +118 -0
  71. package/dist/src/context-reader.js +447 -0
  72. package/dist/src/control-plane/auth.d.ts +32 -4
  73. package/dist/src/control-plane/auth.js +85 -24
  74. package/dist/src/control-plane/server.js +19 -6
  75. package/dist/src/control-plane.d.ts +17 -1
  76. package/dist/src/control-plane.js +32 -6
  77. package/dist/src/crypto.d.ts +1 -1
  78. package/dist/src/crypto.js +5 -5
  79. package/dist/src/daemon-lease.d.ts +70 -0
  80. package/dist/src/daemon-lease.js +420 -0
  81. package/dist/src/daemon.d.ts +94 -1
  82. package/dist/src/daemon.js +1082 -105
  83. package/dist/src/darwin-service-policy.d.ts +41 -0
  84. package/dist/src/darwin-service-policy.js +60 -0
  85. package/dist/src/dashboard-cli.js +15 -15
  86. package/dist/src/device-authorization.d.ts +37 -0
  87. package/dist/src/device-authorization.js +199 -0
  88. package/dist/src/device-enrollment.d.ts +91 -0
  89. package/dist/src/device-enrollment.js +349 -0
  90. package/dist/src/disk.d.ts +17 -12
  91. package/dist/src/disk.js +43 -17
  92. package/dist/src/doctor.d.ts +35 -1
  93. package/dist/src/doctor.js +332 -41
  94. package/dist/src/durable-ticket-lock.d.ts +24 -0
  95. package/dist/src/durable-ticket-lock.js +232 -0
  96. package/dist/src/enroll.d.ts +1 -1
  97. package/dist/src/enroll.js +13 -7
  98. package/dist/src/env.d.ts +10 -1
  99. package/dist/src/env.js +11 -3
  100. package/dist/src/errors.d.ts +8 -8
  101. package/dist/src/errors.js +6 -6
  102. package/dist/src/flush.d.ts +12 -0
  103. package/dist/src/flush.js +37 -0
  104. package/dist/src/heartbeat.d.ts +86 -12
  105. package/dist/src/heartbeat.js +415 -29
  106. package/dist/src/index-background-watchdog.d.ts +1 -0
  107. package/dist/src/index-background-watchdog.js +94 -0
  108. package/dist/src/index-background-work.d.ts +21 -0
  109. package/dist/src/index-background-work.js +25 -0
  110. package/dist/src/index-background.d.ts +64 -0
  111. package/dist/src/index-background.js +394 -0
  112. package/dist/src/index-build-child.d.ts +1 -0
  113. package/dist/src/index-build-child.js +109 -0
  114. package/dist/src/index-manifest.d.ts +52 -0
  115. package/dist/src/index-manifest.js +444 -0
  116. package/dist/src/index-publication-proof.d.ts +84 -0
  117. package/dist/src/index-publication-proof.js +380 -0
  118. package/dist/src/index-publication-state.d.ts +149 -0
  119. package/dist/src/index-publication-state.js +696 -0
  120. package/dist/src/index-publication-verifier.d.ts +89 -0
  121. package/dist/src/index-publication-verifier.js +341 -0
  122. package/dist/src/index-publish.d.ts +62 -0
  123. package/dist/src/index-publish.js +540 -0
  124. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  125. package/dist/src/index-scratch-cleanup.js +166 -0
  126. package/dist/src/index-segment-types.d.ts +132 -0
  127. package/dist/src/index-segment-types.js +21 -0
  128. package/dist/src/index-segments.d.ts +9 -0
  129. package/dist/src/index-segments.js +516 -0
  130. package/dist/src/index-store.d.ts +123 -0
  131. package/dist/src/index-store.js +495 -0
  132. package/dist/src/index-sync.d.ts +91 -15
  133. package/dist/src/index-sync.js +286 -53
  134. package/dist/src/index-upgrade-publication.d.ts +30 -0
  135. package/dist/src/index-upgrade-publication.js +179 -0
  136. package/dist/src/integration-manager.d.ts +32 -0
  137. package/dist/src/integration-manager.js +394 -0
  138. package/dist/src/leakscan.js +1 -1
  139. package/dist/src/local-api.d.ts +56 -15
  140. package/dist/src/local-api.js +4979 -541
  141. package/dist/src/machine-settings.d.ts +51 -0
  142. package/dist/src/machine-settings.js +166 -0
  143. package/dist/src/managed-chunks.d.ts +5 -2
  144. package/dist/src/managed-chunks.js +14 -14
  145. package/dist/src/mcp-install.d.ts +11 -9
  146. package/dist/src/mcp-install.js +73 -28
  147. package/dist/src/mcp-workspace.d.ts +18 -0
  148. package/dist/src/mcp-workspace.js +50 -0
  149. package/dist/src/mcp.js +294 -25
  150. package/dist/src/migrate.js +27 -21
  151. package/dist/src/notify.d.ts +1 -1
  152. package/dist/src/notify.js +5 -5
  153. package/dist/src/offload.d.ts +201 -14
  154. package/dist/src/offload.js +1848 -140
  155. package/dist/src/onboarding.d.ts +8 -1
  156. package/dist/src/onboarding.js +4 -4
  157. package/dist/src/packages.d.ts +2 -2
  158. package/dist/src/packages.js +10 -2
  159. package/dist/src/passkey.d.ts +0 -1
  160. package/dist/src/passkey.js +2 -7
  161. package/dist/src/password-lock.d.ts +2 -2
  162. package/dist/src/password-lock.js +6 -6
  163. package/dist/src/paths.d.ts +2 -0
  164. package/dist/src/paths.js +2 -0
  165. package/dist/src/presence.d.ts +86 -0
  166. package/dist/src/presence.js +240 -0
  167. package/dist/src/progress-deadline.d.ts +21 -0
  168. package/dist/src/progress-deadline.js +91 -0
  169. package/dist/src/project-repair.d.ts +55 -0
  170. package/dist/src/project-repair.js +131 -0
  171. package/dist/src/providers/gcs.d.ts +28 -7
  172. package/dist/src/providers/gcs.js +35 -24
  173. package/dist/src/providers/gdrive.d.ts +71 -8
  174. package/dist/src/providers/gdrive.js +223 -50
  175. package/dist/src/providers/index.d.ts +11 -3
  176. package/dist/src/providers/index.js +30 -10
  177. package/dist/src/providers/s3.d.ts +30 -8
  178. package/dist/src/providers/s3.js +41 -30
  179. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  180. package/dist/src/providers/safe-storage-fetch.js +72 -0
  181. package/dist/src/queue.d.ts +136 -19
  182. package/dist/src/queue.js +862 -96
  183. package/dist/src/reclaim-transaction.d.ts +156 -0
  184. package/dist/src/reclaim-transaction.js +1027 -0
  185. package/dist/src/recovery-codes.d.ts +32 -0
  186. package/dist/src/recovery-codes.js +338 -0
  187. package/dist/src/recovery.js +12 -9
  188. package/dist/src/rehydrate.d.ts +25 -22
  189. package/dist/src/rehydrate.js +319 -23
  190. package/dist/src/restore.d.ts +57 -4
  191. package/dist/src/restore.js +272 -36
  192. package/dist/src/resume-stub.d.ts +92 -0
  193. package/dist/src/resume-stub.js +417 -0
  194. package/dist/src/retention.d.ts +98 -7
  195. package/dist/src/retention.js +1066 -62
  196. package/dist/src/rotate.js +3 -3
  197. package/dist/src/search.d.ts +566 -8
  198. package/dist/src/search.js +5940 -297
  199. package/dist/src/secrets.d.ts +51 -7
  200. package/dist/src/secrets.js +316 -24
  201. package/dist/src/service.d.ts +49 -11
  202. package/dist/src/service.js +776 -35
  203. package/dist/src/share.js +3 -3
  204. package/dist/src/shared-spaces.d.ts +98 -0
  205. package/dist/src/shared-spaces.js +214 -0
  206. package/dist/src/source-reader.d.ts +73 -0
  207. package/dist/src/source-reader.js +715 -0
  208. package/dist/src/spool.d.ts +1 -1
  209. package/dist/src/spool.js +1 -1
  210. package/dist/src/start-tui.js +2 -1
  211. package/dist/src/start.js +2 -2
  212. package/dist/src/storage-endpoint.d.ts +21 -0
  213. package/dist/src/storage-endpoint.js +122 -0
  214. package/dist/src/storage-setup.js +12 -12
  215. package/dist/src/storage-targets.d.ts +109 -6
  216. package/dist/src/storage-targets.js +975 -67
  217. package/dist/src/stream-to-cloud.d.ts +5 -1
  218. package/dist/src/stream-to-cloud.js +34 -14
  219. package/dist/src/sync-rules.d.ts +31 -6
  220. package/dist/src/sync-rules.js +153 -14
  221. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  222. package/dist/src/team-backfill-scheduling.js +33 -0
  223. package/dist/src/team-backfill.d.ts +116 -0
  224. package/dist/src/team-backfill.js +1429 -0
  225. package/dist/src/team-index-cache.d.ts +16 -0
  226. package/dist/src/team-index-cache.js +152 -0
  227. package/dist/src/team-offboarding.d.ts +38 -0
  228. package/dist/src/team-offboarding.js +1043 -0
  229. package/dist/src/team-presence.d.ts +127 -0
  230. package/dist/src/team-presence.js +904 -0
  231. package/dist/src/team-publication-policy.d.ts +20 -0
  232. package/dist/src/team-publication-policy.js +140 -0
  233. package/dist/src/team-realtime.d.ts +68 -0
  234. package/dist/src/team-realtime.js +816 -0
  235. package/dist/src/team-source-facts-cache.d.ts +23 -0
  236. package/dist/src/team-source-facts-cache.js +255 -0
  237. package/dist/src/trash.d.ts +1 -1
  238. package/dist/src/trash.js +2 -2
  239. package/dist/src/tui.js +11 -12
  240. package/dist/src/types.d.ts +173 -7
  241. package/dist/src/types.js +20 -0
  242. package/dist/src/ui-server.d.ts +163 -35
  243. package/dist/src/ui-server.js +712 -72
  244. package/dist/src/ui.d.ts +1 -2
  245. package/dist/src/ui.js +1 -2
  246. package/dist/src/upload.d.ts +27 -0
  247. package/dist/src/upload.js +383 -43
  248. package/dist/src/vault.d.ts +226 -30
  249. package/dist/src/vault.js +1776 -192
  250. package/dist/src/watcher.d.ts +7 -1
  251. package/dist/src/watcher.js +198 -55
  252. package/dist/src/worker.d.ts +27 -3
  253. package/dist/src/worker.js +274 -55
  254. package/package.json +33 -12
  255. package/scripts/native-reboot-rehearsal.mjs +90 -0
  256. package/web/app.js +6032 -343
  257. package/web/bootstrap.js +17 -0
  258. package/web/index.html +255 -57
  259. package/web/rail.js +317 -40
  260. package/web/retention.html +2 -2
  261. package/web/rules-view.js +188 -16
  262. package/web/sessions-view.js +485 -62
  263. package/web/sessions.html +2 -2
  264. package/web/setup-api.js +152 -29
  265. package/web/setup-logic.js +68 -9
  266. package/web/setup.html +113 -44
  267. package/web/setup.js +604 -71
  268. package/web/style.css +513 -98
  269. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/dist/src/cli.js CHANGED
@@ -1,43 +1,47 @@
1
1
  #!/usr/bin/env node
2
2
  import { writeFile } from "node:fs/promises";
3
3
  import { archiveFile, configureRemoteStorage, defaultDataDir, initialize, listArchives, readConfig, vaultStatus, addRecipient, removeRecipient } from "./vault.js";
4
+ import { isV2 } from "./types.js";
4
5
  import { restoreArchive } from "./restore.js";
5
- import { detectAgents, findTranscripts, hookConfig, hookEventFromStdin } from "./adapters.js";
6
+ import { detectAgents, findTranscripts, hookConfig, hookEventFromStdin, hookEventNameOf } from "./adapters.js";
6
7
  import { setupVault } from "./onboarding.js";
7
8
  import { providers } from "./control-plane.js";
8
9
  import { ArchiveQueue } from "./queue.js";
9
10
  import { drainQueue } from "./worker.js";
10
11
  import { runDoctor } from "./doctor.js";
11
12
  import { localApiTokenPath } from "./local-api.js";
12
- import { startUi, readUiRecord, DEFAULT_UI_PORT, DISPLAY_HOST } from "./ui-server.js";
13
+ import { startUi, startUiSupervisor, readUiRecord, uiProbeOrigin, DEFAULT_UI_PORT, DISPLAY_HOST } from "./ui-server.js";
13
14
  import { enroll } from "./enroll.js";
14
15
  import { freePort } from "./net.js";
15
- import { errorPayload, fail, isVaultlineError } from "./errors.js";
16
- import { DRIVE_FOLDER_DEFAULT, PRODUCT_MARK, PRODUCT_NAME } from "./branding.js";
16
+ import { errorPayload, fail, isSealkeepError } from "./errors.js";
17
+ import { DRIVE_FOLDER_DEFAULT, PRODUCT_MARK, PRODUCT_NAME, STORAGE_PREFIX_DEFAULT, providerLabel } from "./branding.js";
17
18
  import { migrateVault, rewrapVault } from "./migrate.js";
18
19
  import { uploadArchive, uploadPending } from "./upload.js";
19
20
  import { applyRetention, approveReclamation, evaluateRetention, pruneRedundantArchives, retentionSettings, setRetentionPolicy } from "./retention.js";
20
21
  import { startDaemon } from "./daemon.js";
21
22
  import { applySyncRules, describeSyncRules, resolveSyncRules } from "./sync-rules.js";
22
23
  import { buildContentIndex, dropContentIndex, search } from "./search.js";
24
+ import { migrateBlobToSegments } from "./index-store.js";
23
25
  import { readAudit, toCsv } from "./audit.js";
24
26
  import { deleteProviderCredentials, loadProviderCredentials, storeProviderCredentials } from "./secrets.js";
25
- import { keyRecipientId, rawPublicKey } from "../packages/vaultline-crypto/src/index.js";
27
+ import { keyRecipientId, rawPublicKey } from "../packages/sealkeep-crypto/src/index.js";
26
28
  import { generateKeyPairSync } from "node:crypto";
27
29
  import { hostname } from "node:os";
28
30
  import { readFile } from "node:fs/promises";
29
31
  import { runTui } from "./tui.js";
30
32
  import { assertNotDowngrade, verifyArtifact, verifyManifest } from "./update.js";
31
- import { autopilotStatus, disableAutopilot, enableAutopilot } from "./autopilot.js";
33
+ import { autopilotStatus, disableAutopilot, enableAutopilot, reconcileMachineRetentionPolicy } from "./autopilot.js";
32
34
  import { liveness, readHeartbeat } from "./heartbeat.js";
33
35
  import { hasPasswordLock, unwrapPhrase, wrapPhrase } from "./password-lock.js";
34
36
  import { isMnemonic, PHRASE_WORDS, promptForPhrase, recoveryKit, verifyAgainstVault } from "./recovery.js";
35
37
  import { setupPlan } from "./storage-setup.js";
36
38
  import { connectGdrive } from "./providers/gdrive.js";
37
39
  import { resolveRecoveryPhrase } from "./secrets.js";
38
- import { amber, bold, blue, bytes, callout, command as cmd, dim, green, heading, hint, keyValue, mark, relativeTime, shortPath, steps, table, vaultline } from "./ui.js";
39
- import { envVar } from "./env.js";
40
- const VALUE_FLAGS = new Set(["--data-dir", "--recovery-phrase", "--agent", "--home", "--limit", "--executable", "--provider", "--bucket", "--prefix", "--region", "--older-than-days", "--status", "--max", "--port", "--overwrite", "--label", "--public-key", "--config-id", "--backend", "--endpoint", "--policy", "--grace-days", "--interval", "--manifest", "--artifact", "--key", "--current", "--api", "--token", "--group", "--cli-path", "--account-id", "--project", "--out", "--password"]);
40
+ import { amber, bold, blue, bytes, callout, command as cmd, dim, divider, green, heading, hint, keyValue, mark, relativeTime, shortPath, steps, table } from "./ui.js";
41
+ import { envVar, PREFIX as ENV_PREFIX } from "./env.js";
42
+ import { automaticTranscriptIsEnabled, readLocalSettings } from "./machine-settings.js";
43
+ import { normalizeDarwinServicePolicy, prepareLegacyDarwinManagerHandoff } from "./darwin-service-policy.js";
44
+ const VALUE_FLAGS = new Set(["--data-dir", "--recovery-phrase", "--agent", "--home", "--limit", "--executable", "--provider", "--bucket", "--prefix", "--region", "--older-than-days", "--status", "--max", "--port", "--ui-port", "--overwrite", "--label", "--public-key", "--config-id", "--backend", "--endpoint", "--policy", "--grace-days", "--interval", "--manifest", "--artifact", "--key", "--current", "--api", "--token", "--group", "--cli-path", "--account-id", "--project", "--out", "--password", "--kind", "--text", "--channel", "--reword", "--mode", "--session", "--mirror", "--interval-ms", "--from-seq", "--from-byte", "--max-gb", "--priority", "--projects", "--id", "--email", "--code", "--passcode"]);
41
45
  function take(args, flag, fallback) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : fallback; }
42
46
  function positionals(args) { return args.filter((value, index) => !value.startsWith("-") && !VALUE_FLAGS.has(args[index - 1] ?? "")); }
43
47
  function required(value, message) { if (!value)
@@ -134,7 +138,7 @@ async function pauseUntilWrittenDown() {
134
138
  /**
135
139
  * What a browser-opening command says for itself.
136
140
  *
137
- * The URL carries the bearer token in its fragment (see uiUrl in
141
+ * The URL carries a short-lived one-use bootstrap ticket (see uiUrl in
138
142
  * ui-server.ts), so it is deliberately *not* printed on the path where a
139
143
  * browser did open: scrollback outlives the session, gets pasted into issues,
140
144
  * and is captured whole by `script` and CI logs. The origin alone is enough to
@@ -153,14 +157,14 @@ function printUi(handle, dataDir, options = {}) {
153
157
  : `\n${BRAND} ${handle.reused ? "already running on" : "settings on"} ${where}`);
154
158
  if (handle.browser.opened) {
155
159
  print(` ${mark.ok()} Opened in your browser.`);
156
- print(` ${dim(`Nothing opened? Visit ${handle.origin} and paste the token from ${shortPath(localApiTokenPath(dataDir), 46)}`)}`);
160
+ print(` ${dim(`Nothing opened? Run ${cmd("sealkeep ui")} again to mint a fresh secure link for this window.`)}`);
157
161
  }
158
162
  else {
159
163
  // `--no-browser` is a choice; warning that a browser "could not be opened"
160
164
  // would report a failure that never happened.
161
165
  if (!handle.browser.declined)
162
166
  print(` ${mark.warn()} ${(handle.browser.problem ?? "No browser could be opened").replace(/\.$/, "")}.`);
163
- print(` ${dim("Open this link yourself. It carries the key to this window, so keep it off shared screens:")}`);
167
+ print(` ${dim("Open this link yourself. Its sign-in ticket works once and expires quickly, so keep it off shared screens:")}`);
164
168
  print(` ${handle.url}`);
165
169
  }
166
170
  if (handle.reused) {
@@ -186,7 +190,7 @@ function usage() {
186
190
  ["start", "first run: choose free or paid, get your recovery phrase"],
187
191
  ["enroll <code>", "set this machine up from the code in your account panel"],
188
192
  ["ui", "open the settings window in your browser"],
189
- ["autopilot", "set it up once and let it run itself"],
193
+ ["autopilot [--reclaim]", "preserve, index, and upload automatically; local originals stay unless you opt in"],
190
194
  ["quickstart", "set up and queue existing sessions, without a background service"],
191
195
  ["setup", "create a vault and a recovery kit"],
192
196
  ["status", "what is archived, queued, and pending"],
@@ -202,23 +206,39 @@ function usage() {
202
206
  ["scan <path>", "find pasted secrets in transcripts before they are sealed in"],
203
207
  ["list", "browse archives"],
204
208
  ["search <query>", "search metadata, or contents with --content"],
209
+ ["why <commit>", "the session — and the reasoning — behind a git commit"],
205
210
  ["projects", "the project names this vault knows — the ones routing rules pin"],
206
211
  ["space members [project]", "who can open a project, and who can open everything"],
212
+ ["shared", "what you share and what is shared to you; normal agents attach it automatically"],
213
+ ["team", "who is working on what right now — the feed git does not have"],
214
+ ["now \"doing X\" --project P", "tell the team what you started; --done closes it"],
215
+ ["projects mode <name> active|passive", "active: a sealed session becomes searchable + announced in minutes"],
216
+ ["context <project|channel>", "write the compact pack an agent reads when it joins shared memory"],
217
+ ["claude <project|channel>", "start Claude Code with that shared memory attached"],
218
+ ["codex <project|channel>", "same launcher, for Codex"],
207
219
  ["index build", "build the local content index that --content searches"],
220
+ ["index migrate", "move the local index onto sealed, append-only segments"],
208
221
  ["index status", "what is searchable and what is not yet indexed"],
222
+ ["index drop", "remove the local index; the next build rebuilds it from your archives"],
209
223
  ["storage targets", "several storages at once, with limits, pins, and priority"],
210
224
  ["mcp install", "let your agents search this history themselves"],
211
- ["recover <id> <dest>", "restore original bytes; --native puts it back where it came from, --key opens it as a registered recipient"],
225
+ ["bridge start <session> --channel <id>", "live-share an ongoing session; members follow it read-only"],
226
+ ["bridge watch <channel>", "follow a shared session live into a local mirror your agent can read"],
227
+ ["bridge push <channel> \"…\"", "speak into a shared channel; the driver's agent hears it next turn"],
228
+ ["bridge pull <channel>", "fetch teammates' bridge messages into this machine's inbox"],
229
+ ["bridge mode <ch> observe|suggest|open", "cooperation mode: choose how far teammates' words spread"],
230
+ ["bridge review · approve · reject", "suggest mode: your yes/no/reword before anything reaches your agent"],
231
+ ["recover <id> <dest>", "restore original bytes; --dry-run previews without writing, --native restores in place, while backup/replace retain a displaced file at a visible safety path"],
212
232
  ["recover <session-file>", "agent says a session file is missing? name it and it goes back where resume expects it"],
213
233
  ["tui", "the same view in the terminal"]
214
234
  ]),
215
235
  heading("Storage and retention"),
216
236
  keyValue([
217
- ["cloud login/status", "sign in to managed storage (--code for a Google account), or check usage"],
237
+ ["cloud login/status", "sign in with --email and a --code from your account panel, or check usage"],
218
238
  ["cloud push <id>", "send one archive to managed storage"],
219
239
  ["cloud archives", "list what managed storage is holding"],
220
240
  ["cloud pull <ref> <dest>", "fetch one archive back out of managed storage"],
221
- ["open <ref> <dest>", "fetch and decrypt one archive, on any machine"],
241
+ ["open <ref> <dest>", "fetch and decrypt one archive, on any machine; --key opens a colleague's"],
222
242
  ["plans", "what the storage packages cost"],
223
243
  ["verify", "prove every stored archive can still be read back"],
224
244
  ["storage setup", "guided setup for S3, R2, GCS, or Google Drive"],
@@ -227,14 +247,17 @@ function usage() {
227
247
  ["storage credentials", "store provider keys in the OS keychain"],
228
248
  ["upload --all", "upload and verify pending archives"],
229
249
  ["retention policy", "decide when sources may be reclaimed"],
230
- ["retention apply", "dry run; add --confirm to move sources to the trash"],
250
+ ["retention apply", "dry run; add --confirm to permanently free verified local originals"],
231
251
  ["retention prune", "drop snapshots a newer archive already contains"],
232
- ["retention offload", "remove local archives your bucket already holds"]
252
+ ["retention offload", "remove local archives your bucket already holds"],
253
+ ["squeeze", "measure what sealing this machine's sessions would save; source cleanup uses retention"]
233
254
  ]),
234
255
  heading("Keys and trust"),
235
256
  keyValue([
236
257
  ["recovery seal", "store a sealed copy of your key with your account"],
237
258
  ["recovery open", "read that sealed copy back, with your phrase"],
259
+ ["recovery codes", "replace and print five one-time lost-device backup codes"],
260
+ ["recovery restore", "restore a lost-device vault with one backup code"],
238
261
  ["device keygen", "mint a device key"],
239
262
  ["recipients", "list, add, or remove who can open new archives"],
240
263
  ["rewrap", "apply the current recipient set to existing archives"],
@@ -253,12 +276,6 @@ function usage() {
253
276
  }
254
277
  async function main() {
255
278
  const [command, ...args] = process.argv.slice(2);
256
- // The product itself writes SEALKEEP_DATA_DIR into the agent configs that
257
- // `mcp install` generates, and the MCP server honours it — so a person who
258
- // exports it and then runs the CLI reasonably expects the same vault. It used
259
- // to be ignored here, which meant the CLI quietly worked on a different vault
260
- // than the agent did. An explicit --data-dir still wins over the variable.
261
- const dataDir = take(args, "--data-dir", envVar("DATA_DIR") ?? defaultDataDir());
262
279
  const json = args.includes("--json");
263
280
  // --version is the first thing anyone types at an unfamiliar CLI, and it was
264
281
  // answering "Unknown command". Read it from the manifest rather than hardcoding
@@ -290,9 +307,33 @@ async function main() {
290
307
  print(usage());
291
308
  return;
292
309
  }
310
+ // `sealkeep setup --help` used to RUN setup, because --help was only ever
311
+ // read as the first word. Asking a destructive command what it does must
312
+ // never be the same as running it — this was caught rehearsing a company
313
+ // setup, where it re-ran setup against the operator's real vault.
314
+ if (args.includes("--help") || args.includes("-h")) {
315
+ print(usage());
316
+ print(` ${dim(`Showing the full command list: ${command} takes its options from the line above.`)}\n`);
317
+ return;
318
+ }
319
+ // The product itself writes SEALKEEP_DATA_DIR into the agent configs that
320
+ // `mcp install` generates, and the MCP server honours it — so a person who
321
+ // exports it and then runs the CLI reasonably expects the same vault. It used
322
+ // to be ignored here, which meant the CLI quietly worked on a different vault
323
+ // than the agent did. An explicit --data-dir still wins over the variable.
324
+ // Resolve the discovered default lazily and only after data-independent help
325
+ // and version commands. Besides avoiding unnecessary reads, this is the
326
+ // escape hatch when discovery finds two real vaults: an explicit --data-dir
327
+ // (or environment setting) must be able to disambiguate them.
328
+ const dataDirFlag = args.indexOf("--data-dir");
329
+ const explicitDataDir = dataDirFlag >= 0 ? args[dataDirFlag + 1] : undefined;
330
+ if (dataDirFlag >= 0 && (!explicitDataDir || explicitDataDir.startsWith("--"))) {
331
+ fail("invalid_argument", "--data-dir requires a path");
332
+ }
333
+ const dataDir = explicitDataDir ?? envVar("DATA_DIR") ?? defaultDataDir();
293
334
  /**
294
- * A bare `vaultline` on a machine with no vault is somebody who has just run
295
- * `npm i -g vaultline`. Setting this product up belongs in a window, not in a
335
+ * A bare `sealkeep` on a machine with no vault is somebody who has just run
336
+ * `npm i -g sealkeep`. Setting this product up belongs in a window, not in a
296
337
  * wall of commands, so that case opens one.
297
338
  *
298
339
  * Three deliberate limits. An existing vault still prints help, because
@@ -329,15 +370,28 @@ async function main() {
329
370
  const liveDetail = status.running && status.live.state === "never-started"
330
371
  ? "Installed. Waiting for its first check, which happens at login or within a minute."
331
372
  : status.live.detail;
373
+ const localUi = status.ui.available && status.ui.origin
374
+ ? `${mark.ok()} ${status.ui.origin}${status.ui.fallback ? dim(` · 7477 was busy, using ${status.ui.port}`) : ""}${status.ui.supervised ? dim(" · kept available without opening a window") : dim(" · interactive window, not service-supervised")}`
375
+ : status.ui.supervised
376
+ ? `${mark.warn()} retrying${status.ui.lastError ? dim(` · ${status.ui.lastError}`) : ""}`
377
+ : status.running
378
+ ? `${mark.warn()} not available yet ${dim("· the installed service will retry; check again")}`
379
+ : dim("off with Autopilot");
332
380
  print(`\n${BRAND} ${status.running ? green("autopilot on") : dim("autopilot off")}\n`);
333
381
  print(keyValue([
334
382
  ["Right now", `${status.running && status.live.state === "never-started" ? dim("waiting") : liveMark} ${dim(liveDetail)}`],
335
- ["Done so far", status.live.totals ? `${status.live.totals.archived} sealed ${dim("·")} ${status.live.totals.uploaded} uploaded ${dim("·")} ${status.live.totals.reclaimed} reclaimed${status.live.totals.failed ? ` ${dim("·")} ${mark.warn()} ${status.live.totals.failed} failed` : ""}` : dim("nothing yet")],
336
- ["Service", status.running ? `${status.service.kind} ${dim(`· ${shortPath(status.service.path, 52)}`)}` : dim("not installed")],
383
+ ["Done so far", status.live.totals ? `${status.live.totals.archived} sealed ${dim("·")} ${status.live.totals.indexed ?? 0} indexed ${dim("·")} ${status.live.totals.uploaded} uploaded ${dim("·")} ${status.live.totals.reclaimed} reclaimed${status.live.totals.failed ? ` ${dim("·")} ${mark.warn()} ${status.live.totals.failed} failed` : ""}` : dim("nothing yet")],
384
+ ["Service", status.running
385
+ ? `${status.service.kind} ${dim(`· ${status.service.persistence === "boot" ? "starts at boot and survives logout" : "starts at login"} · ${shortPath(status.service.path, 52)}`)}`
386
+ : dim("not installed")],
387
+ ["Local UI", localUi],
337
388
  ["Unlocks itself", status.phraseAvailable ? `${mark.ok()} yes` : `${mark.warn()} no — sessions will queue but stay unsealed`],
338
389
  ["Policy", `${status.retention.policy} ${dim(`· older than ${status.retention.olderThanDays}d · ${status.retention.graceDays}d grace`)}`],
339
- ["Bucket", status.hasRemoteTarget ? "configured" : dim("none — nothing will be reclaimed")],
340
- ["Queue", `${status.queue.ready} waiting ${dim("·")} ${status.queue.done} sealed`]
390
+ ["Reclaims", status.reclaimEnabled
391
+ ? `enabled ${dim("· only eligible originals after every safety check")}`
392
+ : dim("off on this machine · original sessions stay")],
393
+ ["Storage", status.hasRemoteTarget ? "configured" : dim("not connected yet · sealed archives stay on this machine")],
394
+ ["Queue", `${status.queue.ready} waiting ${dim("·")} ${status.queue.completed} sealed${status.queue.superseded ? ` ${dim(`· ${status.queue.superseded} obsolete snapshots skipped`)}` : ""}`]
341
395
  ]));
342
396
  print("");
343
397
  return;
@@ -348,15 +402,32 @@ async function main() {
348
402
  print(JSON.stringify(result, null, 2));
349
403
  return;
350
404
  }
405
+ if (result.service.installed) {
406
+ print(`\n ${mark.warn()} Autopilot is still running. Nothing was removed.`);
407
+ if (result.service.note)
408
+ print(` ${result.service.note}`);
409
+ print(` ${dim("Your phrase, hooks, policy, queue, and every archive were kept so the running service remains safe.")}\n`);
410
+ process.exitCode = 1;
411
+ return;
412
+ }
351
413
  print(`\n ${mark.ok()} Autopilot off. The service is removed${result.forgotPhrase ? " and this machine has forgotten your phrase" : ""}.`);
414
+ if (result.hooksRemoved.length)
415
+ print(` ${mark.ok()} Automatic agent hooks removed from ${result.hooksRemoved.join(" and ")}; unrelated hooks were kept.`);
416
+ if (result.service.note)
417
+ print(` ${mark.warn()} ${result.service.note}`);
352
418
  print(` ${dim("Every archive is still here. Nothing was deleted.")}\n`);
353
419
  return;
354
420
  }
355
421
  const dryRun = args.includes("--dry-run");
422
+ const explicitReclaim = args.includes("--reclaim");
423
+ const explicitNoReclaim = args.includes("--no-reclaim");
424
+ if (explicitReclaim && explicitNoReclaim) {
425
+ fail("invalid_argument", "Choose either --reclaim or --no-reclaim, not both");
426
+ }
356
427
  const result = await enableAutopilot(dataDir, {
357
428
  phrase: take(args, "--recovery-phrase"),
358
429
  remember: !args.includes("--no-remember"),
359
- reclaim: !args.includes("--no-reclaim"),
430
+ reclaim: explicitReclaim ? true : explicitNoReclaim ? false : undefined,
360
431
  olderThanDays: take(args, "--older-than-days") ? Number(take(args, "--older-than-days")) : undefined,
361
432
  graceDays: take(args, "--grace-days") ? Number(take(args, "--grace-days")) : undefined,
362
433
  home: take(args, "--home"),
@@ -374,19 +445,43 @@ async function main() {
374
445
  print(heading(dryRun ? "What would happen" : "Autopilot is on"));
375
446
  print(keyValue([
376
447
  ["Watches", result.agents.length ? result.agents.join(" and ") : dim("no agents found yet")],
448
+ ["Agent memory", result.hooks.length
449
+ ? `${dryRun ? "would install for" : "installed for"} ${result.hooks.join(" and ")} ${dim("· automatic recall, no daily commands")}`
450
+ : dim("no supported agent hooks installed")],
377
451
  ["Found", result.found.sessions ? `${result.found.sessions} sessions ${dim(`· ${bytes(result.found.bytes)}`)}` : dim("nothing yet")],
378
452
  ["Seals", result.remembered
379
453
  ? `automatically ${dim(`· phrase kept in ${result.remembered.backend}`)}`
380
454
  : dryRun && !args.includes("--no-remember")
381
455
  ? `automatically ${dim("· the phrase would be kept in this machine's keystore")}`
382
456
  : `${mark.warn()} needs SEALKEEP_RECOVERY_PHRASE`],
383
- ["Reclaims", result.reclaimEnabled ? `after ${result.retention.olderThanDays}d ${dim(`+ ${result.retention.graceDays}d grace, once a remote copy is verified`)}` : dim("never — sync only")],
384
- ["Starts", result.service.installed ? `at login ${dim(`· ${result.service.kind}`)}` : dryRun ? dim(`${result.service.kind} service at ${shortPath(result.service.path, 44)}`) : `${mark.warn()} not installed`]
457
+ /**
458
+ * Sealing was the only stage this screen described, so someone turned
459
+ * autopilot on expecting "indexed, searchable, in the cloud" and got a
460
+ * pile of sealed files that no agent could find and no second machine
461
+ * held. What it DOES is what it should say — encryption included, since
462
+ * "will this leave my machine readable?" is the first question anyone has.
463
+ */
464
+ ["Encrypts", `on this machine, always ${dim("· nothing leaves readable, with or without a cloud copy")}`],
465
+ ["Indexes", `each sealed session ${dim("· searchable by your agents through the MCP server, before anything is reclaimed")}`],
466
+ ["Uploads", result.hasRemoteTarget
467
+ ? `automatically to configured storage ${dim("· each copy is verified after upload")}`
468
+ : dim("automatically once storage is connected · sealed archives stay here until then")],
469
+ ["Reclaims", result.reclaimEnabled
470
+ ? `after ${result.retention.olderThanDays}d ${dim(`+ ${result.retention.graceDays}d grace and every final safety check`)}`
471
+ : dim("off · original sessions stay on this machine; opt in with sealkeep autopilot --reclaim")],
472
+ ["Starts", result.service.installed
473
+ ? result.service.persistence === "boot"
474
+ ? `at boot and stays active after logout ${dim(`· ${result.service.kind}`)}`
475
+ : `at login ${dim(`· ${result.service.kind}`)}`
476
+ : dryRun ? dim(`${result.service.kind} service at ${shortPath(result.service.path, 44)}`) : `${mark.warn()} not installed`],
477
+ ["Local UI", result.service.installed
478
+ ? `${uiProbeOrigin(DEFAULT_UI_PORT)} ${dim("· kept available by Autopilot; no login popup")}`
479
+ : dryRun ? dim(`${uiProbeOrigin(DEFAULT_UI_PORT)} · would start with Autopilot`) : dim("not started")]
385
480
  ]));
386
481
  for (const note of result.notes)
387
482
  print(`\n ${mark.warn()} ${note}`);
388
483
  if (!dryRun) {
389
- print(`\n${vaultline()}`);
484
+ print(`\n${divider()}`);
390
485
  print(`${dim(" From here it runs on its own. Check on it with")} ${cmd("sealkeep autopilot status")}${dim(", or stop it with")} ${cmd("sealkeep autopilot off")}${dim(".")}\n`);
391
486
  }
392
487
  else {
@@ -395,10 +490,11 @@ async function main() {
395
490
  return;
396
491
  }
397
492
  if (command === "quickstart") {
398
- const result = await setupVault(dataDir, take(args, "--recovery-phrase"));
399
- const installs = await detectAgents(take(args, "--home"));
493
+ const home = take(args, "--home");
494
+ const result = await setupVault(dataDir, take(args, "--recovery-phrase"), { home });
495
+ const installs = await detectAgents(home);
400
496
  const detected = installs.filter((install) => install.detected);
401
- const found = (await Promise.all(detected.map((install) => findTranscripts(install.agent, take(args, "--home"), 500)))).flat();
497
+ const found = (await Promise.all(detected.map((install) => findTranscripts(install.agent, home, 500)))).flat();
402
498
  const totalBytes = found.reduce((total, candidate) => total + candidate.bytes, 0);
403
499
  // What leaves this machine is decided here, before anything is queued — see sync-rules.ts.
404
500
  const { rules, source } = await resolveSyncRules(dataDir);
@@ -433,10 +529,10 @@ async function main() {
433
529
  print(heading("Next"));
434
530
  print(steps([
435
531
  `Encrypt what is queued: ${cmd(`SEALKEEP_RECOVERY_PHRASE="…" sealkeep queue run`)}`,
436
- `Archive automatically: ${cmd("vaultline agents hook-config codex")} ${dim("(review, then merge)")}`,
532
+ `Archive automatically: ${cmd("sealkeep agents hook-config codex")} ${dim("(review, then merge)")}`,
437
533
  `See it: ${cmd("sealkeep desktop")}`
438
534
  ]));
439
- print(`\n${vaultline()}\n${dim(" Everything below that line is ciphertext. Nothing above it leaves this machine.")}\n`);
535
+ print(`\n${divider()}\n${dim(" Everything below that line is ciphertext. Nothing above it leaves this machine.")}\n`);
440
536
  return;
441
537
  }
442
538
  if (command === "start") {
@@ -486,7 +582,7 @@ async function main() {
486
582
  mode: "free",
487
583
  provider: take(args, "--provider", "s3"),
488
584
  bucket: take(args, "--bucket") ?? fail("invalid_argument", "--bucket is required for the free tier"),
489
- prefix: take(args, "--prefix", "vaultline"),
585
+ prefix: take(args, "--prefix", STORAGE_PREFIX_DEFAULT),
490
586
  region: take(args, "--region"), accountId: take(args, "--account-id"), project: take(args, "--project")
491
587
  });
492
588
  if (json) {
@@ -563,7 +659,7 @@ async function main() {
563
659
  const cloud = await import("./cloud.js");
564
660
  const [action] = positionals(args);
565
661
  if (action === "login") {
566
- const email = take(args, "--email") ?? fail("invalid_argument", "--email is required");
662
+ const email = take(args, "--email") ?? fail("invalid_argument", "Usage: sealkeep cloud login --email <you@example.com> --code <one-time code from your account panel>. A Google account can only sign in with a code.");
567
663
  // A one-time code from the panel is the only way in for an account that
568
664
  // signed up with Google, and it works for password accounts too.
569
665
  const code = take(args, "--code") ?? envVar("CLOUD_CODE");
@@ -605,18 +701,18 @@ async function main() {
605
701
  if (!archiveId)
606
702
  fail("invalid_argument", "Usage: sealkeep cloud push <archive-id>");
607
703
  const result = await cloud.pushArchive(dataDir, archiveId).catch(async (error) => {
608
- // cloud.ts's call() maps a 402 to a VaultlineError coded "forbidden"
609
- // whose message is just whatever the control plane said — today a bare
610
- // "Quota exceeded", no numbers, no next step. The message is the only
611
- // signal available to tell that apart from an unrelated 403 without
612
- // editing cloud.ts, so it is what we sniff.
613
- if (isVaultlineError(error) && error.code === "forbidden" && /quota/i.test(error.message)) {
704
+ // A 402 now arrives coded `payment_required`, so this no longer has to
705
+ // guess whether a "forbidden" was really a billing answer. The quota
706
+ // test still stands, because 402 covers two different situations now —
707
+ // out of room, and not on a plan that includes this at all — and only
708
+ // the first should be rewritten into an accounting message.
709
+ if (isSealkeepError(error) && error.code === "payment_required" && /quota/i.test(error.message)) {
614
710
  const account = await cloud.accountStatus(dataDir).catch(() => null);
615
711
  if (account) {
616
712
  const { quotaExceededMessage } = await import("./packages.js");
617
713
  // The shared message is written for a screen. This is a terminal, so
618
714
  // it names the command that shows the packages.
619
- fail("forbidden", `${quotaExceededMessage(account)} Run \`sealkeep plans\` to see them.`);
715
+ fail("payment_required", `${quotaExceededMessage(account)} Run \`sealkeep plans\` to see them.`);
620
716
  }
621
717
  }
622
718
  throw error;
@@ -656,14 +752,41 @@ async function main() {
656
752
  if (!vaultRef)
657
753
  fail("invalid_argument", "Usage: sealkeep cloud pull <vault-ref> <destination>");
658
754
  const out = required(destination, "Destination is required");
659
- const { ciphertext, bytes: size } = await cloud.pullCiphertext(dataDir, vaultRef);
660
- await writeFile(out, ciphertext, { mode: 0o600 });
755
+ // A streamed archive is a folder of chunks, not one object, so pulling
756
+ // its reference used to 404. Raw bytes still mean raw bytes — the parts
757
+ // are written beside each other rather than silently concatenated into
758
+ // something no command can then open.
759
+ const parts = await cloud.managedArchiveParts(dataDir, vaultRef);
760
+ if (!parts.chunked) {
761
+ const { ciphertext, bytes: size } = await cloud.pullCiphertext(dataDir, vaultRef);
762
+ await writeFile(out, ciphertext, { mode: 0o600 });
763
+ if (json) {
764
+ print(JSON.stringify({ vaultRef, output: out, bytes: size, chunked: false }, null, 2));
765
+ return;
766
+ }
767
+ print(`\n ${mark.ok()} Pulled ${bold(bytes(size))} to ${out}`);
768
+ print(dim(` Open it with: sealkeep open ${vaultRef} <destination> --recovery-phrase …\n`));
769
+ return;
770
+ }
771
+ const { mkdir } = await import("node:fs/promises");
772
+ const { join: joinPath } = await import("node:path");
773
+ await mkdir(out, { recursive: true });
774
+ let total = 0;
775
+ const written = [];
776
+ for (const ref of parts.refs) {
777
+ const { ciphertext, bytes: size } = await cloud.pullCiphertext(dataDir, ref);
778
+ const name = ref.slice(vaultRef.length + 1);
779
+ await writeFile(joinPath(out, name), ciphertext, { mode: 0o600 });
780
+ written.push(name);
781
+ total += size;
782
+ }
661
783
  if (json) {
662
- print(JSON.stringify({ vaultRef, output: out, bytes: size }, null, 2));
784
+ print(JSON.stringify({ vaultRef, output: out, bytes: total, chunked: true, parts: written }, null, 2));
663
785
  return;
664
786
  }
665
- print(`\n ${mark.ok()} Pulled ${bold(bytes(size))} to ${out}`);
666
- print(dim(" Decrypt it with: sealkeep recover <id> <destination> --recovery-phrase …\n"));
787
+ print(`\n ${mark.ok()} Pulled ${bold(bytes(total))} as ${written.length} objects into ${out}`);
788
+ print(dim(` This one was streamed, so it is a folder of chunks.`));
789
+ print(dim(` Open it with: sealkeep open ${vaultRef} <destination> --recovery-phrase …\n`));
667
790
  return;
668
791
  }
669
792
  fail("invalid_argument", `Unknown cloud action: ${action}. Try login, logout, status, archives, push, or pull.`);
@@ -676,7 +799,7 @@ async function main() {
676
799
  if (password !== undefined && (password.length < 8 || password.length > 1024)) {
677
800
  fail("invalid_argument", "The vault password must be between 8 and 1024 characters.");
678
801
  }
679
- const result = await setupVault(dataDir, take(args, "--recovery-phrase"));
802
+ const result = await setupVault(dataDir, take(args, "--recovery-phrase"), { home: take(args, "--home") });
680
803
  // Keep the phrase in this machine's keystore, the way the wizard does by
681
804
  // default. Without it a vault made from the terminal looked finished and
682
805
  // was not: the window's Archive button answered "this machine has no
@@ -710,13 +833,42 @@ async function main() {
710
833
  }
711
834
  }
712
835
  }
836
+ // Match the browser first run: a signed-in paid setup creates the
837
+ // lost-every-device path while the one-time phrase is already in memory.
838
+ // No later `recovery codes` command or hidden configuration is required.
839
+ let recoveryCodes = [];
840
+ let recoveryKitWarning;
841
+ if (result.recoveryPhrase) {
842
+ try {
843
+ const cloud = await import("./cloud.js");
844
+ const account = await cloud.accountStatus(dataDir);
845
+ if (account.plan !== "free") {
846
+ const kit = await import("./recovery-codes.js").then(({ createRecoveryCodes }) => createRecoveryCodes(dataDir, result.recoveryPhrase));
847
+ recoveryCodes = kit.codes;
848
+ }
849
+ }
850
+ catch (error) {
851
+ // A local-only/free setup has no Cloud login and needs no hosted kit.
852
+ // Warn only when a signed-in/remote attempt actually failed.
853
+ if (!(isSealkeepError(error) && error.code === "unauthorized")) {
854
+ recoveryKitWarning = `The Recovery Kit could not be stored (${error instanceof Error ? error.message.split("\n")[0] : "network error"}). Open Settings when this machine is online to create it.`;
855
+ }
856
+ }
857
+ }
713
858
  if (json) {
714
- print(JSON.stringify({ ...result, remembered, ...(password ? { passwordLock } : {}) }, null, 2));
859
+ print(JSON.stringify({ ...result, remembered, recoveryCodes, ...(recoveryKitWarning ? { recoveryKitWarning } : {}), ...(password ? { passwordLock } : {}) }, null, 2));
715
860
  return;
716
861
  }
717
862
  print(`\n${BRAND}\n`);
718
863
  if (result.recoveryPhrase) {
719
864
  print(recoveryPhraseScreen(result.recoveryPhrase));
865
+ if (recoveryCodes.length) {
866
+ print(`\n ${bold("ONE-TIME BACKUP CODES")} ${dim("· shown once · each works once")}`);
867
+ recoveryCodes.forEach((code, index) => print(` ${dim(String(index + 1).padStart(2, "0"))} ${bold(code)}`));
868
+ print(`\n ${dim("Save these with your recovery phrase. A fresh signed-in machine can use either path.")}\n`);
869
+ }
870
+ else if (recoveryKitWarning)
871
+ print(`\n ${mark.warn()} ${recoveryKitWarning}\n`);
720
872
  await pauseUntilWrittenDown();
721
873
  print(remembered
722
874
  ? ` ${mark.ok()} This machine will unlock the vault on its own ${dim(`· phrase kept in ${remembered.backend}`)}`
@@ -755,6 +907,323 @@ async function main() {
755
907
  print(`\n${BRAND}\n`, recoveryPhraseScreen(phrase), "");
756
908
  return;
757
909
  }
910
+ /**
911
+ * The one command this product can be introduced by.
912
+ *
913
+ * Everything else here assumes you already believe the pitch. This assumes
914
+ * nothing: it reads your session folders, measures YOUR files with the real
915
+ * compressor, and prints what it would give back. It writes nothing, opens
916
+ * no socket and installs no service unless you pass --confirm.
917
+ *
918
+ * `--confirm` finishes the job rather than half of it. Archiving alone frees
919
+ * nothing — the reason a fresh install used to return zero bytes for thirty
920
+ * days is that retention counts from the archive date, which is the wrong
921
+ * clock for someone who ran a command called "squeeze" on a full disk. So
922
+ * this reclaims what it just sealed, immediately, and only what it can prove
923
+ * it sealed: the source has to still be byte-for-byte the file that was
924
+ * archived, or it is left alone. Sources go to the OS trash, never unlink —
925
+ * and because macOS does not return those bytes until the trash is emptied,
926
+ * this says so in plain words instead of reporting a number the disk will
927
+ * not agree with.
928
+ */
929
+ if (command === "squeeze") {
930
+ const home = take(args, "--home");
931
+ const confirm = args.includes("--confirm");
932
+ const requestedReclaim = take(args, "--reclaim");
933
+ if (requestedReclaim && requestedReclaim !== "trash" && requestedReclaim !== "delete") {
934
+ fail("invalid_argument", "--reclaim takes trash or delete");
935
+ }
936
+ // This older path sealed a local archive and then directly moved or
937
+ // unlinked native transcripts. It bypassed the verified remote/search
938
+ // gates, source journal, final activity/pin checks and Codex same-id
939
+ // pointer. Keep squeeze as a read-only estimator until it can delegate to
940
+ // the unified retention transaction; no flag can re-enable direct cleanup.
941
+ if (confirm || requestedReclaim) {
942
+ fail("invalid_argument", "Squeeze is measurement-only. To free disk safely, upload and index the archive, then use `sealkeep retention apply --confirm`; that path rechecks storage, search coverage, activity and pins and preserves Codex resume.");
943
+ }
944
+ /**
945
+ * Squeezing costs disk before it returns any, and on macOS the reclaimed
946
+ * source goes to ~/.Trash on the SAME volume — so nothing is freed until a
947
+ * person empties it. Run on a nearly-full disk, this used to seal until the
948
+ * write failed. `--limit` bounds one pass; the floor stops before the disk
949
+ * does, which is the state the people who need this command are actually in.
950
+ */
951
+ const limitArg = take(args, "--limit");
952
+ const limitBytes = limitArg ? Number(limitArg) * 1024 ** 3 : Infinity;
953
+ if (limitArg && !(limitBytes > 0))
954
+ fail("invalid_argument", "--limit takes a number of gigabytes, e.g. --limit 8");
955
+ /**
956
+ * How little room this may leave. It has to be small: the whole promise is
957
+ * that this works on a disk that is already out of space, so a floor of
958
+ * several hundred megabytes would refuse the exact machine it is for.
959
+ */
960
+ const FLOOR_BYTES = 64 * 1024 ** 2;
961
+ /**
962
+ * On macOS a reclaimed source is renamed into ~/.Trash — the SAME volume —
963
+ * so trashing frees nothing until a person empties it. That is fine when
964
+ * there is room to spare and useless when there is not, which is why the
965
+ * disk-pressure path has to be able to unlink instead. Trash stays the
966
+ * default; deleting is something you ask for.
967
+ */
968
+ const { freeBytes: freeOnDisk } = await import("./disk.js");
969
+ let stopped = null;
970
+ const installs = await detectAgents(home);
971
+ const detected = installs.filter((install) => install.detected);
972
+ if (!detected.length) {
973
+ print(`\n${BRAND}\n\n ${dim("No Claude Code or Codex sessions found on this machine.")}\n`);
974
+ return;
975
+ }
976
+ const found = (await Promise.all(detected.map((install) => findTranscripts(install.agent, home, 5000)))).flat();
977
+ const total = found.reduce((sum, item) => sum + item.bytes, 0);
978
+ // A ratio measured on THIS machine's biggest session, not a number from a
979
+ // landing page — and measured the way the real run will do it. The sample
980
+ // is exactly one chunk, compressed alone, because that is what --confirm
981
+ // does to every chunk. Sampling a 128 MB whole-file window instead flatters
982
+ // the result: it finds duplicates across a span the real run never sees,
983
+ // and the preview would promise a number the operation cannot deliver.
984
+ const SQUEEZE_CHUNK = 64 * 1024 ** 2;
985
+ const biggest = [...found].sort((a, b) => b.bytes - a.bytes)[0];
986
+ let ratio = 0;
987
+ if (biggest) {
988
+ const { open } = await import("node:fs/promises");
989
+ const { squeezeSync } = await import("../packages/sealkeep-crypto/src/index.js");
990
+ const handle = await open(biggest.path, "r");
991
+ try {
992
+ const want = Math.min(biggest.bytes, SQUEEZE_CHUNK);
993
+ const buffer = Buffer.allocUnsafe(want);
994
+ const { bytesRead } = await handle.read(buffer, 0, want, 0);
995
+ const sample = buffer.subarray(0, bytesRead);
996
+ if (bytesRead > 0)
997
+ ratio = bytesRead / squeezeSync(sample).length;
998
+ }
999
+ finally {
1000
+ await handle.close();
1001
+ }
1002
+ }
1003
+ const keeps = ratio > 0 ? Math.round(total / ratio) : 0;
1004
+ if (json) {
1005
+ print(JSON.stringify({ sessions: found.length, totalBytes: total, measuredRatio: Number(ratio.toFixed(1)), wouldKeepBytes: keeps, wouldFreeBytes: total - keeps, confirmed: confirm }, null, 2));
1006
+ return;
1007
+ }
1008
+ print(`\n${BRAND}\n`);
1009
+ print(heading("What is on this disk"));
1010
+ for (const install of detected) {
1011
+ const mine = found.filter((item) => item.agent === install.agent);
1012
+ print(` ${bold(install.agent.padEnd(12))} ${String(mine.length).padStart(5)} sessions ${bytes(mine.reduce((s, i) => s + i.bytes, 0))}`);
1013
+ }
1014
+ print(` ${dim("─".repeat(44))}`);
1015
+ print(` ${bold("total".padEnd(12))} ${String(found.length).padStart(5)} sessions ${bold(bytes(total))}`);
1016
+ if (!ratio) {
1017
+ print(`\n ${dim("Nothing measurable here yet.")}\n`);
1018
+ return;
1019
+ }
1020
+ print(`\n${heading("What squeezing them gives back")}`);
1021
+ print(` Measured on your largest session: ${bold(`${ratio.toFixed(0)}x`)}`);
1022
+ print(` Kept, sealed and restorable: ${bytes(keeps)}`);
1023
+ print(` Potential local allocation reduction: ${bold(bytes(total - keeps))}`);
1024
+ print(` ${dim("This command only measures. Safe cleanup requires a verified remote copy and searchable index.")}`);
1025
+ if (!confirm) {
1026
+ print(`\n ${dim("Nothing has been touched. Nothing left this machine.")}`);
1027
+ print(steps([`Safely free eligible sources: ${cmd("sealkeep retention apply --confirm")}`]));
1028
+ return;
1029
+ }
1030
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
1031
+ const { stat: statFile } = await import("node:fs/promises");
1032
+ let freed = 0, sealed = 0, skipped = 0, unverified = 0;
1033
+ print(`\n${heading("Squeezing")}`);
1034
+ /**
1035
+ * SMALLEST FIRST, in passes.
1036
+ *
1037
+ * Biggest-first is the intuitive order and it is exactly wrong here: on a
1038
+ * full disk the large sessions are the ones there is no room to seal, so
1039
+ * the run stalls on its first candidate having freed nothing. A small
1040
+ * session costs almost nothing to seal and hands back its own size, so
1041
+ * starting small EARNS the room to reach the big ones. Anything that does
1042
+ * not fit yet is deferred, and each pass retries the deferred list against
1043
+ * the space the previous pass returned. It stops when a whole pass moves
1044
+ * nothing, which is the real "cannot proceed".
1045
+ */
1046
+ /**
1047
+ * A session an agent still has OPEN is not yours to reclaim.
1048
+ *
1049
+ * The size check below catches a session being appended to between sealing
1050
+ * and reclaiming, but an agent sitting idle at a prompt holds its transcript
1051
+ * open without changing a byte — so it passed that check and was deleted out
1052
+ * from under a running Codex. The bytes survived in the vault and
1053
+ * `recover --native` put them back, but resume would have found nothing.
1054
+ * One lsof over the transcript roots is cheap and closes it properly.
1055
+ */
1056
+ const openNow = new Set();
1057
+ const liveIds = new Set();
1058
+ try {
1059
+ const { execFile } = await import("node:child_process");
1060
+ const { promisify } = await import("node:util");
1061
+ const run = promisify(execFile);
1062
+ // Held open at this instant.
1063
+ const roots = [...new Set(found.map((item) => item.path.split("/").slice(0, -4).join("/")))].filter(Boolean);
1064
+ for (const root of roots) {
1065
+ const { stdout } = await run("lsof", ["-F", "n", "+D", root], { maxBuffer: 32 * 1024 * 1024 }).catch(() => ({ stdout: "" }));
1066
+ for (const line of stdout.split("\n"))
1067
+ if (line.startsWith("n/"))
1068
+ openNow.add(line.slice(1));
1069
+ }
1070
+ /**
1071
+ * And — the one that actually matters — being RESUMED by a live process.
1072
+ *
1073
+ * lsof alone is a point-in-time answer to a question that is not:
1074
+ * an agent waiting at a prompt has its transcript CLOSED between turns,
1075
+ * so it looks idle and reclaimable right up until it writes again. Two
1076
+ * live Codex sessions were deleted that way. The session id is on the
1077
+ * process command line for as long as the agent is running, which is the
1078
+ * honest signal.
1079
+ */
1080
+ const { stdout: procs } = await run("ps", ["-Ao", "command="], { maxBuffer: 32 * 1024 * 1024 }).catch(() => ({ stdout: "" }));
1081
+ for (const line of procs.split("\n")) {
1082
+ // The AGENT's own process, not any command that merely mentions one.
1083
+ // Matching the whole line scooped ids out of unrelated processes —
1084
+ // including this tool's, whose working paths contain "claude" — and
1085
+ // every session then looked live, so squeeze skipped work it should
1086
+ // have done. The executable name is the claim; the rest is noise.
1087
+ // An agent usually runs as `node /…/bin/codex resume <id>`, so argv0 is
1088
+ // the interpreter and the agent is the SCRIPT. Check both, and nothing
1089
+ // further along — the arguments are where unrelated paths live.
1090
+ const head = line.trim().split(/\s+/).slice(0, 2);
1091
+ const names = head.map((token) => token.split("/").pop()?.toLowerCase() ?? "");
1092
+ if (!names.some((name) => name === "codex" || name === "claude"))
1093
+ continue;
1094
+ for (const id of line.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) ?? [])
1095
+ liveIds.add(id.toLowerCase());
1096
+ }
1097
+ }
1098
+ catch { /* no lsof or ps: the size check below still catches active writers */ }
1099
+ /**
1100
+ * A standing "never take this one" list, read from `<dataDir>/never-reclaim`.
1101
+ *
1102
+ * Detecting a live agent only protects a session while its process is UP.
1103
+ * Quit the agent and the same transcript becomes reclaimable, which is no
1104
+ * use to someone who wants a particular session left alone permanently.
1105
+ * One id or path fragment per line; `#` comments ignored.
1106
+ */
1107
+ const { join: joinPath } = await import("node:path");
1108
+ const neverReclaim = await readFile(joinPath(dataDir, "never-reclaim"), "utf8")
1109
+ .then((text) => text.split("\n").map((line) => line.split("#")[0].trim().toLowerCase()).filter(Boolean))
1110
+ .catch(() => []);
1111
+ const isLive = (path) => {
1112
+ const lower = path.toLowerCase();
1113
+ return openNow.has(path)
1114
+ || [...liveIds].some((id) => lower.includes(id))
1115
+ || neverReclaim.some((mark) => lower.includes(mark));
1116
+ };
1117
+ let heldOpen = 0;
1118
+ let pending = [...found].sort((a, b) => a.bytes - b.bytes);
1119
+ let pass = 0;
1120
+ while (pending.length > 0 && pass < 24) {
1121
+ pass += 1;
1122
+ const deferred = [];
1123
+ let movedThisPass = false;
1124
+ for (const candidate of pending) {
1125
+ if (freed >= limitBytes) {
1126
+ stopped = "limit";
1127
+ break;
1128
+ }
1129
+ const roomLeft = (await freeOnDisk(dataDir)) ?? Number.POSITIVE_INFINITY;
1130
+ // What this one costs before it gives anything back: its archive, with
1131
+ // room to be wrong about the ratio, plus a working margin.
1132
+ const willCost = Math.ceil(candidate.bytes / Math.max(ratio, 1)) * 2 + 32 * 1024 ** 2;
1133
+ if (isLive(candidate.path)) {
1134
+ heldOpen += 1;
1135
+ continue;
1136
+ }
1137
+ if (roomLeft - willCost < FLOOR_BYTES) {
1138
+ deferred.push(candidate);
1139
+ continue;
1140
+ }
1141
+ try {
1142
+ // Chunked, not whole-file. Whole-file zstd compresses ~58x against ~20x
1143
+ // here — but it also destroys ranged reads, and a ranged read is how
1144
+ // search quotes a line without decompressing a gigabyte. Squeezing a
1145
+ // disk should not cost the ability to find what was on it, so this
1146
+ // takes the smaller number and keeps the archives searchable. 64 MB
1147
+ // windows are wide enough to catch the repeated screenshots that make
1148
+ // these files large, and bounded enough not to blow up memory.
1149
+ const record = await archiveFile(dataDir, candidate.path, phrase, candidate.agent, { indexInline: true, project: candidate.project ?? undefined, projectKey: candidate.projectKey ?? undefined, compression: "zstd-chunk", chunkBytes: 64 * 1024 * 1024 });
1150
+ sealed += 1;
1151
+ // Only reclaim what is provably still the file that was just sealed —
1152
+ // a session the agent appended to since has bytes in no archive.
1153
+ const now = await statFile(candidate.path).catch(() => null);
1154
+ if (!now || !isV2(record) || now.size !== record.source.bytes) {
1155
+ skipped += 1;
1156
+ continue;
1157
+ }
1158
+ // READ IT BACK BEFORE TAKING THE ORIGINAL.
1159
+ //
1160
+ // Sealing checks hashes of bytes it computed on the way out; it never
1161
+ // opens what landed on disk. Trusting that and trashing the source is a
1162
+ // lower bar than this product sets for itself everywhere else —
1163
+ // `reclaimOne` refuses outright without a verified copy, and says in
1164
+ // its own docstring that such checks are what "protect against actual
1165
+ // data loss". Squeeze is local by design and has no remote copy to
1166
+ // verify against, so it must verify locally instead: decrypt the
1167
+ // archive and require the plaintext to hash to what the source hashed.
1168
+ // Anything less is asking a brand-new codec to be right on the first
1169
+ // try, with the only copy of someone's work as the stake.
1170
+ const { verifyArchiveReadsBack } = await import("./restore.js");
1171
+ const proof = await verifyArchiveReadsBack(dataDir, record.id, phrase, record.source.sha256);
1172
+ if (!proof) {
1173
+ unverified += 1;
1174
+ continue;
1175
+ }
1176
+ // Defense in depth: the command-level guard above makes this branch
1177
+ // unreachable, and this inner boundary also refuses cleanup. Native
1178
+ // sources may only leave through retention's durable transaction.
1179
+ throw new Error("squeeze source cleanup is disabled; use retention apply");
1180
+ }
1181
+ catch {
1182
+ skipped += 1;
1183
+ }
1184
+ }
1185
+ if (stopped)
1186
+ break;
1187
+ pending = deferred;
1188
+ if (!movedThisPass) {
1189
+ stopped = "disk";
1190
+ break;
1191
+ }
1192
+ }
1193
+ const stillTooBig = pending.length;
1194
+ print(` ${mark.ok()} Sealed ${bold(String(sealed))} sessions · no native sources reclaimed`);
1195
+ if (stillTooBig && stopped !== "limit")
1196
+ print(` ${dim(`${stillTooBig} still too large for the room available — free more, then run this again.`)}`);
1197
+ if (heldOpen)
1198
+ print(` ${dim(`${heldOpen} left alone — a running agent, or listed in never-reclaim.`)}`);
1199
+ if (skipped)
1200
+ print(` ${dim(`${skipped} left alone — still being written, or already archived.`)}`);
1201
+ if (unverified)
1202
+ print(` ${amber(`${unverified} sealed but NOT reclaimed — the archive did not read back. Your originals are untouched.`)}`);
1203
+ if (stopped === "limit")
1204
+ print(` ${dim(`Stopped at your --limit of ${limitArg} GB. Run it again for the rest.`)}`);
1205
+ if (stopped === "disk")
1206
+ print(` ${amber(`Stopped with ${bytes(FLOOR_BYTES)} free — empty the trash to reclaim what was just sealed, then run this again.`)}`);
1207
+ /**
1208
+ * Sealing is one stage of three. This command reclaims a disk and then says
1209
+ * nothing about the archives being neither searchable nor off this machine,
1210
+ * which is how someone ends up with 131 GB "preserved" that no agent can
1211
+ * find and no second machine holds. Name what is left.
1212
+ */
1213
+ if (sealed > 0) {
1214
+ const { indexCoverage } = await import("./search.js").catch(() => ({ indexCoverage: null }));
1215
+ const cover = indexCoverage ? await indexCoverage(dataDir).catch(() => null) : null;
1216
+ const unindexed = cover ? cover.total - cover.indexed : sealed;
1217
+ if (unindexed > 0) {
1218
+ print(`\n ${amber(`${unindexed} archives are sealed but NOT searchable yet.`)}`);
1219
+ print(steps([`Make them searchable: ${cmd("sealkeep index build")}`, `Put them somewhere else too: ${cmd("sealkeep upload --all")}`]));
1220
+ }
1221
+ }
1222
+ print(`\n ${bold("Every native source is still on this disk.")}`);
1223
+ print(` ${dim("Use retention after upload and indexing to reclaim an eligible source safely:")}`);
1224
+ print(steps([`Put one back: ${cmd("sealkeep recover <session-file>")}`]));
1225
+ return;
1226
+ }
758
1227
  if (command === "status") {
759
1228
  const status = { ...(await vaultStatus(dataDir)), queue: await new ArchiveQueue(dataDir).stats() };
760
1229
  if (json) {
@@ -776,9 +1245,9 @@ async function main() {
776
1245
  const rows = [
777
1246
  ["Background", live.state === "running" ? `${green("running")} ${dim(live.detail.replace(/^Running\. /, ""))}` : live.state === "late" ? `${amber("late")} ${dim(live.detail)}` : dim(live.detail)],
778
1247
  ["Archives", `${bold(String(status.archiveCount))} ${dim(`· ${bytes(status.archivedBytes)} of history preserved`)}`],
779
- ["Queue", `${status.queue.ready} ready ${dim("·")} ${status.queue.leased} in flight ${dim("·")} ${status.queue.done} archived${status.queue.failed ? ` ${dim("·")} ${mark.fail()} ${status.queue.failed} failed` : ""}`],
1248
+ ["Queue", `${status.queue.ready} ready ${dim("·")} ${status.queue.leased} in flight ${dim("·")} ${status.queue.completed} archived${status.queue.superseded ? ` ${dim("·")} ${status.queue.superseded} obsolete snapshots skipped` : ""}${status.queue.failed ? ` ${dim("·")} ${mark.fail()} ${status.queue.failed} failed` : ""}`],
780
1249
  ["Remote", status.remoteStorage
781
- ? `${status.remoteStorage.provider}://${status.remoteStorage.bucket}/${status.remoteStorage.prefix}`
1250
+ ? `${providerLabel(status.remoteStorage.provider)} ${dim(`· ${status.remoteStorage.bucket}/${status.remoteStorage.prefix}`)}`
782
1251
  // A managed machine has no bucket of its own. Saying "none configured"
783
1252
  // to someone whose archives are in our storage is the same falsehood
784
1253
  // doctor was telling.
@@ -820,6 +1289,13 @@ async function main() {
820
1289
  // to pass the flag. Demanding the flag unconditionally — which this line
821
1290
  // used to do — made `archive` the one command a normal setup could not run.
822
1291
  const phraseForSeal = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
1292
+ // The agent is knowable from where the file lives: a Claude or Codex
1293
+ // transcript archived by hand gets the same agent — and therefore the same
1294
+ // project derivation — the watcher would have given it. Only a
1295
+ // project-tagged session is ever recalled automatically; "custom", which
1296
+ // used to label every hand-sealed file, left them invisible to that lane.
1297
+ const { agentForTranscriptPath, projectIdentityOfRecord } = await import("./adapters.js");
1298
+ const agentName = take(args, "--agent") ?? agentForTranscriptPath(source) ?? "custom";
823
1299
  if (args.includes("--stream")) {
824
1300
  // Chunk → encrypt → send: the ciphertext never touches this disk. Needs
825
1301
  // a configured own bucket; the seal is verified in the provider's own
@@ -845,19 +1321,10 @@ async function main() {
845
1321
  // bucket self-recovering. --single-object keeps the old multipart path.
846
1322
  if (!args.includes("--single-object")) {
847
1323
  const { sealToChunkFolder } = await import("./chunk-store.js");
848
- const agentName = take(args, "--agent", "custom") ?? "custom";
849
1324
  const explicitTarget = take(args, "--target");
850
- const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
851
- const project = await (async () => {
852
- try {
853
- if (agentName === "claude")
854
- return claudeProjectFromPath(source).project;
855
- if (agentName === "codex")
856
- return (await codexProjectFromRollout(source, new Date().toISOString())).project;
857
- }
858
- catch { /* no project is a folder called no-project, not an error */ }
859
- return null;
860
- })();
1325
+ const identity = await projectIdentityOfRecord({ source: { path: source, agent: agentName }, createdAt: new Date().toISOString() })
1326
+ .catch(() => ({ project: null, projectKey: null, projectPath: null }));
1327
+ const { project, projectKey } = identity;
861
1328
  // Multi-target routing happens up here so a project pinned to Google
862
1329
  // Drive takes the single-object road (Drive holds whole objects), and
863
1330
  // everything else streams chunk folders to its routed target.
@@ -867,9 +1334,9 @@ async function main() {
867
1334
  if (routed.provider === "gdrive") {
868
1335
  const { archiveFile } = await import("./vault.js");
869
1336
  const { uploadArchive } = await import("./upload.js");
870
- const record = await archiveFile(dataDir, source, phraseForSeal, agentName, { project });
1337
+ const record = await archiveFile(dataDir, source, phraseForSeal, agentName, { project, projectKey, indexInline: true });
871
1338
  const uploaded = await uploadArchive(dataDir, record.id, {
872
- remoteStorage: { provider: "gdrive", bucket: routed.bucket ?? "gdrive", prefix: routed.prefix ?? "vaultline", region: routed.region },
1339
+ remoteStorage: { provider: "gdrive", bucket: routed.bucket ?? "gdrive", prefix: routed.prefix ?? STORAGE_PREFIX_DEFAULT, region: routed.region },
873
1340
  targetId: routed.id
874
1341
  });
875
1342
  if (json) {
@@ -879,30 +1346,43 @@ async function main() {
879
1346
  print(` ${mark.ok()} Archived ${bold(bytes(record.source.bytes))} to Google Drive ${dim(`(target "${routed.id}", single object)`)}`);
880
1347
  return;
881
1348
  }
882
- const outcome = await sealToChunkFolder(dataDir, source, phraseForSeal, agentName, { project, resume: !fresh, targetId: routed.id });
1349
+ const outcome = await sealToChunkFolder(dataDir, source, phraseForSeal, agentName, { project, projectKey, resume: !fresh, targetId: routed.id });
883
1350
  if (json) {
884
1351
  print(JSON.stringify({ id: outcome.record.id, bytes: outcome.record.source.bytes, storedBytes: outcome.storedBytes, folder: outcome.folder, chunks: outcome.chunkCount, reusedChunks: outcome.reusedChunks, indexed: outcome.indexed, streamed: true }, null, 2));
885
1352
  return;
886
1353
  }
887
- print(` ${mark.ok()} Archived ${bold(bytes(outcome.record.source.bytes))} into ${outcome.record.remote?.provider}://…/${outcome.folder.split("/").slice(-3).join("/")}/ ${dim(`(${outcome.chunkCount} chunks)`)}`);
1354
+ print(` ${mark.ok()} Archived ${bold(bytes(outcome.record.source.bytes))} into ${providerLabel(outcome.record.remote?.provider ?? "")} …/${outcome.folder.split("/").slice(-3).join("/")}/ ${dim(`(${outcome.chunkCount} chunks)`)}`);
888
1355
  if (outcome.reusedChunks > 0)
889
1356
  print(` ${dim(`↻ resumed: ${outcome.reusedChunks} chunks were already in the bucket, verified instead of re-sent`)}`);
890
1357
  print(` ${dim(`${bytes(outcome.storedBytes)} sealed and verified; this disk held at most ${bytes(outcome.heldAtMostBytes)} of it at any moment`)}`);
891
1358
  return;
892
1359
  }
893
- const outcome = await sealArchiveToCloud(dataDir, source, phraseForSeal, take(args, "--agent", "custom") ?? "custom", { resume: !fresh });
1360
+ const streamAgent = agentName;
1361
+ const streamIdentity = await projectIdentityOfRecord({ source: { path: source, agent: streamAgent }, createdAt: new Date().toISOString() })
1362
+ .catch(() => ({ project: null, projectKey: null, projectPath: null }));
1363
+ const outcome = await sealArchiveToCloud(dataDir, source, phraseForSeal, streamAgent, { resume: !fresh, project: streamIdentity.project, projectKey: streamIdentity.projectKey });
894
1364
  if (json) {
895
1365
  print(JSON.stringify({ id: outcome.record.id, bytes: outcome.record.source.bytes, storedBytes: outcome.storedBytes, objectKey: outcome.objectKey, streamed: true }, null, 2));
896
1366
  return;
897
1367
  }
898
- print(` ${mark.ok()} Archived ${bold(bytes(outcome.record.source.bytes))} straight to ${outcome.record.remote?.provider} ${dim(`→ ${outcome.record.id}`)}`);
1368
+ print(` ${mark.ok()} Archived ${bold(bytes(outcome.record.source.bytes))} straight to ${providerLabel(outcome.record.remote?.provider ?? "")} ${dim(`→ ${outcome.record.id}`)}`);
899
1369
  print(` ${dim(`${bytes(outcome.storedBytes)} sealed and verified in the bucket; this disk held at most ${bytes(outcome.heldAtMostBytes)} of it at any moment`)}`);
900
1370
  return;
901
1371
  }
902
1372
  // A project named here decides which members are wrapped in, so an
903
1373
  // archive shared with a colleague is the one they were actually given.
904
- const localProject = take(args, "--project") ?? null;
905
- const record = await archiveFile(dataDir, source, phraseForSeal, take(args, "--agent", "custom"), { project: localProject });
1374
+ // An explicit --project still wins; otherwise the agent's own derivation
1375
+ // (recorded cwd, git remote) tags the session the way the watcher would.
1376
+ const namedProject = take(args, "--project");
1377
+ const localIdentity = namedProject === undefined
1378
+ ? await projectIdentityOfRecord({ source: { path: source, agent: agentName }, createdAt: new Date().toISOString() })
1379
+ .catch(() => ({ project: null, projectKey: null, projectPath: null }))
1380
+ : { project: namedProject, projectKey: null, projectPath: null };
1381
+ const record = await archiveFile(dataDir, source, phraseForSeal, agentName, {
1382
+ indexInline: true,
1383
+ project: localIdentity.project ?? null,
1384
+ ...(localIdentity.projectKey ? { projectKey: localIdentity.projectKey } : {}),
1385
+ });
906
1386
  if (json) {
907
1387
  print(JSON.stringify({ id: record.id, bytes: record.source.bytes, objectPath: record.objectPath, deduplicated: record.deduplicated }, null, 2));
908
1388
  return;
@@ -910,6 +1390,20 @@ async function main() {
910
1390
  print(record.deduplicated
911
1391
  ? ` ${mark.ok()} Already archived, unchanged since ${relativeTime(record.createdAt)} ${dim(`(${record.id})`)}`
912
1392
  : ` ${mark.ok()} Archived ${bold(bytes(record.source.bytes))} ${dim(`→ ${record.id}`)}`);
1393
+ // "New seals index themselves" was only true of daemon seals; this command
1394
+ // and `queue run` left theirs unsearchable while doctor promised otherwise.
1395
+ // The build is incremental — only what was just sealed gets decrypted.
1396
+ if (!record.deduplicated) {
1397
+ try {
1398
+ const { buildContentIndex } = await import("./search.js");
1399
+ const built = await buildContentIndex(dataDir, phraseForSeal, { sync: false });
1400
+ if (built.indexedNow > 0)
1401
+ print(` ${dim("searchable — `sealkeep search --content` finds it")}`);
1402
+ }
1403
+ catch {
1404
+ print(` ${dim("not indexed yet — `sealkeep index build` adds it")}`);
1405
+ }
1406
+ }
913
1407
  return;
914
1408
  }
915
1409
  /**
@@ -968,10 +1462,49 @@ async function main() {
968
1462
  const limit = take(args, "--limit");
969
1463
  if (!json)
970
1464
  print(`\n${heading("Checking every archive is really there")}`);
971
- const checks = await cloud.verifyArchives(dataDir, {
972
- limit: limit ? Number(limit) : undefined,
973
- onProgress: json ? undefined : (check) => print(` ${check.ok ? mark.ok() : mark.warn()} ${check.vaultRef}${check.ok ? dim(` ${bytes(check.bytes)}`) : ` ${check.problem}`}`)
974
- });
1465
+ const cap = limit ? Number(limit) : undefined;
1466
+ const show = (check) => print(` ${check.ok ? mark.ok() : mark.warn()} ${check.vaultRef}${check.ok ? dim(` ${bytes(check.bytes)}`) : ` ${check.problem}`}`);
1467
+ let checks = [];
1468
+ let local = false;
1469
+ try {
1470
+ checks = await cloud.verifyArchives(dataDir, { limit: cap, onProgress: json ? undefined : show });
1471
+ }
1472
+ catch (error) {
1473
+ /**
1474
+ * A vault with no cloud copies is not a broken vault. This used to stop
1475
+ * at "Not signed in to Sealkeep Cloud" on a local-only vault — the one
1476
+ * case where the answer needs no network at all, because the ciphertext
1477
+ * is on this disk. Read it back and prove it instead of refusing.
1478
+ */
1479
+ if (!(isSealkeepError(error) && error.code === "unauthorized"))
1480
+ throw error;
1481
+ }
1482
+ /**
1483
+ * Local copies are checked WHETHER OR NOT there are cloud ones.
1484
+ *
1485
+ * Being signed in used to send this down the cloud path alone, so a machine
1486
+ * holding 1,035 local archives and no cloud copies was told "Nothing stored
1487
+ * yet." — the most alarming possible sentence to read just after reclaiming
1488
+ * the originals. Proving a local archive costs no disk now, so there is no
1489
+ * reason left to skip it.
1490
+ */
1491
+ const cloudCount = checks.length;
1492
+ {
1493
+ const { verifyArchiveReadsBack } = await import("./restore.js");
1494
+ const records = (await listArchives(dataDir)).filter((record) => record.version === 2);
1495
+ if (records.length)
1496
+ local = true;
1497
+ const phrase = records.length
1498
+ ? required(await unlock(dataDir, take(args, "--recovery-phrase")), "Reading a local archive back needs --recovery-phrase")
1499
+ : "";
1500
+ for (const record of (typeof cap === "number" ? records.slice(0, cap) : records)) {
1501
+ const ok = await verifyArchiveReadsBack(dataDir, record.id, phrase, record.source.sha256).catch(() => false);
1502
+ const check = { vaultRef: record.id.slice(0, 8), ok, bytes: record.source.bytes, ...(ok ? {} : { problem: "did not read back" }) };
1503
+ checks.push(check);
1504
+ if (!json)
1505
+ show(check);
1506
+ }
1507
+ }
975
1508
  const broken = checks.filter((check) => !check.ok);
976
1509
  if (json) {
977
1510
  print(JSON.stringify({ checked: checks.length, broken: broken.length, checks }, null, 2));
@@ -980,8 +1513,11 @@ async function main() {
980
1513
  print(dim(" Nothing stored yet.\n"));
981
1514
  else if (broken.length)
982
1515
  print(`\n ${mark.warn()} ${bold(String(broken.length))} of ${checks.length} could not be read back. Re-archive them.\n`);
983
- else
984
- print(`\n ${mark.ok()} All ${checks.length} archives fetched back and matched their recorded hash.\n`);
1516
+ else {
1517
+ const where = cloudCount && local ? `${cloudCount} fetched back, ${checks.length - cloudCount} read back from this disk`
1518
+ : local ? "read back from this disk" : "fetched back";
1519
+ print(`\n ${mark.ok()} All ${checks.length} archives ${where} and matched their recorded hash.\n`);
1520
+ }
985
1521
  if (broken.length)
986
1522
  process.exitCode = 1;
987
1523
  return;
@@ -1039,28 +1575,287 @@ async function main() {
1039
1575
  }
1040
1576
  fail("invalid_argument", "Usage: sealkeep space members [project]");
1041
1577
  }
1578
+ /**
1579
+ * The awareness tier git lacks: who is on what, right now. `team` reads the
1580
+ * feed; `now` speaks into it. Presence is facts — no gate, no approval —
1581
+ * and each event is one sealed line, so the feed costs bytes, not budget.
1582
+ */
1583
+ if (command === "team") {
1584
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "Reading the team feed needs the recovery phrase");
1585
+ const { listPresence, activeNow } = await import("./presence.js");
1586
+ const project = take(args, "--project");
1587
+ const events = await listPresence(dataDir, phrase, { project: project ?? undefined, limit: Number(take(args, "--limit", "25")) });
1588
+ const active = activeNow(events);
1589
+ if (json) {
1590
+ print(JSON.stringify({ active, events }, null, 2));
1591
+ return;
1592
+ }
1593
+ print(`\n${heading("Working right now")}`);
1594
+ if (!active.length)
1595
+ print(` ${dim("Nobody has an open `started` yet. In Team mode, normal Claude Code and Codex sessions publish automatically.")}`);
1596
+ const presenceAuthor = (event) => event.authorAccountId === undefined
1597
+ ? event.from
1598
+ : `${event.from} [account #${event.authorAccountId}]`;
1599
+ for (const event of active)
1600
+ print(` ${green("●")} ${bold(presenceAuthor(event))} ${dim("·")} ${event.project} ${dim("·")} ${event.line} ${dim(`· ${relativeTime(event.at)}`)}`);
1601
+ print(`\n${heading("Recent")}`);
1602
+ for (const event of events.slice(0, 12)) {
1603
+ const markKind = event.kind === "started" ? green("▶") : event.kind === "finished" ? dim("■") : dim("·");
1604
+ print(` ${markKind} ${presenceAuthor(event)} ${dim("·")} ${event.project} ${dim("·")} ${event.line} ${dim(`· ${relativeTime(event.at)}`)}`);
1605
+ }
1606
+ print("");
1607
+ return;
1608
+ }
1609
+ if (command === "now") {
1610
+ const line = positionals(args).join(" ");
1611
+ const project = take(args, "--project");
1612
+ const done = args.includes("--done");
1613
+ if (!project)
1614
+ fail("invalid_argument", "Usage: sealkeep now \"what you are doing\" --project <name> [--done]");
1615
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "Publishing presence needs the recovery phrase");
1616
+ const { publishPresence } = await import("./presence.js");
1617
+ const { event } = await publishPresence(dataDir, { project, kind: done ? "finished" : "started", line: line || (done ? "done" : "working") }, phrase);
1618
+ if (json) {
1619
+ print(JSON.stringify(event, null, 2));
1620
+ return;
1621
+ }
1622
+ print(` ${mark.ok()} ${done ? "Closed" : "Announced"}: ${bold(event.from)} ${done ? "finished" : "started"} on ${bold(project)} — “${event.line}”`);
1623
+ if (!done)
1624
+ print(` ${dim("Close it later with: sealkeep now --project " + project + " --done")}`);
1625
+ return;
1626
+ }
1627
+ /**
1628
+ * The Shared surface, in the terminal: projects with members, who can open
1629
+ * everything, and the live channels this machine follows — each with the
1630
+ * command that starts an agent from it. The dashboard's Shared view renders
1631
+ * this same listing over GET /v1/space.
1632
+ */
1633
+ if (command === "shared") {
1634
+ const { listSharedSpaces } = await import("./shared-spaces.js");
1635
+ const spaces = await listSharedSpaces(dataDir);
1636
+ if (json) {
1637
+ print(JSON.stringify(spaces, null, 2));
1638
+ return;
1639
+ }
1640
+ print(`\n${heading("Shared projects")}`);
1641
+ if (spaces.projects.length === 0)
1642
+ print(` ${dim("Nothing shared yet. `sealkeep recipients add --project <name>` lets a teammate into one project.")}`);
1643
+ for (const project of spaces.projects) {
1644
+ const last = project.lastSealedAt ? relativeTime(project.lastSealedAt) : "nothing sealed yet";
1645
+ print(`\n ${bold(project.name)} ${dim(`· ${project.archives} session${project.archives === 1 ? "" : "s"} · ${last}`)}`);
1646
+ for (const member of project.members)
1647
+ print(` ${green("●")} ${member.label} ${dim(member.id.slice(0, 12))}`);
1648
+ if (project.members.length === 0)
1649
+ print(` ${dim("no members yet — only this vault's own keys open it")}`);
1650
+ print(` ${hint("normal Claude Code and Codex sessions attach this project's shared context automatically")}`);
1651
+ }
1652
+ if (spaces.vaultWide.length) {
1653
+ print(`\n${heading("Members of everything")}`);
1654
+ for (const member of spaces.vaultWide)
1655
+ print(` ${green("●")} ${member.label} ${dim(member.id.slice(0, 12))}`);
1656
+ }
1657
+ if (spaces.channels.length) {
1658
+ print(`\n${heading("Live channels this machine follows")}`);
1659
+ for (const channel of spaces.channels) {
1660
+ print(` ${bold(channel.channel)} ${dim(`· mirror ${bytes(channel.bytes)} · updated ${relativeTime(channel.updatedAt)}`)}`);
1661
+ print(` ${hint("the followed mirror is attached automatically on this machine")}`);
1662
+ }
1663
+ }
1664
+ print("");
1665
+ return;
1666
+ }
1667
+ if (command === "context") {
1668
+ const { buildContextPack } = await import("./shared-spaces.js");
1669
+ const ref = required(positionals(args)[0], "Usage: sealkeep context <project|channel>");
1670
+ const pack = await buildContextPack(dataDir, ref);
1671
+ if (json) {
1672
+ print(JSON.stringify({ ref: pack.ref, kind: pack.kind, path: pack.path }, null, 2));
1673
+ return;
1674
+ }
1675
+ print(`\n ${mark.ok()} Context pack for ${bold(pack.ref)} ${dim(`(${pack.kind})`)} → ${shortPath(pack.path)}\n`);
1676
+ print(pack.markdown);
1677
+ return;
1678
+ }
1679
+ /**
1680
+ * The launcher: `sealkeep claude <ref>` / `sealkeep codex <ref>` starts the
1681
+ * agent with the shared memory attached — context pack written, one-line
1682
+ * pointer injected the way that agent accepts context, env set. `resume` /
1683
+ * `start` / `session` as a first word are accepted and ignored so the
1684
+ * spelling people reach for ("sealkeep codex resume team-x") just works.
1685
+ * `--print` (or a missing binary) shows the exact command instead of running
1686
+ * it, so this never half-starts an interactive tool.
1687
+ */
1688
+ if (command === "claude" || command === "codex") {
1689
+ const { buildLaunchPlan } = await import("./shared-spaces.js");
1690
+ // Everything after "--" belongs to the agent VERBATIM — the launcher's own
1691
+ // token scanning (resume-sugar, --print) touches only its half, so
1692
+ // `sealkeep claude myproj -- --print` hands --print through to claude.
1693
+ // The sugar strips at most ONE leading resume/start/session token, so a
1694
+ // project literally named "resume" still launches: `sealkeep claude resume
1695
+ // resume` (first token is sugar, second is the ref).
1696
+ const separator = args.indexOf("--");
1697
+ const mine = separator >= 0 ? args.slice(0, separator) : [...args];
1698
+ const passthrough = separator >= 0 ? args.slice(separator + 1) : [];
1699
+ if (mine[0] === "resume" || mine[0] === "start" || mine[0] === "session")
1700
+ mine.shift();
1701
+ const printOnly = mine.includes("--print");
1702
+ const filtered = mine.filter((token) => token !== "--print");
1703
+ const ref = required(filtered.shift(), `Usage: sealkeep ${command} <project|channel> [--print] [-- extra agent args]`);
1704
+ const plan = await buildLaunchPlan(dataDir, command, ref, [...filtered, ...passthrough]);
1705
+ const shellCommand = plan.argv.map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" ");
1706
+ print(` ${mark.ok()} Context pack ${dim(shortPath(plan.pack.path))} ${dim(`(${plan.pack.kind}: ${plan.pack.ref})`)}`);
1707
+ // Awareness, both directions — and strictly best-effort: a plane hiccup
1708
+ // must never keep an agent from starting.
1709
+ if (plan.pack.kind === "project") {
1710
+ try {
1711
+ const quietPhrase = await unlock(dataDir);
1712
+ if (quietPhrase) {
1713
+ const { listPresence, activeNow, publishPresence } = await import("./presence.js");
1714
+ const active = activeNow(await listPresence(dataDir, quietPhrase, { project: ref, limit: 20 }));
1715
+ const { bridgeSender } = await import("./bridge.js");
1716
+ const me = await bridgeSender(dataDir);
1717
+ for (const event of active.filter((e) => e.sender !== me.id)) {
1718
+ print(` ${mark.warn()} ${bold(event.from)} is already on ${bold(ref)} ${dim(`(${relativeTime(event.at)})`)}: “${event.line}” — coordinate before doubling the work.`);
1719
+ }
1720
+ await publishPresence(dataDir, { project: ref, kind: "started", line: `working with ${command}` }, quietPhrase);
1721
+ print(` ${dim(`Announced to the team feed: started on ${ref} · close with \`sealkeep now --project ${ref} --done\``)}`);
1722
+ }
1723
+ }
1724
+ catch { /* offline or passive setups just launch */ }
1725
+ }
1726
+ print(` ${dim("If sealkeep_* tools are missing inside the agent: sealkeep mcp install")}`);
1727
+ if (printOnly) {
1728
+ print(`\n ${cmd(shellCommand)}\n`);
1729
+ return;
1730
+ }
1731
+ const { spawn } = await import("node:child_process");
1732
+ const child = spawn(plan.argv[0], plan.argv.slice(1), { stdio: "inherit", env: { ...process.env, ...plan.env } });
1733
+ await new Promise((resolve) => {
1734
+ child.on("error", (error) => {
1735
+ if (error.code === "ENOENT") {
1736
+ print(`\n ${mark.warn()} ${bold(command)} is not on PATH here. Run it yourself:`);
1737
+ print(` ${cmd(shellCommand)}\n`);
1738
+ }
1739
+ else
1740
+ print(` ${mark.fail()} ${String(error)}`);
1741
+ process.exitCode = 1;
1742
+ resolve();
1743
+ });
1744
+ child.on("exit", (code) => { process.exitCode = code ?? 0; resolve(); });
1745
+ });
1746
+ return;
1747
+ }
1748
+ if (command === "projects" && args[0] === "backfill") {
1749
+ /**
1750
+ * Writes the project onto records that never recorded one.
1751
+ *
1752
+ * `projectOfRecord` answers from the record when the seal stored a project
1753
+ * and otherwise re-reads the original transcript off disk. That fallback
1754
+ * has an expiry date: reclaim the source and the answer is gone for good,
1755
+ * taking membership with it — a project nobody can name is a project no
1756
+ * colleague can be added to, and `rewrap --project` re-keys nothing while
1757
+ * reporting the archives belong somewhere else.
1758
+ *
1759
+ * So this is run BEFORE reclaiming, not after. It only adds a field that
1760
+ * was already derivable; it never overwrites one a seal recorded, and an
1761
+ * archive whose project cannot be determined is left exactly as it was.
1762
+ */
1763
+ const { listArchives } = await import("./vault.js");
1764
+ const { projectOfRecord } = await import("./adapters.js");
1765
+ const { writeFile: write, readFile: read } = await import("node:fs/promises");
1766
+ const { join: joinPath } = await import("node:path");
1767
+ const confirm = args.includes("--confirm");
1768
+ const records = await listArchives(dataDir);
1769
+ const config = await readConfig(dataDir);
1770
+ const pending = [];
1771
+ let already = 0, undecidable = 0;
1772
+ for (const record of records) {
1773
+ if ("project" in record.source && record.source.project) {
1774
+ already += 1;
1775
+ continue;
1776
+ }
1777
+ const project = await projectOfRecord(record).catch(() => null);
1778
+ if (!project) {
1779
+ undecidable += 1;
1780
+ continue;
1781
+ }
1782
+ pending.push({ id: record.id, project });
1783
+ }
1784
+ if (json) {
1785
+ print(JSON.stringify({ already, undecidable, wouldWrite: pending.length, confirmed: confirm }, null, 2));
1786
+ }
1787
+ else {
1788
+ print(`\n${BRAND}\n`);
1789
+ print(heading("Project attribution"));
1790
+ print(` ${String(already).padStart(5)} already recorded`);
1791
+ print(` ${String(pending.length).padStart(5)} can still be recovered from the source on disk`);
1792
+ print(` ${String(undecidable).padStart(5)} cannot be determined`);
1793
+ const byProject = new Map();
1794
+ for (const item of pending)
1795
+ byProject.set(item.project, (byProject.get(item.project) ?? 0) + 1);
1796
+ for (const [name, count] of [...byProject].sort((a, b) => b[1] - a[1]).slice(0, 8)) {
1797
+ print(` ${String(count).padStart(4)} ${bold(name)}`);
1798
+ }
1799
+ }
1800
+ if (!confirm) {
1801
+ if (!json) {
1802
+ print(`\n ${dim("Nothing written. Run this before reclaiming sources — afterwards the")}`);
1803
+ print(` ${dim("original files are gone and the project can no longer be derived.")}`);
1804
+ print(steps([`Write them: ${cmd("sealkeep projects backfill --confirm")}`]));
1805
+ }
1806
+ return;
1807
+ }
1808
+ let written = 0;
1809
+ for (const item of pending) {
1810
+ const path = joinPath(config.storage.root, `${item.id}.json`);
1811
+ try {
1812
+ const record = JSON.parse(await read(path, "utf8"));
1813
+ if (record?.source?.project)
1814
+ continue;
1815
+ record.source.project = item.project;
1816
+ await write(path, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
1817
+ written += 1;
1818
+ }
1819
+ catch { /* a record that will not read is left exactly as it was */ }
1820
+ }
1821
+ if (json)
1822
+ print(JSON.stringify({ written }, null, 2));
1823
+ else
1824
+ print(`\n ${mark.ok()} Recorded a project on ${bold(String(written))} archives.\n`);
1825
+ return;
1826
+ }
1827
+ if (command === "projects" && positionals(args)[0] === "mode") {
1828
+ const [, name, wanted] = positionals(args);
1829
+ if (!name || (wanted !== "active" && wanted !== "passive"))
1830
+ fail("invalid_argument", "Usage: sealkeep projects mode <name> active|passive");
1831
+ const { listArchives, setProjectSharing } = await import("./vault.js");
1832
+ const { projectIdentityOfRecord } = await import("./adapters.js");
1833
+ const identities = await Promise.all((await listArchives(dataDir)).map((record) => projectIdentityOfRecord(record).catch(() => ({ project: null, projectKey: null, projectPath: null }))));
1834
+ const projectKeys = new Set(identities
1835
+ .filter((identity) => identity.project === name && identity.projectKey)
1836
+ .map((identity) => identity.projectKey));
1837
+ if (projectKeys.size > 1) {
1838
+ fail("invalid_argument", `More than one detected project is named ${name}; choose the exact folder in Settings so Sealkeep does not enable sharing for the wrong repository`);
1839
+ }
1840
+ await setProjectSharing(dataDir, name, wanted, [...projectKeys][0]);
1841
+ print(wanted === "active"
1842
+ ? ` ${mark.ok()} ${bold(name)} → ${bold("active")}: every sealed session syncs the index and announces a finished line — teammates' agents can find the work in minutes.`
1843
+ : ` ${mark.ok()} ${bold(name)} → ${bold("passive")}: archive-at-rest — nothing extra moves until someone asks.`);
1844
+ return;
1845
+ }
1042
1846
  if (command === "projects") {
1043
1847
  // Routing rules can pin a project to a destination, and the panel cannot
1044
1848
  // offer a list: project names never reach the cloud — that is the point.
1045
1849
  // So this machine, which does know them, prints the exact strings to paste.
1046
1850
  const { listArchives } = await import("./vault.js");
1047
- const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
1851
+ const { projectOfRecord } = await import("./adapters.js");
1048
1852
  const records = await listArchives(dataDir);
1049
1853
  const seen = new Map();
1050
1854
  let unknown = 0;
1051
1855
  let unknownBytes = 0;
1052
1856
  for (const record of records) {
1053
1857
  const agentName = record.source.agent;
1054
- let project = null;
1055
- try {
1056
- if (agentName === "claude")
1057
- project = claudeProjectFromPath(record.source.path).project;
1058
- else if (agentName === "codex")
1059
- project = (await codexProjectFromRollout(record.source.path, record.createdAt)).project;
1060
- }
1061
- catch {
1062
- project = null;
1063
- }
1858
+ const project = await projectOfRecord(record);
1064
1859
  if (!project) {
1065
1860
  unknown += 1;
1066
1861
  unknownBytes += record.source.bytes ?? 0;
@@ -1095,16 +1890,26 @@ async function main() {
1095
1890
  ], "no project recorded yet"));
1096
1891
  if (unknown > 0)
1097
1892
  print(`\n ${dim(`${unknown} archive${unknown === 1 ? "" : "s"} recorded no project (${bytes(unknownBytes)}).`)}`);
1098
- print(`\n ${dim("Paste a name into \"Only these projects\" in the account panel to pin it to a destination.")}\n`);
1893
+ print(`\n ${dim("Pin a project to a destination under \"Pinned projects\" in the account panel, or with \"+ pin project\" in this machine\u2019s dashboard Settings.")}\n`);
1099
1894
  return;
1100
1895
  }
1101
1896
  if (command === "open") {
1102
1897
  const [vaultRef, destination] = positionals(args);
1103
1898
  if (!vaultRef || !destination)
1104
- fail("invalid_argument", "Usage: sealkeep open <vault-ref> <destination> --recovery-phrase <phrase>");
1105
- const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
1899
+ fail("invalid_argument", "Usage: sealkeep open <vault-ref> <destination> [--recovery-phrase <phrase> | --key <base64-or-file>]");
1900
+ // A recipient key is the team's way in: an archive rewrapped for a
1901
+ // registered key opens with that key and no phrase, so reading a
1902
+ // colleague's session never requires sharing the vault phrase that would
1903
+ // hand over the whole vault.
1904
+ const keyArg = take(args, "--key");
1905
+ const privateKey = keyArg
1906
+ ? (await readFile(keyArg, "utf8").then((text) => text.trim()).catch(() => keyArg.trim()))
1907
+ : undefined;
1908
+ const opener = privateKey
1909
+ ? { privateKey }
1910
+ : required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase or --key is required");
1106
1911
  const cloud = await import("./cloud.js");
1107
- const outcome = await cloud.openFromCloud(dataDir, vaultRef, destination, phrase);
1912
+ const outcome = await cloud.openFromCloud(dataDir, vaultRef, destination, opener);
1108
1913
  if (json) {
1109
1914
  print(JSON.stringify(outcome, null, 2));
1110
1915
  return;
@@ -1125,7 +1930,28 @@ async function main() {
1125
1930
  * expects, so resume simply works again. Two different sessions matching
1126
1931
  * one fragment is a question, not a guess: both are listed with ids.
1127
1932
  */
1128
- const looksLikeId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
1933
+ let looksLikeId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
1934
+ /**
1935
+ * `sealkeep list` prints ids cut to eight characters, and this command used
1936
+ * to accept only a full UUID. So the id the product showed you was never an
1937
+ * id it would take back, and the not-found message sent you to `list` to
1938
+ * read another one that would not work either. Any unambiguous prefix
1939
+ * resolves; an ambiguous one asks rather than guesses. A prefix that
1940
+ * matches no archive falls through to the path matching below, so naming a
1941
+ * missing session file still works.
1942
+ */
1943
+ if (!looksLikeId && /^[0-9a-f]{4,}$/i.test(id)) {
1944
+ const byPrefix = (await listArchives(dataDir))
1945
+ .filter((record) => record.version === 2 && record.id.toLowerCase().startsWith(id.toLowerCase()));
1946
+ if (byPrefix.length === 1) {
1947
+ id = byPrefix[0].id;
1948
+ looksLikeId = true;
1949
+ }
1950
+ else if (byPrefix.length > 1) {
1951
+ const lines = byPrefix.slice(0, 6).map((record) => ` ${record.id} ${record.source.path}`);
1952
+ fail("archive_not_found", `"${id}" matches ${byPrefix.length} archives. Name one:\n${lines.join("\n")}`);
1953
+ }
1954
+ }
1129
1955
  if (!looksLikeId) {
1130
1956
  const { resolve: resolvePath, basename } = await import("node:path");
1131
1957
  const archives = (await listArchives(dataDir)).filter((record) => record.version === 2);
@@ -1163,13 +1989,22 @@ async function main() {
1163
1989
  const secret = privateKey
1164
1990
  ? ""
1165
1991
  : required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or pass --key with a recipient's private key)");
1992
+ const dryRun = args.includes("--dry-run");
1166
1993
  const outcome = await restoreArchive(dataDir, id, secret, {
1167
1994
  destination: native ? undefined : required(destination, "Destination is required unless --native is used"),
1168
1995
  native, home: take(args, "--home"), overwrite: take(args, "--overwrite", "refuse"),
1996
+ ...(dryRun ? { dryRun } : {}),
1169
1997
  ...(privateKey ? { privateKey } : {})
1170
1998
  });
1171
1999
  if (json) {
1172
- print(JSON.stringify({ id, output: outcome.output, bytes: outcome.bytes, native: outcome.native, backupPath: outcome.backupPath }, null, 2));
2000
+ print(JSON.stringify({ id, output: outcome.output, bytes: outcome.bytes, native: outcome.native, backupPath: outcome.backupPath, ...(outcome.dryRun ? { dryRun: true, destinationExists: outcome.destinationExists ?? false } : {}) }, null, 2));
2001
+ return;
2002
+ }
2003
+ if (outcome.dryRun) {
2004
+ print(` ${mark.ok()} Would recover ${bold(bytes(outcome.bytes))} to ${outcome.output}${outcome.native ? dim(" (original location)") : ""}`);
2005
+ if (outcome.destinationExists)
2006
+ print(` ${dim("A file already exists there — a real run needs --overwrite backup or replace.")}`);
2007
+ print(` ${dim("Nothing was written. Re-run without --dry-run to restore.")}`);
1173
2008
  return;
1174
2009
  }
1175
2010
  print(` ${mark.ok()} Recovered ${bold(bytes(outcome.bytes))} to ${outcome.output}${outcome.native ? dim(" (original location)") : ""}`);
@@ -1212,36 +2047,143 @@ async function main() {
1212
2047
  return;
1213
2048
  }
1214
2049
  if (action === "hook-config") {
1215
- print(JSON.stringify(hookConfig(agentId(agent), take(args, "--executable", "vaultline"), dataDir), null, 2));
2050
+ print(JSON.stringify(hookConfig(agentId(agent), take(args, "--executable", "sealkeep"), dataDir), null, 2));
1216
2051
  return;
1217
2052
  }
1218
2053
  fail("invalid_argument", "Usage: sealkeep agents <detect|sessions|hook-config> [agent]");
1219
2054
  }
1220
2055
  if (command === "hook") {
1221
2056
  const [action] = positionals(args);
2057
+ if (action === "context") {
2058
+ // The user-facing lane is deliberately local-only. It consumes context
2059
+ // already prepared by the async companion hook: no network, remote index,
2060
+ // membership reconciliation or publishing may delay this boundary.
2061
+ try {
2062
+ const raw = await readStdin();
2063
+ const { consumePreparedAgentContext, parseAgentHookPayload } = await import("./agent-context.js");
2064
+ const payload = parseAgentHookPayload(raw);
2065
+ const phrase = await unlock(dataDir).catch(() => undefined);
2066
+ if (!phrase) {
2067
+ print("{}");
2068
+ return;
2069
+ }
2070
+ const outcome = await consumePreparedAgentContext(dataDir, phrase, payload);
2071
+ if (!outcome?.additionalContext) {
2072
+ print("{}");
2073
+ return;
2074
+ }
2075
+ print(JSON.stringify({
2076
+ hookSpecificOutput: {
2077
+ hookEventName: payload.hook_event_name ?? "SessionStart",
2078
+ additionalContext: outcome.additionalContext,
2079
+ }
2080
+ }));
2081
+ }
2082
+ catch {
2083
+ print("{}");
2084
+ }
2085
+ return;
2086
+ }
2087
+ if (action === "context-sync") {
2088
+ // Agent configs mark this command async, but Codex still enforces its
2089
+ // timeout and kills unfinished work. Persist a sealed, coalesced request
2090
+ // here; the daemon owns search/network work and automatic retry.
2091
+ try {
2092
+ const raw = await readStdin();
2093
+ const { clearHookUnlockFailure, enqueueAutomaticAgentContextRequest, parseAgentHookPayload, recordHookUnlockFailure } = await import("./agent-context.js");
2094
+ const payload = parseAgentHookPayload(raw);
2095
+ const agent = agentId(take(args, "--agent"));
2096
+ const phrase = await unlock(dataDir).catch(() => undefined);
2097
+ if (phrase) {
2098
+ await enqueueAutomaticAgentContextRequest(dataDir, phrase, agent, payload);
2099
+ await clearHookUnlockFailure(dataDir);
2100
+ }
2101
+ else {
2102
+ // Fail open, but not silently: doctor reads this marker.
2103
+ await recordHookUnlockFailure(dataDir, agent).catch(() => undefined);
2104
+ }
2105
+ }
2106
+ catch { /* durable recall is fail-open; an existing coalesced request remains available */ }
2107
+ print("{}");
2108
+ return;
2109
+ }
1222
2110
  if (action === "rehydrate") {
1223
- // The SessionStart side of the hook pair: the agent is about to resume
1224
- // a session — if its transcript left this disk for the vault, put it
1225
- // back before the agent reads it. NEVER break the session: every
1226
- // outcome, including "no phrase available here", is a JSON line and
1227
- // exit 0.
2111
+ // Compatibility/manual hook action. Current generated agent configs do
2112
+ // not install it: an automatic multi-GB restore would defeat reclaim and
2113
+ // can race the descriptor an already-started agent is appending to.
2114
+ // Every outcome remains a valid empty hook response and exit 0.
1228
2115
  const { rehydrateSession, rehydrateTargetFromPayload } = await import("./rehydrate.js");
2116
+ const { withoutArchiveRecordWarnings } = await import("./vault.js");
1229
2117
  const target = rehydrateTargetFromPayload(await readStdin());
1230
2118
  const phrase = await unlock(dataDir).catch(() => undefined);
1231
- const outcome = await rehydrateSession(dataDir, target, phrase).catch((error) => ({
2119
+ await withoutArchiveRecordWarnings(() => rehydrateSession(dataDir, target, phrase).catch((error) => ({
1232
2120
  rehydrated: false, reason: "restore-failed",
1233
2121
  note: error instanceof Error ? error.message.split("\n")[0] : "rehydrate failed"
1234
- }));
1235
- print(JSON.stringify(outcome));
2122
+ })));
2123
+ // `RehydrateOutcome` is Sealkeep's internal receipt, not part of the
2124
+ // agent hook protocol. Claude validates SessionStart JSON strictly; keys
2125
+ // such as `rehydrated`, `reason`, `archiveId` and `output` make a
2126
+ // successful restore look like a failed hook. The filesystem mutation
2127
+ // above is the whole result, so return the protocol's accepted no-op
2128
+ // object on every calm outcome.
2129
+ print("{}");
2130
+ return;
2131
+ }
2132
+ if (action === "bridge-context") {
2133
+ // UserPromptSubmit hook: hand pending teammate messages to the agent's
2134
+ // next turn as additional context. The hook contract's discipline is the
2135
+ // hook boundary's — NEVER break a session: any failure is empty output
2136
+ // and exit 0. No phrase is needed; the pending queue is already local
2137
+ // plaintext, exactly like the mirror beside it.
2138
+ const channel = take(args, "--channel");
2139
+ try {
2140
+ const { drainPending, promptInjection, pendingCount } = await import("./bridge.js");
2141
+ const pending = channel ? await drainPending(dataDir, channel) : [];
2142
+ if (!pending.length) {
2143
+ print("{}");
2144
+ return;
2145
+ }
2146
+ const waiting = channel ? await pendingCount(dataDir, channel) : 0;
2147
+ print(JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: promptInjection(pending, waiting) } }));
2148
+ }
2149
+ catch {
2150
+ print("{}");
2151
+ }
1236
2152
  return;
1237
2153
  }
1238
2154
  if (action !== "enqueue" && action !== "archive")
1239
- fail("invalid_argument", "Usage: sealkeep hook enqueue --agent <codex|claude> --data-dir <path> · sealkeep hook rehydrate --agent <codex|claude> --data-dir <path>");
2155
+ fail("invalid_argument", "Usage: sealkeep hook <enqueue|context|context-sync|rehydrate> --agent <codex|claude> --data-dir <path>");
1240
2156
  const agent = agentId(take(args, "--agent"));
1241
- // The hook records intent only: no encryption, no secret, no network. `sealkeep queue run` does the work.
1242
- const event = await hookEventFromStdin(agent, await readStdin());
1243
- const { job, deduped } = await new ArchiveQueue(dataDir).enqueue({ sourcePath: event.sourcePath, agent, event: event.event, sessionId: event.sessionId });
1244
- print(JSON.stringify({ queued: true, deduped, jobId: job.id, event: job.event, note: action === "archive" ? "`hook archive` now queues work; run `sealkeep queue run` to encrypt it" : undefined }));
2157
+ const raw = await readStdin().catch(() => "");
2158
+ if (hookEventNameOf(raw) === "SessionEnd") {
2159
+ // A session that has ended will never read recall prepared for it. Drop
2160
+ // its slots first — this needs no transcript, while the archive intent
2161
+ // below may legitimately fail without one — so the daemon's next passes
2162
+ // go to the sessions still typing.
2163
+ try {
2164
+ const { discardAutomaticAgentContext, parseAgentHookPayload } = await import("./agent-context.js");
2165
+ await discardAutomaticAgentContext(dataDir, parseAgentHookPayload(raw));
2166
+ }
2167
+ catch { /* fail-open: a stale slot expires on its own */ }
2168
+ }
2169
+ try {
2170
+ // The hook records intent only: no encryption, no secret, no network.
2171
+ // `sealkeep queue run` does the work. Lifecycle input is inherently
2172
+ // racy: an agent may rotate or remove its transcript between announcing
2173
+ // the event and this process opening the path. That missed snapshot must
2174
+ // never corrupt the agent's strict JSON hook channel or stop compaction;
2175
+ // the background watcher remains the durable retry path.
2176
+ const event = await hookEventFromStdin(agent, raw);
2177
+ const local = await readLocalSettings(dataDir);
2178
+ if (automaticTranscriptIsEnabled(local, agent, event.sourcePath)) {
2179
+ await new ArchiveQueue(dataDir).enqueue({ sourcePath: event.sourcePath, agent, event: event.event, sessionId: event.sessionId });
2180
+ }
2181
+ }
2182
+ catch {
2183
+ // Hook stdout belongs to the agent's protocol, not to Sealkeep's CLI.
2184
+ // Codex and Claude validate it even on a fail-open preservation miss.
2185
+ }
2186
+ print("{}");
1245
2187
  return;
1246
2188
  }
1247
2189
  if (command === "queue") {
@@ -1282,6 +2224,17 @@ async function main() {
1282
2224
  : ` ${mark.ok()} Encrypted ${bold(String(done.length))} session${done.length === 1 ? "" : "s"} ${dim(`· ${bytes(archived)} of history preserved`)}`);
1283
2225
  for (const job of failed)
1284
2226
  print(` ${mark.warn()} ${shortPath(job.sourcePath)} ${dim(`— ${job.lastError?.message ?? "will retry"}`)}`);
2227
+ if (done.length > 0) {
2228
+ try {
2229
+ const { buildContentIndex } = await import("./search.js");
2230
+ const built = await buildContentIndex(dataDir, phrase, { sync: false });
2231
+ if (built.indexedNow > 0)
2232
+ print(` ${dim(`${built.indexedNow} made searchable`)}`);
2233
+ }
2234
+ catch {
2235
+ print(` ${dim("not indexed yet — `sealkeep index build` adds them")}`);
2236
+ }
2237
+ }
1285
2238
  return;
1286
2239
  }
1287
2240
  if (action === "retry") {
@@ -1308,6 +2261,220 @@ async function main() {
1308
2261
  * writing a config fragment that runs `sealkeep mcp`, a command that did not
1309
2262
  * exist until now: anyone who merged it got an agent that failed to start.
1310
2263
  */
2264
+ if (command === "bridge") {
2265
+ const [action, arg] = positionals(args);
2266
+ if (action === undefined) {
2267
+ const { listChannelStatus } = await import("./bridge.js");
2268
+ const rows = await listChannelStatus(dataDir);
2269
+ if (json) {
2270
+ print(JSON.stringify(rows, null, 2));
2271
+ return;
2272
+ }
2273
+ print(`\n${heading("Bridge channels on this machine")}`);
2274
+ if (!rows.length) {
2275
+ print(` ${dim("None yet. Drive one: sealkeep bridge start <session> --channel <id> · follow one: sealkeep bridge watch <id>")}\n`);
2276
+ return;
2277
+ }
2278
+ print(table(rows, [
2279
+ { header: "channel", get: (row) => bold(row.channel) },
2280
+ { header: "mode", get: (row) => row.mode },
2281
+ { header: "mirror", get: (row) => (row.mirrorBytes ? bytes(row.mirrorBytes) : dim("—")), align: "right" },
2282
+ { header: "for agent", get: (row) => (row.pending ? String(row.pending) : dim("0")), align: "right" },
2283
+ { header: "held", get: (row) => (row.held ? `${row.held} ${dim(`→ sealkeep bridge review ${row.channel}`)}` : dim("0")) }
2284
+ ], "none"));
2285
+ print("");
2286
+ return;
2287
+ }
2288
+ const { publishOnce, watchOnce, defaultMirrorPath, publishBridgeMessage, pullBridgeMessages, drainPending, setChannelMode, channelSettings, cooperationModeOf, listHeld, resolveHeld } = await import("./bridge.js");
2289
+ if (action === "start") {
2290
+ // Driver: tail a session file and seal each new stretch as a delta.
2291
+ const sessionPath = required(arg ?? take(args, "--session"), "Usage: sealkeep bridge start <session-file> --channel <id>");
2292
+ const channel = required(take(args, "--channel"), "A --channel id is required (1-64 chars of A-Za-z0-9-)");
2293
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "The recovery phrase is required to seal bridge deltas");
2294
+ const intervalMs = Number(take(args, "--interval-ms") ?? "1500");
2295
+ let seq = Number(take(args, "--from-seq") ?? "0");
2296
+ let fromByte = Number(take(args, "--from-byte") ?? "0");
2297
+ // Cooperation mode: the driver chooses how far teammates' words spread.
2298
+ // A fresh interactive channel defaults to `suggest` — the issue's own
2299
+ // commenters called the yes/no/reword the load-bearing part — and the
2300
+ // choice is printed, never silent. --mode always wins; --approve is the
2301
+ // older spelling of suggest.
2302
+ const askedMode = take(args, "--mode");
2303
+ if (askedMode && !["observe", "suggest", "open"].includes(askedMode))
2304
+ fail("invalid_argument", "--mode must be observe, suggest, or open");
2305
+ const hadSettings = (await channelSettings(dataDir, channel)).mode !== undefined || (await channelSettings(dataDir, channel)).approval !== undefined;
2306
+ const mode = askedMode ?? (args.includes("--approve") ? "suggest" : hadSettings ? cooperationModeOf(await channelSettings(dataDir, channel)) : "suggest");
2307
+ await setChannelMode(dataDir, channel, mode);
2308
+ const modeLine = mode === "observe" ? "teammates' messages stay between the humans — your agent never sees them"
2309
+ : mode === "suggest" ? `teammates' messages wait for your yes/no/reword ${dim(`(sealkeep bridge review ${channel})`)}`
2310
+ : "teammates' messages reach your agent's next turn directly";
2311
+ print(` ${mark.ok()} Cooperation mode ${bold(mode)}: ${modeLine} ${dim(`· change: sealkeep bridge mode ${channel} <observe|suggest|open>`)}`);
2312
+ print(` ${mark.ok()} Bridging ${bold(sessionPath)} on channel ${bold(channel)} ${dim(`(delta every ${intervalMs}ms — Ctrl-C to stop)`)}`);
2313
+ print(` ${hint(`Members follow with: sealkeep bridge watch ${channel}`)}`);
2314
+ const once = Symbol("stop");
2315
+ const stop = new Promise((resolve) => process.once("SIGINT", () => resolve(once)));
2316
+ for (;;) {
2317
+ const outcome = await publishOnce(dataDir, { channel, seq, sessionPath, fromByte }, phrase).catch((error) => { print(` ${dim(`delta ${seq} skipped: ${error.message}`)}`); return null; });
2318
+ if (outcome) {
2319
+ print(` ${dim(`→ delta ${outcome.seq} · ${bytes(outcome.newHighWater - fromByte)} · ${outcome.ref}`)}`);
2320
+ seq = outcome.seq + 1;
2321
+ fromByte = outcome.newHighWater;
2322
+ }
2323
+ // The lane back: teammates' messages land in this machine's inbox and
2324
+ // pending queue. The UserPromptSubmit hook (`sealkeep hook
2325
+ // bridge-context --channel <id>`) hands pending ones to the agent's
2326
+ // next turn; the print below keeps the human driver in the loop too.
2327
+ const arrived = await pullBridgeMessages(dataDir, channel, phrase).catch(() => ({ fresh: [], mode: "open" }));
2328
+ for (const message of arrived.fresh)
2329
+ print(` ✉ ${bold(message.from)} ${dim(`(${message.kind})`)}: ${message.text}`);
2330
+ if (arrived.fresh.length && arrived.mode === "suggest")
2331
+ print(` ${hint(`held for your yes/no/reword: ${cmd(`sealkeep bridge review ${channel}`)}`)}`);
2332
+ if (arrived.fresh.length && arrived.mode === "observe")
2333
+ print(` ${dim("observe mode — visible to you, never to your agent")}`);
2334
+ const raced = await Promise.race([new Promise((r) => setTimeout(r, intervalMs)), stop]);
2335
+ if (raced === once) {
2336
+ print(`\n ${mark.ok()} Bridge stopped at seq ${seq}.`);
2337
+ return;
2338
+ }
2339
+ }
2340
+ }
2341
+ if (action === "watch") {
2342
+ // Member: pull deltas into a local mirror and print what arrives.
2343
+ const channel = required(arg ?? take(args, "--channel"), "Usage: sealkeep bridge watch <channel>");
2344
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "The recovery phrase is required to unseal bridge deltas");
2345
+ const mirrorPath = take(args, "--mirror") ?? defaultMirrorPath(dataDir, channel);
2346
+ const intervalMs = Number(take(args, "--interval-ms") ?? "2000");
2347
+ let fromSeq = Number(take(args, "--from-seq") ?? "0");
2348
+ print(` ${mark.ok()} Watching channel ${bold(channel)} ${dim(`→ mirror ${mirrorPath} (Ctrl-C to stop)`)}`);
2349
+ const once = Symbol("stop");
2350
+ const stop = new Promise((resolve) => process.once("SIGINT", () => resolve(once)));
2351
+ for (;;) {
2352
+ const result = await watchOnce(dataDir, { channel, fromSeq, mirrorPath }, phrase).catch((error) => { print(` ${dim(`poll failed: ${error.message}`)}`); return null; });
2353
+ if (result) {
2354
+ if (result.applied.length)
2355
+ print(` ${dim(`↓ applied deltas ${result.applied.join(", ")}`)}`);
2356
+ if (result.needsReset)
2357
+ print(` ${mark.warn()} The shared session was rewritten upstream (compaction or resume). Delete the mirror and re-watch from seq 0 for a clean snapshot.`);
2358
+ fromSeq = result.nextSeq;
2359
+ }
2360
+ const arrived = await pullBridgeMessages(dataDir, channel, phrase).catch(() => ({ fresh: [], mode: "open" }));
2361
+ for (const message of arrived.fresh)
2362
+ print(` ✉ ${bold(message.from)} ${dim(`(${message.kind})`)}: ${message.text}`);
2363
+ const raced = await Promise.race([new Promise((r) => setTimeout(r, intervalMs)), stop]);
2364
+ if (raced === once) {
2365
+ print(`\n ${mark.ok()} Stopped watching at seq ${fromSeq}.`);
2366
+ return;
2367
+ }
2368
+ }
2369
+ }
2370
+ if (action === "push") {
2371
+ // Any member speaks into the channel: sealed like a delta, delivered to
2372
+ // every other member's inbox, injected into the driver's next turn. This
2373
+ // is the "suggest" scope of anthropics/claude-code#60082 — visible,
2374
+ // labeled teammate input, not ghost keystrokes.
2375
+ const channel = required(arg ?? take(args, "--channel"), "Usage: sealkeep bridge push <channel> \"your message\" [--kind suggest|note|handoff]");
2376
+ // Flags come out BEFORE the free text is gathered, or a flag's value
2377
+ // reads as part of the message ("…the flag suggest").
2378
+ const kind = (take(args, "--kind") ?? "suggest");
2379
+ const explicitText = take(args, "--text");
2380
+ const body = explicitText ?? positionals(args).slice(2).join(" ");
2381
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "The recovery phrase is required to seal a bridge message");
2382
+ const { ref, message } = await publishBridgeMessage(dataDir, { channel, kind, text: required(body || undefined, "Say something: sealkeep bridge push <channel> \"…\"") }, phrase);
2383
+ if (json) {
2384
+ print(JSON.stringify({ ref, message }, null, 2));
2385
+ return;
2386
+ }
2387
+ print(` ${mark.ok()} Sent to ${bold(channel)} as ${bold(message.from)} ${dim(`(${message.kind} · ${ref})`)}`);
2388
+ print(` ${dim("Members see it in their bridge loop; the driver's agent hears it on its next turn if the hook is installed.")}`);
2389
+ return;
2390
+ }
2391
+ if (action === "pull") {
2392
+ // One-shot fetch: new teammate messages into the inbox. --drain also
2393
+ // empties the pending queue (what the prompt hook would have consumed) —
2394
+ // Codex-side agents call the MCP inbox tool, humans call this.
2395
+ const channel = required(arg ?? take(args, "--channel"), "Usage: sealkeep bridge pull <channel> [--drain]");
2396
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "The recovery phrase is required to unseal bridge messages");
2397
+ const { fresh } = await pullBridgeMessages(dataDir, channel, phrase);
2398
+ const drained = args.includes("--drain") ? await drainPending(dataDir, channel) : [];
2399
+ if (json) {
2400
+ print(JSON.stringify({ fresh, drained }, null, 2));
2401
+ return;
2402
+ }
2403
+ if (!fresh.length && !drained.length) {
2404
+ print(` ${dim("Nothing new on the channel.")}`);
2405
+ return;
2406
+ }
2407
+ for (const message of (drained.length ? drained : fresh))
2408
+ print(` ✉ ${bold(message.from)} ${dim(`(${message.kind} · ${message.at.slice(11, 19)}Z)`)}: ${message.text}`);
2409
+ return;
2410
+ }
2411
+ if (action === "mode") {
2412
+ const channel = required(arg, "Usage: sealkeep bridge mode <channel> [observe|suggest|open]");
2413
+ const wanted = positionals(args)[2];
2414
+ if (wanted === undefined) {
2415
+ const mode = cooperationModeOf(await channelSettings(dataDir, channel));
2416
+ if (json) {
2417
+ print(JSON.stringify({ channel, mode }, null, 2));
2418
+ return;
2419
+ }
2420
+ print(` ${bold(channel)} · cooperation mode ${bold(mode)}`);
2421
+ return;
2422
+ }
2423
+ if (!["observe", "suggest", "open"].includes(wanted))
2424
+ fail("invalid_argument", "Mode must be observe, suggest, or open");
2425
+ await setChannelMode(dataDir, channel, wanted);
2426
+ // Comfort truth: switching to open does NOT release what suggest already
2427
+ // held — those still need the driver's verdict, and saying so beats a
2428
+ // silent queue nobody remembers.
2429
+ if (wanted === "open") {
2430
+ const stillHeld = (await listHeld(dataDir, channel)).length;
2431
+ if (stillHeld)
2432
+ print(` ${mark.warn()} ${stillHeld} message${stillHeld === 1 ? "" : "s"} from suggest mode still held — ${cmd(`sealkeep bridge review ${channel}`)} to release or reject them.`);
2433
+ }
2434
+ print(` ${mark.ok()} ${bold(channel)} → ${bold(wanted)}: ${wanted === "observe" ? "messages stay between the humans" : wanted === "suggest" ? "messages wait for your yes/no/reword" : "messages reach your agent's next turn directly"}.`);
2435
+ return;
2436
+ }
2437
+ if (action === "review") {
2438
+ const channel = required(arg, "Usage: sealkeep bridge review <channel>");
2439
+ const held = await listHeld(dataDir, channel);
2440
+ if (json) {
2441
+ print(JSON.stringify(held, null, 2));
2442
+ return;
2443
+ }
2444
+ if (!held.length) {
2445
+ print(` ${dim("Nothing held. Messages wait here when the channel's cooperation mode is suggest.")}`);
2446
+ return;
2447
+ }
2448
+ held.forEach((message, index) => print(` ${bold(String(index + 1))}. ✉ ${bold(message.from)} ${dim(`(${message.kind} · ${message.at.slice(11, 19)}Z)`)}: ${message.text}`));
2449
+ print(`\n ${hint(`${cmd(`sealkeep bridge approve ${channel} <n|all>`)} ${dim("·")} ${cmd(`sealkeep bridge approve ${channel} <n> --reword "…"`)} ${dim("·")} ${cmd(`sealkeep bridge reject ${channel} <n|all>`)}`)}`);
2450
+ return;
2451
+ }
2452
+ if (action === "approve" || action === "reject") {
2453
+ // The yes/no/reword the issue's commenters called load-bearing: nothing
2454
+ // reaches the agent until the driver has said yes — and a reworded
2455
+ // message is marked as reworded, so a teammate is never quoted on words
2456
+ // the driver wrote.
2457
+ const channel = required(arg, `Usage: sealkeep bridge ${action} <channel> <n|all>`);
2458
+ const which = positionals(args)[2] ?? "all";
2459
+ const index = which === "all" ? "all" : Number(which);
2460
+ if (index !== "all" && (!Number.isInteger(index) || index < 1))
2461
+ fail("invalid_argument", `Which one? A number from \`sealkeep bridge review ${channel}\`, or "all".`);
2462
+ const { resolved, remaining } = await resolveHeld(dataDir, channel, { approve: action === "approve", index, reword: take(args, "--reword") });
2463
+ if (json) {
2464
+ print(JSON.stringify({ resolved, remaining }, null, 2));
2465
+ return;
2466
+ }
2467
+ if (!resolved.length) {
2468
+ print(` ${dim("Nothing was held.")}`);
2469
+ return;
2470
+ }
2471
+ print(` ${mark.ok()} ${action === "approve" ? "Approved" : "Rejected"} ${resolved.length} message${resolved.length === 1 ? "" : "s"}${remaining ? dim(` · ${remaining} still held`) : ""}`);
2472
+ if (action === "approve")
2473
+ print(` ${dim("The agent hears it on its next turn (hook), or when it checks sealkeep_bridge_inbox.")}`);
2474
+ return;
2475
+ }
2476
+ fail("invalid_argument", "Usage: sealkeep bridge <start|watch|push|pull|mode|review|approve|reject> …");
2477
+ }
1311
2478
  if (command === "mcp") {
1312
2479
  const [action] = positionals(args);
1313
2480
  if (action === "install") {
@@ -1341,6 +2508,13 @@ async function main() {
1341
2508
  fail("invalid_argument", "Usage: sealkeep mcp [run|install] [--agent <name>]");
1342
2509
  // No banner, no colour: stdout is the protocol channel here, and anything
1343
2510
  // printed to it corrupts the first message the client reads.
2511
+ //
2512
+ // The server resolves its vault once, at module load, from the environment
2513
+ // — so `--data-dir` has to be there BEFORE the import or the flag is
2514
+ // silently ignored and the agent is served a different vault than the one
2515
+ // asked for. That is how it failed: a second machine's MCP server quietly
2516
+ // answered from the default vault instead.
2517
+ process.env[`${ENV_PREFIX}DATA_DIR`] = dataDir;
1344
2518
  await import("./mcp.js");
1345
2519
  return;
1346
2520
  }
@@ -1382,12 +2556,19 @@ async function main() {
1382
2556
  return;
1383
2557
  }
1384
2558
  if (command === "api") {
1385
- // Headless and on a predictable port: this is the address the TUI, the MCP
1386
- // server and anything else local is pointed at, so it neither opens a
1387
- // window nor inherits an ephemeral port from a UI that happens to be up.
1388
- const handle = await startUi(dataDir, { port: await freePort(Number(take(args, "--port", "4180"))), openBrowser: false, reuse: false });
1389
- print(`\n${BRAND} local API on ${bold(handle.origin.replace("http://", ""))}`);
2559
+ // A vault has one stateful local API owner. Upload/reclaim journals have
2560
+ // in-memory transfer ownership, so a second headless server is not a
2561
+ // harmless extra address. Reuse the Autopilot/UI listener when present;
2562
+ // otherwise this command becomes the owner on its requested port.
2563
+ const requestedPort = Number(take(args, "--port", "4180"));
2564
+ if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535) {
2565
+ fail("invalid_argument", "--port must be an integer from 0 to 65535");
2566
+ }
2567
+ const handle = await startUi(dataDir, { port: requestedPort, openBrowser: false });
2568
+ print(`\n${BRAND} local API ${handle.reused ? "already running" : "listening"} on ${bold(handle.origin.replace("http://", ""))}`);
1390
2569
  print(` ${dim(`Token file (do not share): ${shortPath(localApiTokenPath(dataDir), 60)}`)}`);
2570
+ if (handle.reused)
2571
+ return;
1391
2572
  print(` ${dim("Ctrl-C to stop.")}\n`);
1392
2573
  holdUntilStopped(handle);
1393
2574
  return;
@@ -1447,8 +2628,8 @@ async function main() {
1447
2628
  if (command === "search") {
1448
2629
  const [query] = positionals(args);
1449
2630
  const mode = args.includes("--content") ? "content" : "metadata";
1450
- const hits = await search(dataDir, required(query, "Usage: sealkeep search <query> [--content] [--agent <name>]"), {
1451
- mode, agent: take(args, "--agent"),
2631
+ const hits = await search(dataDir, required(query, "Usage: sealkeep search <query> [--content] [--agent <name>] [--project <name>]"), {
2632
+ mode, agent: take(args, "--agent"), project: take(args, "--project"),
1452
2633
  phrase: mode === "content" ? required(await unlock(dataDir, take(args, "--recovery-phrase")), "Content search decrypts locally and needs --recovery-phrase") : undefined
1453
2634
  });
1454
2635
  if (json) {
@@ -1462,10 +2643,63 @@ async function main() {
1462
2643
  }
1463
2644
  for (const hit of hits) {
1464
2645
  print(` ${bold(shortPath(hit.path, 60))}`);
1465
- print(` ${dim(`${hit.agent} · ${bytes(hit.bytes)} · ${relativeTime(hit.createdAt)} · ${hit.id.slice(0, 8)}`)}`);
2646
+ print(` ${dim(`${hit.agent}${hit.project ? ` · ${hit.project}` : ""} · ${bytes(hit.bytes)} · ${relativeTime(hit.createdAt)} · ${hit.id.slice(0, 8)}`)}`);
2647
+ if (hit.matched === "content" && hit.meta) {
2648
+ const meta = hit.meta;
2649
+ const span = meta.startedAt && meta.endedAt
2650
+ ? `${meta.startedAt.slice(0, 10)} ${meta.startedAt.slice(11, 16)}–${meta.endedAt.slice(11, 16)}`
2651
+ : null;
2652
+ const gitBit = meta.commits?.length
2653
+ ? `git: ${meta.commits.slice(0, 3).map((c) => c.h).join(" ")}${meta.commits.length > 3 ? ` +${meta.commits.length - 3}` : ""}`
2654
+ : null;
2655
+ const fileBit = meta.files?.length ? `${meta.files.length} file${meta.files.length === 1 ? "" : "s"}` : null;
2656
+ const bits = [span, gitBit, fileBit].filter(Boolean);
2657
+ if (bits.length)
2658
+ print(` ${dim(bits.join(" · "))}`);
2659
+ }
1466
2660
  if (hit.matched === "content")
1467
2661
  for (const snippet of hit.snippets)
1468
- print(` ${blue("│")} ${dim(snippet.trim().slice(0, 96))}`);
2662
+ print(` ${blue("│")} ${dim(snippet.trim())}`);
2663
+ print("");
2664
+ }
2665
+ return;
2666
+ }
2667
+ /**
2668
+ * The question git cannot answer. `git blame` names the commit; this names
2669
+ * the SESSION the commit came out of — the reasoning, the dead ends, the
2670
+ * alternatives — because the index records which commits landed inside each
2671
+ * session's time window, in that session's working directory.
2672
+ */
2673
+ if (command === "why") {
2674
+ const hash = required(positionals(args)[0], "Usage: sealkeep why <commit-hash>");
2675
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "Reading the sealed index needs the recovery phrase");
2676
+ const { loadReadableIndexes, sessionsForCommit, refreshIndexFromRemote } = await import("./search.js");
2677
+ await refreshIndexFromRemote(dataDir, phrase);
2678
+ // A machine with nothing indexed yet is an EMPTY state, not a broken one —
2679
+ // a colleague who just joined has exactly this vault, and answering them
2680
+ // with a failure reads as "something is wrong with your setup" when the
2681
+ // truthful answer is "nothing here knows about that commit yet".
2682
+ const indexes = await loadReadableIndexes(dataDir, { phrase }).catch(() => null);
2683
+ const matches = indexes ? sessionsForCommit(indexes, hash) : [];
2684
+ if (json) {
2685
+ print(JSON.stringify(matches, null, 2));
2686
+ return;
2687
+ }
2688
+ if (!matches.length) {
2689
+ print(indexes
2690
+ ? `\n ${dim(`No indexed session carries commit ${hash}. The commit↔session link is built by \`sealkeep index build\` on the machine where that repo lives.`)}\n`
2691
+ : `\n ${dim(`Nothing is indexed on this machine yet, so no commit can be traced to a session. Run \`sealkeep index build\` — or wait for a teammate's index to reach this account.`)}\n`);
2692
+ return;
2693
+ }
2694
+ const records = await listArchives(dataDir);
2695
+ print(`\n${heading(`The session behind ${hash}`)}`);
2696
+ for (const match of matches) {
2697
+ const record = records.find((r) => r.id === match.id);
2698
+ print(` ${bold(match.subject || "(commit)")}`);
2699
+ print(` ${dim(`session ${match.id.slice(0, 8)}${record ? ` · ${record.source.agent} · ${relativeTime(record.createdAt)}` : ""}${match.meta.startedAt ? ` · ${match.meta.startedAt.slice(0, 16).replace("T", " ")}` : ""}`)}`);
2700
+ if (record)
2701
+ print(` ${dim(shortPath(record.source.path, 62))}`);
2702
+ print(` ${hint(`the reasoning: ${cmd(`sealkeep recover ${match.id.slice(0, 8)} ./why-${hash.slice(0, 8)}`)} ${dim("· or the agent finds it via sealkeep_search")}`)}`);
1469
2703
  print("");
1470
2704
  }
1471
2705
  return;
@@ -1474,7 +2708,21 @@ async function main() {
1474
2708
  const [action] = positionals(args);
1475
2709
  const phrase = async () => required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
1476
2710
  if (action === "build") {
1477
- const built = await buildContentIndex(dataDir, await phrase());
2711
+ /**
2712
+ * Indexing a large vault is twenty minutes of work that used to print
2713
+ * nothing at all until it finished — indistinguishable from a hang, and
2714
+ * when it did die there was no way to tell how far it got. A line per
2715
+ * checkpoint is the difference between "it is working" and "is it?".
2716
+ */
2717
+ let lastShown = 0;
2718
+ const built = await buildContentIndex(dataDir, await phrase(), {
2719
+ onProgress: json ? undefined : (done, total) => {
2720
+ if (done !== total && done - lastShown < 20)
2721
+ return;
2722
+ lastShown = done;
2723
+ print(` ${dim(`indexed ${done}/${total}${done < total ? " — safe to stop; it resumes here" : ""}`)}`);
2724
+ }
2725
+ });
1478
2726
  if (json) {
1479
2727
  print(JSON.stringify(built, null, 2));
1480
2728
  return;
@@ -1483,6 +2731,20 @@ async function main() {
1483
2731
  print(` ${dim("The index is encrypted at rest and never uploaded. Remove it with `sealkeep index drop`.")}`);
1484
2732
  return;
1485
2733
  }
2734
+ if (action === "migrate") {
2735
+ const result = await migrateBlobToSegments(dataDir, await phrase());
2736
+ if (json) {
2737
+ print(JSON.stringify(result, null, 2));
2738
+ return;
2739
+ }
2740
+ if (result.migrated) {
2741
+ print(` ${mark.ok()} Moved ${bold(String(result.archives))} archives ${dim(`(${result.tokens} terms)`)} into the segmented index.`);
2742
+ }
2743
+ else {
2744
+ print(` ${dim("Already segmented.")}`);
2745
+ }
2746
+ return;
2747
+ }
1486
2748
  if (action === "drop") {
1487
2749
  await dropContentIndex(dataDir);
1488
2750
  print(` ${mark.ok()} Content index removed.`);
@@ -1490,12 +2752,35 @@ async function main() {
1490
2752
  }
1491
2753
  if (action === "status") {
1492
2754
  const { indexCoverage } = await import("./search.js");
1493
- const coverage = await indexCoverage(dataDir);
2755
+ // Offered, never demanded: without it the shared corpus stays unknown and
2756
+ // is reported as unknown, which is still better than the old zero.
2757
+ const known = await unlock(dataDir, take(args, "--recovery-phrase")).catch(() => null);
2758
+ const coverage = await indexCoverage(dataDir, known ? { phrase: known } : {});
1494
2759
  if (json) {
1495
2760
  print(JSON.stringify(coverage, null, 2));
1496
2761
  return;
1497
2762
  }
1498
- print(`\n ${bold(String(coverage.indexed))} of ${bold(String(coverage.total))} archives searchable ${dim(`· index ${bytes(coverage.indexBytes)}${coverage.builtAt ? ` · updated ${coverage.builtAt.slice(0, 16).replace("T", " ")}` : ""}`)}`);
2763
+ const built = dim(`· index ${bytes(coverage.indexBytes)}${coverage.builtAt ? ` · updated ${coverage.builtAt.slice(0, 16).replace("T", " ")}` : ""}`);
2764
+ if (coverage.searchable !== null) {
2765
+ print(`\n ${bold(String(coverage.searchable))} archive${coverage.searchable === 1 ? "" : "s"} searchable from this machine ${built}`);
2766
+ // The line that matters on a team: history this machine never sealed
2767
+ // is still searchable here, and saying so is the shared-memory claim
2768
+ // made checkable rather than asserted.
2769
+ if (coverage.fromOtherMachines) {
2770
+ print(` ${dim(`${coverage.fromOtherMachines} of them sealed by your other machines — pulled in as ciphertext, searched locally.`)}`);
2771
+ }
2772
+ print(` ${dim(`${coverage.indexed} of ${coverage.total} sealed on this machine.`)}`);
2773
+ }
2774
+ else if (coverage.total === 0) {
2775
+ // Do not print "0 of 0 searchable": on a colleague's laptop that reads
2776
+ // as "the account is empty" when it only means "I sealed nothing".
2777
+ print(`\n This machine has sealed nothing yet ${built}`);
2778
+ print(` ${dim("Add --recovery-phrase to see what the shared index makes searchable here.")}`);
2779
+ }
2780
+ else {
2781
+ print(`\n ${bold(String(coverage.indexed))} of ${bold(String(coverage.total))} archives sealed here are searchable ${built}`);
2782
+ print(` ${dim("Add --recovery-phrase to include what your other machines sealed.")}`);
2783
+ }
1499
2784
  if (coverage.missing.length > 0) {
1500
2785
  print(` ${dim(`${coverage.missing.length} not yet indexed — new seals index themselves; \`sealkeep index build\` adds the rest:`)}`);
1501
2786
  for (const item of coverage.missing.slice(0, 5))
@@ -1506,7 +2791,7 @@ async function main() {
1506
2791
  print("");
1507
2792
  return;
1508
2793
  }
1509
- fail("invalid_argument", "Usage: sealkeep index <build|status|drop> --recovery-phrase <phrase>");
2794
+ fail("invalid_argument", "Usage: sealkeep index <build|migrate|status|drop> --recovery-phrase <phrase>");
1510
2795
  }
1511
2796
  if (command === "audit") {
1512
2797
  const [action] = positionals(args);
@@ -1531,7 +2816,59 @@ async function main() {
1531
2816
  }
1532
2817
  if (command === "recovery") {
1533
2818
  const [action] = positionals(args);
2819
+ if (action === "restore") {
2820
+ const code = take(args, "--code") ?? await promptForPhrase(" Backup code (input hidden): ");
2821
+ const { recoverWithRecoveryCode } = await import("./recovery-codes.js");
2822
+ const restored = await recoverWithRecoveryCode(dataDir, code);
2823
+ if (json) {
2824
+ print(JSON.stringify({ restored: true, ...restored }, null, 2));
2825
+ return;
2826
+ }
2827
+ print(`\n ${mark.ok()} Restored vault ${restored.vaultId} on this machine.`);
2828
+ print(` ${dim(`${restored.remaining} unused backup code${restored.remaining === 1 ? " remains" : "s remain"}. This code cannot be used again.`)}\n`);
2829
+ return;
2830
+ }
1534
2831
  const config = await readConfig(dataDir);
2832
+ if (action === "codes") {
2833
+ const { createRecoveryCodes, recoveryKitStatus } = await import("./recovery-codes.js");
2834
+ if (args.includes("--status")) {
2835
+ const status = await recoveryKitStatus(dataDir);
2836
+ if (json) {
2837
+ print(JSON.stringify(status, null, 2));
2838
+ return;
2839
+ }
2840
+ print(`\n ${status.available ? mark.ok() : mark.warn()} ${status.available ? `${status.remaining} unused backup code${status.remaining === 1 ? " remains" : "s remain"}` : "No backup Recovery Kit is stored"}.\n`);
2841
+ return;
2842
+ }
2843
+ if (!args.includes("--confirm"))
2844
+ fail("invalid_argument", "Creating a new Recovery Kit invalidates every previous backup code. Re-run with --confirm, or use Settings in the dashboard.");
2845
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "Unlock this machine before replacing its Recovery Kit");
2846
+ const created = await createRecoveryCodes(dataDir, phrase);
2847
+ const sheet = `SEALKEEP RECOVERY KIT\n\nOne-time backup codes\n\n${created.codes.map((code, index) => `${index + 1}. ${code}`).join("\n")}\n\nSign in with Google on a fresh machine, then run sealkeep recovery restore --code <code>.\n`;
2848
+ const target = take(args, "--out");
2849
+ if (target) {
2850
+ const { writeFile } = await import("node:fs/promises");
2851
+ await writeFile(target, sheet, { mode: 0o600 });
2852
+ print(` ${mark.ok()} Recovery Kit written to ${target}`);
2853
+ }
2854
+ else if (json)
2855
+ print(JSON.stringify(created, null, 2));
2856
+ else
2857
+ print(sheet);
2858
+ return;
2859
+ }
2860
+ if (action === "forget-machine") {
2861
+ if (!args.includes("--confirm"))
2862
+ fail("invalid_argument", "Forgetting this machine's recovery phrase stops unattended sealing. Re-run with --confirm; encrypted archives are kept.");
2863
+ const { forgetRecoveryPhrase } = await import("./secrets.js");
2864
+ const outcome = await forgetRecoveryPhrase(dataDir, config.vaultId);
2865
+ if (json) {
2866
+ print(JSON.stringify({ forgotten: true, backend: outcome.backend, archivesKept: true }, null, 2));
2867
+ return;
2868
+ }
2869
+ print(`\n ${mark.ok()} This machine forgot the recovery phrase. Encrypted archives were kept.\n`);
2870
+ return;
2871
+ }
1535
2872
  if (action === "kit") {
1536
2873
  // The kit is read after the machine is gone, so it must name the cloud
1537
2874
  // account when there is one.
@@ -1616,7 +2953,7 @@ async function main() {
1616
2953
  print("");
1617
2954
  return;
1618
2955
  }
1619
- fail("invalid_argument", "Usage: sealkeep recovery <kit|verify|seal|open> [--out <path>]");
2956
+ fail("invalid_argument", "Usage: sealkeep recovery <kit|verify|seal|open|codes|restore> [--out <path>]");
1620
2957
  }
1621
2958
  if (command === "phrase" && positionals(args)[0] === "rotate") {
1622
2959
  const { rotateVaultPhrase, rotatedPhrasePath } = await import("./rotate.js");
@@ -1764,11 +3101,12 @@ async function main() {
1764
3101
  const [action, archiveId] = positionals(args);
1765
3102
  if (action === "targets") {
1766
3103
  const sub = positionals(args)[1];
1767
- const { resolveTargets, targetUsage, setStorageTargets } = await import("./storage-targets.js");
3104
+ const { resolveTargets, storageTargetsForEditing, targetUsage, setStorageTargets } = await import("./storage-targets.js");
1768
3105
  if (sub === "add") {
1769
3106
  const provider = required(take(args, "--provider"), "--provider is required");
1770
3107
  const id = take(args, "--id") ?? provider;
1771
- const existing = (await readConfig(dataDir)).storageTargets ?? await resolveTargets(dataDir);
3108
+ const edit = await storageTargetsForEditing(dataDir);
3109
+ const existing = edit.targets;
1772
3110
  const bucketFlag = take(args, "--bucket");
1773
3111
  const target = {
1774
3112
  id, provider,
@@ -1782,15 +3120,23 @@ async function main() {
1782
3120
  ...(take(args, "--projects") ? { projects: take(args, "--projects").split(",").map((p) => p.trim()).filter(Boolean) } : {}),
1783
3121
  ...(take(args, "--priority") ? { priority: Number(take(args, "--priority")) } : {})
1784
3122
  };
1785
- await setStorageTargets(dataDir, [...existing.filter((t) => t.id !== id), target]);
3123
+ const saved = await setStorageTargets(dataDir, [...existing.filter((t) => t.id !== id), target], { accountBaseline: edit.accountBaseline });
3124
+ if (saved.accountConflict)
3125
+ fail("lease_expired", "Storage routing changed on another machine. The newer rules were kept; run this command again after reviewing `sealkeep storage targets`.");
1786
3126
  print(` ${mark.ok()} Target ${bold(id)} saved.`);
3127
+ if (saved.accountSynced === false)
3128
+ print(` ${hint("Saved on this machine only because account routing was unavailable. Sealkeep did not overwrite an unseen account change.")}`);
1787
3129
  return;
1788
3130
  }
1789
3131
  if (sub === "remove") {
1790
3132
  const id = required(positionals(args)[2], "Usage: sealkeep storage targets remove <id>");
1791
- const existing = (await readConfig(dataDir)).storageTargets ?? [];
1792
- await setStorageTargets(dataDir, existing.filter((t) => t.id !== id));
3133
+ const edit = await storageTargetsForEditing(dataDir);
3134
+ const saved = await setStorageTargets(dataDir, edit.targets.filter((t) => t.id !== id), { accountBaseline: edit.accountBaseline });
3135
+ if (saved.accountConflict)
3136
+ fail("lease_expired", "Storage routing changed on another machine. The newer rules were kept; run this command again after reviewing `sealkeep storage targets`.");
1793
3137
  print(` ${mark.ok()} Target ${bold(id)} removed. Archives already there stay readable — records remember where they live.`);
3138
+ if (saved.accountSynced === false)
3139
+ print(` ${hint("Removed on this machine only because account routing was unavailable. Sealkeep did not overwrite an unseen account change.")}`);
1794
3140
  return;
1795
3141
  }
1796
3142
  const targets = await resolveTargets(dataDir);
@@ -1808,7 +3154,7 @@ async function main() {
1808
3154
  const used = usage.get(t.id) ?? 0;
1809
3155
  const cap = t.maxGb !== undefined ? ` of ${t.maxGb} GB` : "";
1810
3156
  const pins = t.projects?.length ? ` · projects: ${t.projects.join(", ")}` : "";
1811
- print(` ${bold(t.id.padEnd(12))} ${t.provider}${t.bucket ? `://${t.bucket}` : ""} ${dim(`· ${bytes(used)}${cap}${pins} · priority ${t.priority ?? (t.provider === "vaultline" ? 0 : 10)}`)}`);
3157
+ print(` ${bold(t.id.padEnd(12))} ${providerLabel(t.provider)}${t.bucket ? ` (${t.bucket})` : ""} ${dim(`· ${bytes(used)}${cap}${pins} · priority ${t.priority ?? (t.provider === "vaultline" ? 0 : 10)}`)}`);
1812
3158
  }
1813
3159
  print(` ${dim("New seals go to the pinned project target first, then lowest priority with room. Reads always follow each archive's own record.")}\n`);
1814
3160
  return;
@@ -1820,20 +3166,20 @@ async function main() {
1820
3166
  return;
1821
3167
  }
1822
3168
  print(remote
1823
- ? ` ${bold(`${remote.provider}://${remote.bucket}/${remote.prefix}`)}${remote.region ? dim(` · ${remote.region}`) : ""}`
3169
+ ? ` ${bold(providerLabel(remote.provider))} ${dim(`${remote.bucket}/${remote.prefix}${remote.region ? ` · ${remote.region}` : ""}`)}`
1824
3170
  : ` ${dim("No remote target. Set one with `sealkeep storage configure --provider r2 --bucket … --prefix …`")}`);
1825
3171
  return;
1826
3172
  }
1827
3173
  if (action === "configure") {
1828
3174
  const provider = required(take(args, "--provider"), "--provider is required");
1829
- if (!Object.hasOwn(providers, provider))
1830
- fail("provider_unsupported", "--provider must be vaultline, s3, r2, or gcs");
3175
+ if (!Object.hasOwn(providers, provider) || provider === "vaultline")
3176
+ fail("provider_unsupported", "--provider must be s3, r2, b2, gcs, or gdrive; Sealkeep Cloud is connected through your account");
1831
3177
  const config = await configureRemoteStorage(dataDir, { provider, bucket: required(take(args, "--bucket"), "--bucket is required"), prefix: required(take(args, "--prefix"), "--prefix is required"), region: take(args, "--region") });
1832
3178
  if (json) {
1833
3179
  print(JSON.stringify(config.remoteStorage, null, 2));
1834
3180
  return;
1835
3181
  }
1836
- print(` ${mark.ok()} Target saved: ${bold(`${config.remoteStorage.provider}://${config.remoteStorage.bucket}/${config.remoteStorage.prefix}`)}`);
3182
+ print(` ${mark.ok()} Target saved: ${bold(providerLabel(config.remoteStorage.provider))} ${dim(`${config.remoteStorage.bucket}/${config.remoteStorage.prefix}`)}`);
1837
3183
  print(` ${hint("Store credentials next: echo '{\"accessKeyId\":\"…\",\"secretAccessKey\":\"…\"}' | sealkeep storage credentials set")}`);
1838
3184
  return;
1839
3185
  }
@@ -1843,7 +3189,7 @@ async function main() {
1843
3189
  const plan = setupPlan({
1844
3190
  provider,
1845
3191
  bucket: required(take(args, "--bucket") ?? config?.remoteStorage?.bucket, "--bucket is required"),
1846
- prefix: take(args, "--prefix") ?? config?.remoteStorage?.prefix ?? `vaultline/${config?.vaultId?.slice(0, 8) ?? "me"}`,
3192
+ prefix: take(args, "--prefix") ?? config?.remoteStorage?.prefix ?? `${STORAGE_PREFIX_DEFAULT}/${config?.vaultId?.slice(0, 8) ?? "me"}`,
1847
3193
  region: take(args, "--region"), accountId: take(args, "--account-id"), project: take(args, "--project")
1848
3194
  });
1849
3195
  if (json) {
@@ -1876,8 +3222,16 @@ async function main() {
1876
3222
  // `archiveId` is just the second positional here: the provider to connect.
1877
3223
  if (archiveId !== "gdrive")
1878
3224
  fail("invalid_argument", "Usage: sealkeep storage connect gdrive [--config-id <id>] [--backend <name>]");
1879
- const configId = take(args, "--config-id") ?? (await readConfig(dataDir)).vaultId;
3225
+ const config = await readConfig(dataDir);
3226
+ const configId = take(args, "--config-id") ?? config.vaultId;
1880
3227
  const backend = take(args, "--backend");
3228
+ const { destinationBindingForTarget, resolveTargets } = await import("./storage-targets.js");
3229
+ const targets = await resolveTargets(dataDir);
3230
+ const bindingTarget = targets.find((target) => target.id === configId)
3231
+ ?? (configId === config.vaultId ? targets.find((target) => target.id === "primary") : undefined);
3232
+ if (!bindingTarget || bindingTarget.provider !== "gdrive") {
3233
+ fail("storage_not_configured", `Storage config "${configId}" is not a configured Google Drive target.`);
3234
+ }
1881
3235
  // The account's shared Google client first: no console visit, no env
1882
3236
  // var — the consent screen is the whole setup. The PKCE desktop path
1883
3237
  // stays for self-hosters who set SEALKEEP_GDRIVE_CLIENT_ID.
@@ -1897,7 +3251,10 @@ async function main() {
1897
3251
  print(` ${dim("Waiting for the browser…")}`);
1898
3252
  }
1899
3253
  });
1900
- const stored = await storeProviderCredentials(dataDir, configId, credentials, backend);
3254
+ const binding = await destinationBindingForTarget(dataDir, bindingTarget, new Date().toISOString(), credentials);
3255
+ if (!binding.destination.account)
3256
+ fail("unauthorized", "Google consent completed without a stable Drive account identity. Connect again.");
3257
+ const stored = await storeProviderCredentials(dataDir, configId, credentials, backend, binding);
1901
3258
  if (json) {
1902
3259
  print(JSON.stringify({ provider: "gdrive", backend: stored.backend, configId }, null, 2));
1903
3260
  return;
@@ -1920,7 +3277,19 @@ async function main() {
1920
3277
  catch {
1921
3278
  fail("invalid_argument", "Credentials on stdin must be JSON");
1922
3279
  }
1923
- const stored = await storeProviderCredentials(dataDir, configId, parsed, backend);
3280
+ const credential = parsed;
3281
+ const config = await readConfig(dataDir);
3282
+ const { destinationBindingForTarget, resolveTargets } = await import("./storage-targets.js");
3283
+ const targets = await resolveTargets(dataDir).catch(() => []);
3284
+ const bindingTarget = targets.find((target) => target.id === configId)
3285
+ ?? (configId === config.vaultId ? targets.find((target) => target.id === "primary") : undefined);
3286
+ const binding = bindingTarget && bindingTarget.provider !== "vaultline"
3287
+ ? await destinationBindingForTarget(dataDir, bindingTarget, null, credential)
3288
+ : undefined;
3289
+ if (bindingTarget?.provider === "gdrive" && !binding?.destination.account) {
3290
+ fail("invalid_argument", "A Google Drive credential must include the stable accountId returned during consent. Use `storage connect gdrive`.");
3291
+ }
3292
+ const stored = await storeProviderCredentials(dataDir, configId, credential, backend, binding);
1924
3293
  print(` ${mark.ok()} Stored in ${bold(stored.backend)} ${dim(`for ${configId.slice(0, 8)}`)}`);
1925
3294
  return;
1926
3295
  }
@@ -1979,7 +3348,7 @@ async function main() {
1979
3348
  // stays, and on real transcripts that is about two thirds of what it
1980
3349
  // replaced. This removes local archives the bucket has a verified copy of.
1981
3350
  const { offloadArchives } = await import("./offload.js");
1982
- const result = await offloadArchives(dataDir, { confirm: args.includes("--confirm") });
3351
+ const result = await offloadArchives(dataDir, { ...options, confirm: args.includes("--confirm") });
1983
3352
  if (json) {
1984
3353
  print(JSON.stringify(result, null, 2));
1985
3354
  return;
@@ -2054,11 +3423,11 @@ async function main() {
2054
3423
  }
2055
3424
  return;
2056
3425
  }
2057
- print(` ${bold(String(result.wouldReclaim))} source${result.wouldReclaim === 1 ? "" : "s"} would move to the trash, freeing ${bold(bytes(result.wouldFreeBytes))}.\n ${hint(`re-run with ${cmd("--confirm")} to do it`)}`);
3426
+ print(` ${bold(String(result.wouldReclaim))} source${result.wouldReclaim === 1 ? "" : "s"} could free up to ${bold(bytes(result.wouldFreeBytes))} after the final safety checks.\n ${hint(`re-run with ${cmd("--confirm")} to do it`)}`);
2058
3427
  return;
2059
3428
  }
2060
- print(` ${mark.ok()} Moved ${bold(String(result.reclaimed.length))} source${result.reclaimed.length === 1 ? "" : "s"} to the trash, freeing ${bold(bytes(result.freedBytes))}`);
2061
- print(` ${dim("They are recoverable from the trash, and their archives are untouched.")}`);
3429
+ print(` ${mark.ok()} Reclaimed ${bold(String(result.reclaimed.length))} source${result.reclaimed.length === 1 ? "" : "s"}, freeing ${bold(bytes(result.freedBytes))} from their native session paths`);
3430
+ print(` ${dim("Their verified encrypted archives remain stored; Codex sessions keep a tiny same-id resume pointer.")}`);
2062
3431
  for (const failure of result.failed)
2063
3432
  print(` ${mark.warn()} ${dim(failure.id.slice(0, 8))} ${dim(failure.error)}`);
2064
3433
  return;
@@ -2099,35 +3468,195 @@ async function main() {
2099
3468
  ...(idleDays === undefined ? {} : { sourceIdleDays: nullable(idleDays) }),
2100
3469
  ...(minBytes === undefined ? {} : { minSourceBytes: nullable(minBytes) })
2101
3470
  });
3471
+ // The policy is account/vault state; unattended deletion is a separate
3472
+ // permission on this machine. A keep-originals policy must revoke that
3473
+ // permission and remove the stale --reclaim invocation from an installed
3474
+ // service. Otherwise changing the policy again later could resurrect
3475
+ // deletion without a new machine opt-in.
3476
+ const machineSafety = await reconcileMachineRetentionPolicy(dataDir, config.retention.policy, {
3477
+ home: take(args, "--home"),
3478
+ cliPath: process.argv[1],
3479
+ });
2102
3480
  print(` ${mark.ok()} Policy set to ${bold(config.retention.policy)} ${dim(`· ${describe(retentionSettings(config))}`)}`);
3481
+ if (machineSafety.reclaimPermissionRevoked) {
3482
+ print(` ${mark.ok()} Automatic reclaim is off on this machine; its timing and other settings were kept.`);
3483
+ }
3484
+ if (machineSafety.serviceReconciled) {
3485
+ print(` ${dim("The installed background service now follows that safe setting.")}`);
3486
+ }
2103
3487
  return;
2104
3488
  }
2105
3489
  fail("invalid_argument", "Usage: sealkeep retention <preview|apply|prune|offload|approve|policy> [options]");
2106
3490
  }
2107
3491
  if (command === "daemon") {
2108
- const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "No recovery phrase available. Run `sealkeep autopilot` so this machine can unlock itself, or set SEALKEEP_RECOVERY_PHRASE.");
2109
- const reclaim = args.includes("--reclaim");
2110
- const daemon = await startDaemon(dataDir, {
2111
- phrase, intervalMs: Number(take(args, "--interval", "30")) * 1000, home: take(args, "--home"),
2112
- watch: !args.includes("--no-watch"), upload: !args.includes("--no-upload"), reclaim,
2113
- notifications: args.includes("--quiet") ? false : undefined,
2114
- onTick: (result) => {
2115
- if (!(result.archived || result.uploaded || result.reclaimed || result.failed))
2116
- return;
2117
- const parts = [result.archived && `${result.archived} archived`, result.uploaded && `${result.uploaded} uploaded`, result.reclaimed && `${result.reclaimed} reclaimed`, result.failed && `${result.failed} failed`].filter(Boolean);
2118
- print(` ${dim(new Date().toLocaleTimeString())} ${parts.join(dim(" · "))}`);
2119
- }
3492
+ const requestedReclaim = args.includes("--reclaim");
3493
+ const watch = !args.includes("--no-watch");
3494
+ // New service receipts say this in argv. The ownership marker is retained
3495
+ // only for the first process after upgrading an older receipt: the stable
3496
+ // manager launches the new package with its old argv once, then daemon
3497
+ // reconciliation rewrites the receipt with --serve-ui. An ordinary
3498
+ // foreground daemon has neither signal and still binds no UI port.
3499
+ const serviceOwned = Boolean(process.env.SEALKEEP_SERVICE_MANAGER_PATH && process.env.SEALKEEP_SERVICE_RECEIPT_PATH);
3500
+ // A native service is long-lived and its local UI owns this machine
3501
+ // setting. Read the persisted value at startup even when an older service
3502
+ // receipt omitted --reclaim; startDaemon keeps following later changes.
3503
+ // An explicitly launched foreground daemon retains flag-only semantics.
3504
+ const reclaim = serviceOwned
3505
+ ? (await readLocalSettings(dataDir)).reclaimEnabled
3506
+ : requestedReclaim;
3507
+ const serveUi = args.includes("--serve-ui") || serviceOwned;
3508
+ const uiPortValue = take(args, "--ui-port", String(DEFAULT_UI_PORT));
3509
+ const uiPort = Number(uiPortValue);
3510
+ if (serveUi && (!Number.isInteger(uiPort) || uiPort < 0 || uiPort > 65_535)) {
3511
+ fail("invalid_argument", "--ui-port must be an integer from 0 to 65535");
3512
+ }
3513
+ // A manager installed by the previous release cannot apply this release's
3514
+ // pre-Node taskpolicy wrapper to the first upgraded child. Repair its
3515
+ // durable files before taking either singleton lease, then exit with the
3516
+ // manager's established upgrade code. launchd relaunches once through the
3517
+ // newly written manager; its generation marker prevents a loop.
3518
+ const handedOffLegacyManager = await prepareLegacyDarwinManagerHandoff({
3519
+ serviceOwned,
3520
+ reconcile: async () => {
3521
+ const service = await import("./service.js");
3522
+ const invocation = service.daemonInvocation(dataDir, {
3523
+ reclaim,
3524
+ upload: !args.includes("--no-upload"),
3525
+ intervalSeconds: Number(take(args, "--interval", "30")),
3526
+ });
3527
+ const options = {
3528
+ dataDir,
3529
+ home: take(args, "--home"),
3530
+ executable: invocation.executable,
3531
+ args: invocation.args,
3532
+ environment: service.serviceUnitEnvironment({}, process.env, {
3533
+ uploads: !args.includes("--no-upload"),
3534
+ }),
3535
+ };
3536
+ const plan = service.servicePlan(options);
3537
+ if (process.env.SEALKEEP_SERVICE_MANAGER_PATH !== plan.managerPath
3538
+ || process.env.SEALKEEP_SERVICE_RECEIPT_PATH !== plan.receiptPath)
3539
+ return false;
3540
+ return service.reconcileInstalledService(options);
3541
+ },
2120
3542
  });
2121
- const status = await daemon.status();
3543
+ if (handedOffLegacyManager) {
3544
+ process.exitCode = 75;
3545
+ return;
3546
+ }
3547
+ // Plist reconciliation makes the corrected scheduling policy durable, but
3548
+ // launchd does not retroactively apply an edited ProcessType to this
3549
+ // already-running generation. Clear only the inherited Darwin background
3550
+ // flag before binding the user-facing loopback UI. Nice and low-priority
3551
+ // disk I/O remain in force, and inability to normalize never blocks setup,
3552
+ // recovery, or preservation.
3553
+ await normalizeDarwinServicePolicy({ serviceOwned });
3554
+ // The settings surface is the recovery path for a service whose remembered
3555
+ // phrase is missing. Start it before unlock, and keep it alive in a
3556
+ // truthful locked state while preservation remains inactive.
3557
+ let wakeForRecovery = null;
3558
+ let recoveryAvailable = new Promise((resolve) => { wakeForRecovery = resolve; });
3559
+ // Returning true tells this process's local API not to start its own queue
3560
+ // worker: this daemon is about to become the one durable owner. The bounded
3561
+ // poll below is still needed when the supervisor borrowed an interactive UI
3562
+ // whose server was created before this callback existed.
3563
+ const recoveryPhraseAvailable = () => { wakeForRecovery?.(); return true; };
3564
+ const supervisedUi = serveUi
3565
+ ? await startUiSupervisor(dataDir, { port: uiPort, onRecoveryPhraseAvailable: recoveryPhraseAvailable }).catch((error) => {
3566
+ print(` ${mark.warn()} Local UI is not supervised yet: ${errorPayload(error).error.message}`);
3567
+ return null;
3568
+ })
3569
+ : null;
3570
+ // The supervisor lease is the service-generation election. A borrower is
3571
+ // proof that another service process already owns this vault; continuing
3572
+ // would create a second queue worker even though the UI itself was safely
3573
+ // reused. Exit before unlock and before startDaemon can acquire any work.
3574
+ if (supervisedUi && !supervisedUi.owned) {
3575
+ await supervisedUi.close();
3576
+ return;
3577
+ }
3578
+ let phrase = await unlock(dataDir, take(args, "--recovery-phrase"));
3579
+ if (!phrase) {
3580
+ if (!serveUi)
3581
+ required(phrase, "No recovery phrase available. Run `sealkeep autopilot` so this machine can unlock itself, or set SEALKEEP_RECOVERY_PHRASE.");
3582
+ if (!supervisedUi)
3583
+ fail("recovery_phrase_missing", "Sealkeep is locked and its local settings window could not start. No preservation work is running.");
3584
+ const uiStatus = await supervisedUi.status();
3585
+ print(`\n${BRAND} ${amber("locked")}`);
3586
+ print(keyValue([
3587
+ ["Preservation", dim("paused · recovery phrase is not stored on this machine")],
3588
+ ["Local UI", uiStatus.available && uiStatus.origin ? uiStatus.origin : dim("retrying in the background")],
3589
+ ]));
3590
+ print(` ${dim("Open it with `sealkeep ui`, then use Settings › Unlocking. Ctrl-C to stop.")}\n`);
3591
+ let closing = null;
3592
+ const closeLocked = () => closing ??= supervisedUi.close();
3593
+ const lockedSignals = ["SIGINT", "SIGTERM"].map((signal) => {
3594
+ const handler = () => { void closeLocked().then(() => process.exit(0)); };
3595
+ process.on(signal, handler);
3596
+ return [signal, handler];
3597
+ });
3598
+ while (!phrase) {
3599
+ await Promise.race([
3600
+ recoveryAvailable,
3601
+ new Promise((resolve) => setTimeout(resolve, 500)),
3602
+ ]);
3603
+ phrase = await unlock(dataDir, take(args, "--recovery-phrase"));
3604
+ recoveryAvailable = new Promise((resolve) => { wakeForRecovery = resolve; });
3605
+ }
3606
+ for (const [signal, handler] of lockedSignals)
3607
+ process.off(signal, handler);
3608
+ }
3609
+ let daemon;
3610
+ try {
3611
+ daemon = await startDaemon(dataDir, {
3612
+ phrase, intervalMs: Number(take(args, "--interval", "30")) * 1000, home: take(args, "--home"),
3613
+ watch, upload: !args.includes("--no-upload"), reclaim, reclaimFromSettings: serviceOwned,
3614
+ notifications: args.includes("--quiet") ? false : undefined,
3615
+ onTick: (result) => {
3616
+ if (!(result.archived || result.uploaded || result.reclaimed || result.failed))
3617
+ return;
3618
+ const parts = [result.archived && `${result.archived} archived`, result.uploaded && `${result.uploaded} uploaded`, result.reclaimed && `${result.reclaimed} reclaimed`, result.failed && `${result.failed} failed`].filter(Boolean);
3619
+ print(` ${dim(new Date().toLocaleTimeString())} ${parts.join(dim(" · "))}`);
3620
+ }
3621
+ });
3622
+ }
3623
+ catch (error) {
3624
+ // An orphaned worker can outlive its service manager. If this replacement
3625
+ // won the UI lease but not the vault-worker election, do not remain alive
3626
+ // as a UI-only child forever: release the listener so the manager can
3627
+ // retry and become the worker after the old generation exits.
3628
+ await supervisedUi?.close().catch(() => undefined);
3629
+ throw error;
3630
+ }
3631
+ // Start durable work before rendering diagnostics. `status()` enumerates
3632
+ // every queue record; on a large, nearly-full vault that read used to race
3633
+ // startup discovery and indexing for minutes while the worker held zero
3634
+ // leases. The service does not need a queue census to begin preserving.
3635
+ const firstPass = daemon.tick();
3636
+ // The UI lane is already alive: slow archive work cannot delay Settings.
3637
+ const uiStatus = supervisedUi ? await supervisedUi.status() : null;
2122
3638
  print(`\n${BRAND} ${green("watching")}`);
2123
3639
  print(keyValue([
2124
- ["Roots", status.watching.length ? status.watching.map((root) => shortPath(root, 50)).join("\n ") : dim("none found")],
2125
- ["Reclaim", reclaim ? "enabled" : dim("disabled")]
3640
+ ["Roots", watch ? dim("discovering agent folders automatically") : dim("watching disabled")],
3641
+ ["Reclaim", reclaim ? "enabled" : dim("disabled")],
3642
+ ...(serveUi ? [["Local UI", uiStatus?.available && uiStatus.origin
3643
+ ? `${uiStatus.origin}${uiStatus.fallback ? dim(` · ${DEFAULT_UI_PORT} was busy`) : ""}`
3644
+ : dim("retrying in the background")]] : [])
2126
3645
  ]));
2127
3646
  print(` ${dim("Ctrl-C to stop.")}\n`);
3647
+ let shutdown = null;
3648
+ const close = () => shutdown ??= (async () => {
3649
+ await Promise.allSettled([supervisedUi?.close(), daemon.close()]);
3650
+ })();
2128
3651
  for (const signal of ["SIGINT", "SIGTERM"])
2129
- process.on(signal, () => { void daemon.close().then(() => process.exit(0)); });
2130
- await daemon.tick();
3652
+ process.on(signal, () => { void close().then(() => process.exit(0)); });
3653
+ try {
3654
+ await firstPass;
3655
+ }
3656
+ catch (error) {
3657
+ await close();
3658
+ throw error;
3659
+ }
2131
3660
  return;
2132
3661
  }
2133
3662
  print(`${mark.fail()} Unknown command: ${command}\n${hint(`run ${cmd("sealkeep help")}`)}`);
@@ -2137,6 +3666,12 @@ const HINTS = {
2137
3666
  vault_not_initialized: "run `sealkeep quickstart`",
2138
3667
  recovery_phrase_missing: "pass --recovery-phrase, or set SEALKEEP_RECOVERY_PHRASE",
2139
3668
  recovery_phrase_mismatch: "check the phrase from your recovery kit",
3669
+ // Deliberately names the free path first: most people hitting this do not
3670
+ // need to pay at all, they need their own bucket, which has always been free.
3671
+ payment_required: "your own bucket and your own Drive stay free — `sealkeep storage configure …`; or pick a plan at https://sealkeep.spala.ai",
3672
+ // Deliberately does NOT mention the recovery kit: the kit cannot fix an
3673
+ // archive that was never wrapped for this key. Membership is the fix.
3674
+ recipient_key_mismatch: "ask whoever owns the archive to `sealkeep rewrap` it for your key, or open it with the recovery phrase",
2140
3675
  storage_not_configured: "run `sealkeep storage configure --provider … --bucket … --prefix …`",
2141
3676
  signer_not_configured: "set SEALKEEP_ENABLE_SIGNER=1 and store credentials",
2142
3677
  destination_exists: "add --overwrite backup to keep the existing file",