sealkeep 0.9.0 → 0.11.0

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 +223 -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.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1698 -163
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +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
@@ -0,0 +1,47 @@
1
+ import type { ArchiveRecordV2, RemoteObject } from "./types.js";
2
+ /**
3
+ * One physical object has one id, independent of which routing rule currently
4
+ * names it. The version marker makes a future identity scheme explicit rather
5
+ * than silently changing ids already used by an API or operation journal.
6
+ */
7
+ export declare function archiveCopyId(copy: Pick<RemoteObject, "provider" | "bucket" | "objectKey" | "destinationFingerprint">): string;
8
+ /** Returns a copy with the deterministic id old records did not persist. */
9
+ export declare function normalizeRemoteCopy(copy: RemoteObject): RemoteObject;
10
+ /** Stable-order dedupe: the first occurrence keeps its position. */
11
+ export declare function dedupeArchiveCopies(copies: readonly RemoteObject[]): RemoteObject[];
12
+ /** Primary first, followed by unique secondary copies. Never mutates a record. */
13
+ export declare function archiveCopies(record: Pick<ArchiveRecordV2, "remote" | "copies">): RemoteObject[];
14
+ /** Rebuilds the compatibility shape: one primary plus optional secondaries. */
15
+ export declare function normalizeArchiveCopies(record: ArchiveRecordV2): ArchiveRecordV2;
16
+ /** True when any primary or secondary copy has earned a verification marker. */
17
+ export declare function hasVerifiedCopy(record: Pick<ArchiveRecordV2, "remote" | "copies">): boolean;
18
+ /**
19
+ * Non-secret identity used only to prove that separately stored delta links
20
+ * can be fetched from one destination. A mutable target label is secondary to
21
+ * the immutable destination fingerprint. Legacy AWS S3/GCS/B2 records can use
22
+ * their bucket; ambiguous R2/Drive records fail closed unless they retained a
23
+ * target id. A single self-contained archive does not need this join key.
24
+ */
25
+ export declare function verifiedCopyDestinationKey(copy: RemoteObject): string | null;
26
+ /** Destination keys at which this exact archive object has a verified copy. */
27
+ export declare function verifiedCopyDestinations(record: Pick<ArchiveRecordV2, "remote" | "copies">): Set<string>;
28
+ /**
29
+ * True only when one remote destination contains every record needed by a
30
+ * session. Verified links split between Drive and Cloud are not a restorable
31
+ * off-machine chain.
32
+ */
33
+ export declare function hasCompleteVerifiedCopyChain(records: readonly ArchiveRecordV2[]): boolean;
34
+ /**
35
+ * Idempotently adds or refreshes a copy. The first copy becomes primary;
36
+ * `makePrimary` explicitly promotes an existing or newly added destination.
37
+ */
38
+ export declare function appendArchiveCopy(record: ArchiveRecordV2, incoming: RemoteObject, options?: {
39
+ makePrimary?: boolean;
40
+ }): ArchiveRecordV2;
41
+ /** Promotes one known copy; an unknown id is refused rather than silently succeeding. */
42
+ export declare function promoteArchiveCopy(record: ArchiveRecordV2, copyId: string): ArchiveRecordV2;
43
+ /**
44
+ * Idempotently removes a copy. Removing the primary promotes the first surviving
45
+ * secondary, preserving the legacy `remote` contract.
46
+ */
47
+ export declare function removeArchiveCopy(record: ArchiveRecordV2, copyId: string): ArchiveRecordV2;
@@ -0,0 +1,179 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * One physical object has one id, independent of which routing rule currently
4
+ * names it. The version marker makes a future identity scheme explicit rather
5
+ * than silently changing ids already used by an API or operation journal.
6
+ */
7
+ export function archiveCopyId(copy) {
8
+ const location = copy.destinationFingerprint
9
+ ? ["v2", copy.destinationFingerprint, copy.provider.trim().toLowerCase(), copy.bucket, copy.objectKey].join("\0")
10
+ : ["v1", copy.provider.trim().toLowerCase(), copy.bucket, copy.objectKey].join("\0");
11
+ return `copy_${createHash("sha256").update(location).digest("hex")}`;
12
+ }
13
+ /** Returns a copy with the deterministic id old records did not persist. */
14
+ export function normalizeRemoteCopy(copy) {
15
+ return { ...copy, copyId: archiveCopyId(copy) };
16
+ }
17
+ function verifiedTime(copy) {
18
+ const time = Date.parse(copy.verifiedAt);
19
+ return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
20
+ }
21
+ /**
22
+ * Merge two observations of the same object. The newest verification owns the
23
+ * byte/checksum/layout facts; a target id is routing metadata and is retained
24
+ * when either observation has it.
25
+ */
26
+ function mergeSameCopy(current, incoming) {
27
+ const newer = verifiedTime(incoming) >= verifiedTime(current) ? incoming : current;
28
+ const older = newer === incoming ? current : incoming;
29
+ const targetId = newer.targetId ?? older.targetId;
30
+ const merged = {
31
+ ...older,
32
+ ...newer,
33
+ copyId: archiveCopyId(newer),
34
+ ...(targetId ? { targetId } : {}),
35
+ };
36
+ // Missing layout means the current object is the legacy single-object form.
37
+ // A stale chunk observation must not turn it back into a folder.
38
+ if (newer.layout === undefined)
39
+ delete merged.layout;
40
+ return merged;
41
+ }
42
+ /** Stable-order dedupe: the first occurrence keeps its position. */
43
+ export function dedupeArchiveCopies(copies) {
44
+ const result = [];
45
+ const positions = new Map();
46
+ for (const raw of copies) {
47
+ const copy = normalizeRemoteCopy(raw);
48
+ const id = copy.copyId;
49
+ const existing = positions.get(id);
50
+ if (existing === undefined) {
51
+ positions.set(id, result.length);
52
+ result.push(copy);
53
+ }
54
+ else {
55
+ result[existing] = mergeSameCopy(result[existing], copy);
56
+ }
57
+ }
58
+ return result;
59
+ }
60
+ /** Primary first, followed by unique secondary copies. Never mutates a record. */
61
+ export function archiveCopies(record) {
62
+ return dedupeArchiveCopies([...(record.remote ? [record.remote] : []), ...(record.copies ?? [])]);
63
+ }
64
+ /** Rebuilds the compatibility shape: one primary plus optional secondaries. */
65
+ export function normalizeArchiveCopies(record) {
66
+ const all = archiveCopies(record);
67
+ const { remote: _remote, copies: _copies, ...unchanged } = record;
68
+ if (all.length === 0)
69
+ return unchanged;
70
+ const [remote, ...copies] = all;
71
+ return {
72
+ ...unchanged,
73
+ remote,
74
+ ...(copies.length > 0 ? { copies } : {}),
75
+ };
76
+ }
77
+ /** True when any primary or secondary copy has earned a verification marker. */
78
+ export function hasVerifiedCopy(record) {
79
+ return archiveCopies(record).some((copy) => Number.isFinite(Date.parse(copy.verifiedAt)));
80
+ }
81
+ /**
82
+ * Non-secret identity used only to prove that separately stored delta links
83
+ * can be fetched from one destination. A mutable target label is secondary to
84
+ * the immutable destination fingerprint. Legacy AWS S3/GCS/B2 records can use
85
+ * their bucket; ambiguous R2/Drive records fail closed unless they retained a
86
+ * target id. A single self-contained archive does not need this join key.
87
+ */
88
+ export function verifiedCopyDestinationKey(copy) {
89
+ if (!Number.isFinite(Date.parse(copy.verifiedAt)) || typeof copy.provider !== "string")
90
+ return null;
91
+ const provider = copy.provider.trim().toLowerCase();
92
+ if (!provider)
93
+ return null;
94
+ if (copy.destinationFingerprint?.trim())
95
+ return `${provider}\0fingerprint\0${copy.destinationFingerprint.trim()}`;
96
+ if (provider === "vaultline")
97
+ return `${provider}\0managed\0${copy.bucket || "account"}`;
98
+ if (copy.targetId?.trim())
99
+ return `${provider}\0target\0${copy.targetId.trim()}\0${copy.bucket || ""}`;
100
+ if (copy.bucket && provider !== "r2" && provider !== "gdrive")
101
+ return `${provider}\0legacy-bucket\0${copy.bucket}`;
102
+ return null;
103
+ }
104
+ /** Destination keys at which this exact archive object has a verified copy. */
105
+ export function verifiedCopyDestinations(record) {
106
+ return new Set(archiveCopies(record).map(verifiedCopyDestinationKey).filter((key) => Boolean(key)));
107
+ }
108
+ /**
109
+ * True only when one remote destination contains every record needed by a
110
+ * session. Verified links split between Drive and Cloud are not a restorable
111
+ * off-machine chain.
112
+ */
113
+ export function hasCompleteVerifiedCopyChain(records) {
114
+ if (records.length === 0)
115
+ return false;
116
+ if (records.length === 1)
117
+ return hasVerifiedCopy(records[0]);
118
+ let common = null;
119
+ for (const record of records) {
120
+ const destinations = verifiedCopyDestinations(record);
121
+ if (common === null)
122
+ common = destinations;
123
+ else {
124
+ const previous = common;
125
+ common = new Set([...previous].filter((destination) => destinations.has(destination)));
126
+ }
127
+ if (common.size === 0)
128
+ return false;
129
+ }
130
+ return (common?.size ?? 0) > 0;
131
+ }
132
+ /**
133
+ * Idempotently adds or refreshes a copy. The first copy becomes primary;
134
+ * `makePrimary` explicitly promotes an existing or newly added destination.
135
+ */
136
+ export function appendArchiveCopy(record, incoming, options = {}) {
137
+ const all = archiveCopies(record);
138
+ const copy = normalizeRemoteCopy(incoming);
139
+ const existing = all.findIndex((candidate) => candidate.copyId === copy.copyId);
140
+ if (existing >= 0)
141
+ all[existing] = mergeSameCopy(all[existing], copy);
142
+ else
143
+ all.push(copy);
144
+ if (options.makePrimary && all.length > 1) {
145
+ const selected = all.findIndex((candidate) => candidate.copyId === copy.copyId);
146
+ if (selected > 0)
147
+ all.unshift(...all.splice(selected, 1));
148
+ }
149
+ return recordWithCopies(record, all);
150
+ }
151
+ /** Promotes one known copy; an unknown id is refused rather than silently succeeding. */
152
+ export function promoteArchiveCopy(record, copyId) {
153
+ const all = archiveCopies(record);
154
+ const selected = all.findIndex((copy) => copy.copyId === copyId);
155
+ if (selected < 0)
156
+ throw new RangeError(`Archive ${record.id} has no remote copy ${copyId}`);
157
+ if (selected > 0)
158
+ all.unshift(...all.splice(selected, 1));
159
+ return recordWithCopies(record, all);
160
+ }
161
+ /**
162
+ * Idempotently removes a copy. Removing the primary promotes the first surviving
163
+ * secondary, preserving the legacy `remote` contract.
164
+ */
165
+ export function removeArchiveCopy(record, copyId) {
166
+ return recordWithCopies(record, archiveCopies(record).filter((copy) => copy.copyId !== copyId));
167
+ }
168
+ function recordWithCopies(record, copies) {
169
+ const normal = dedupeArchiveCopies(copies);
170
+ const { remote: _remote, copies: _copies, ...unchanged } = record;
171
+ if (normal.length === 0)
172
+ return unchanged;
173
+ const [remote, ...secondary] = normal;
174
+ return {
175
+ ...unchanged,
176
+ remote,
177
+ ...(secondary.length > 0 ? { copies: secondary } : {}),
178
+ };
179
+ }
@@ -1,4 +1,4 @@
1
- export type AuditAction = "archive.create" | "archive.restore" | "archive.migrate" | "archive.rewrap" | "archive.prune" | "archive.offload" | "archive.delete" | "phrase.rotate" | "password.change" | "archive.share" | "source.reclaim" | "recipient.add" | "recipient.remove" | "upload.verify" | "remote.check" | "retention.approve";
1
+ export type AuditAction = "archive.create" | "index.tokens" | "index.merge" | "archive.restore" | "archive.restore_local" | "archive.sealed_copy_download" | "archive.migrate" | "archive.rewrap" | "archive.prune" | "archive.offload" | "archive.delete" | "archive.copy_delete" | "phrase.rotate" | "password.change" | "archive.share" | "source.reclaim" | "recipient.add" | "recipient.remove" | "upload.verify" | "remote.check" | "retention.approve" | "storage.gdrive_connect" | "storage.target_connect" | "storage.targets_update" | "phrase.reveal" | "recovery.codes_created" | "bridge.share" | "project.sharing" | "project.draft";
2
2
  export type LocalAuditEvent = {
3
3
  id: string;
4
4
  at: string;
package/dist/src/audit.js CHANGED
@@ -1,8 +1,19 @@
1
- import { appendFile, mkdir, readFile, rename, stat } from "node:fs/promises";
1
+ import { mkdir, open, readFile, rename, stat } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  const MAX_BYTES = 5 * 1024 * 1024;
5
5
  export const auditPath = (dataDir) => join(dataDir, "audit.jsonl");
6
+ async function syncAuditDirectory(dataDir) {
7
+ if (process.platform === "win32")
8
+ return;
9
+ const directory = await open(dataDir, "r");
10
+ try {
11
+ await directory.sync();
12
+ }
13
+ finally {
14
+ await directory.close();
15
+ }
16
+ }
6
17
  /**
7
18
  * Append-only local audit log.
8
19
  *
@@ -14,10 +25,24 @@ export async function recordAudit(dataDir, action, outcome, detail) {
14
25
  const event = { id: randomUUID(), at: new Date().toISOString(), action, outcome, detail };
15
26
  const path = auditPath(dataDir);
16
27
  await mkdir(dataDir, { recursive: true, mode: 0o700 });
17
- const size = await stat(path).then((info) => info.size).catch(() => 0);
18
- if (size > MAX_BYTES)
28
+ const current = await stat(path).catch(() => null);
29
+ if ((current?.size ?? 0) > MAX_BYTES) {
19
30
  await rename(path, `${path}.1`).catch(() => undefined);
20
- await appendFile(path, `${JSON.stringify(event)}\n`, { mode: 0o600 });
31
+ await syncAuditDirectory(dataDir);
32
+ }
33
+ // A destructive decision is not "committed" merely because bytes reached a
34
+ // userspace stream. fsync the append before returning so reclaim can safely
35
+ // place its durable purge-authorized marker afterwards.
36
+ const handle = await open(path, "a", 0o600);
37
+ try {
38
+ await handle.writeFile(`${JSON.stringify(event)}\n`, "utf8");
39
+ await handle.sync();
40
+ }
41
+ finally {
42
+ await handle.close();
43
+ }
44
+ if (!current || current.size > MAX_BYTES)
45
+ await syncAuditDirectory(dataDir);
21
46
  return event;
22
47
  }
23
48
  export async function readAudit(dataDir, options = {}) {
@@ -1,19 +1,28 @@
1
1
  import { ArchiveQueue } from "./queue.js";
2
- import { type RetentionSettings } from "./retention.js";
2
+ import { type RetentionPolicy, type RetentionSettings } from "./retention.js";
3
3
  import { type BackendName } from "./secrets.js";
4
4
  import { serviceStatus, type ServiceOptions, type ServiceOutcome } from "./service.js";
5
5
  import { type Liveness } from "./heartbeat.js";
6
+ import type { McpExec } from "./mcp-install.js";
7
+ import { type UiRuntimeStatus } from "./ui-server.js";
6
8
  export type AutopilotOptions = {
7
9
  phrase?: string;
8
10
  /** Keep the phrase in this machine's keystore so the service can run unattended. */
9
11
  remember?: boolean;
10
- /** Let the service reclaim disk once a remote copy is verified. */
12
+ /**
13
+ * Let the service reclaim disk once every safety gate passes. Omitted means
14
+ * "keep this machine's saved choice"; a machine with no saved choice starts
15
+ * false. This distinction is what makes a package upgrade idempotent without
16
+ * turning deletion on for a fresh install.
17
+ */
11
18
  reclaim?: boolean;
12
19
  olderThanDays?: number;
13
20
  graceDays?: number;
14
21
  home?: string;
15
22
  platform?: NodeJS.Platform;
16
23
  exec?: ServiceOptions["exec"];
24
+ /** Separate seam for agent-owned MCP installers; falls back to `exec` in tests. */
25
+ mcpExec?: McpExec;
17
26
  cliPath?: string;
18
27
  /** Pin the keystore. Omitted, the strongest backend this machine offers is used. */
19
28
  backend?: BackendName;
@@ -30,6 +39,8 @@ export type AutopilotResult = {
30
39
  retention: RetentionSettings;
31
40
  service: ServiceOutcome;
32
41
  agents: string[];
42
+ /** Agents whose complete automatic memory lifecycle is installed (or would be in a dry run). */
43
+ hooks: string[];
33
44
  found: {
34
45
  sessions: number;
35
46
  bytes: number;
@@ -39,14 +50,35 @@ export type AutopilotResult = {
39
50
  hasRemoteTarget: boolean;
40
51
  notes: string[];
41
52
  };
53
+ export type RetentionMachineSafety = {
54
+ /** Whether this call changed the machine's standing deletion permission. */
55
+ reclaimPermissionRevoked: boolean;
56
+ /** Whether an Autopilot service definition belongs to this vault. */
57
+ serviceInstalled: boolean;
58
+ /** Whether that installed definition was rewritten with the safe invocation. */
59
+ serviceReconciled: boolean;
60
+ };
61
+ /**
62
+ * Keep a non-reclaim retention policy and the machine that executes it in sync.
63
+ *
64
+ * Retention timing lives in the vault config, while unattended deletion is a
65
+ * separate, machine-local permission and an installed service also remembers
66
+ * its invocation. Changing only the policy is safe today, but leaves enough
67
+ * stale state for a later policy edit or older service generation to resume
68
+ * deletion without a new machine opt-in. Revoke that permission and rewrite an
69
+ * already-installed service receipt, preserving the schedule and every other
70
+ * local setting. A reclaim-capable policy deliberately grants nothing here.
71
+ */
72
+ export declare function reconcileMachineRetentionPolicy(dataDir: string, policy: RetentionPolicy, options?: Pick<AutopilotOptions, "home" | "platform" | "exec" | "cliPath">): Promise<RetentionMachineSafety>;
42
73
  /**
43
74
  * Turns Sealkeep into something that runs itself.
44
75
  *
45
- * One call sets up the vault, teaches this machine to unlock it, picks a retention
46
- * policy, sweeps up the sessions already on disk, and installs a login service. The
47
- * safety gates are unchanged: reclamation still requires a verified remote copy, an
48
- * idle file, and an elapsed grace period, so enabling it here cannot delete anything
49
- * until a bucket is configured and an upload has been checked.
76
+ * One call sets up the vault, teaches this machine to unlock it, sweeps up the
77
+ * sessions already on disk, and installs a login service. Preservation, indexing,
78
+ * and uploads are automatic. Reclaim is a separate standing permission: it is off
79
+ * on a fresh machine and is enabled only by an explicit option or an already-saved
80
+ * machine choice. Even then, the ordinary remote, search, identity, age, grace and
81
+ * liveness gates remain mandatory.
50
82
  */
51
83
  export declare function enableAutopilot(dataDir: string, options?: AutopilotOptions): Promise<AutopilotResult>;
52
84
  export type AutopilotStatus = {
@@ -55,9 +87,13 @@ export type AutopilotStatus = {
55
87
  live: Liveness;
56
88
  service: Awaited<ReturnType<typeof serviceStatus>>;
57
89
  phraseAvailable: boolean;
90
+ /** Effective per-machine permission followed by the installed service. */
91
+ reclaimEnabled: boolean;
58
92
  retention: RetentionSettings;
59
93
  hasRemoteTarget: boolean;
60
94
  queue: Awaited<ReturnType<ArchiveQueue["stats"]>>;
95
+ /** Live listener truth, including a recorded fallback when 7477 was busy. */
96
+ ui: UiRuntimeStatus;
61
97
  };
62
98
  export declare function autopilotStatus(dataDir: string, options?: AutopilotOptions): Promise<AutopilotStatus>;
63
99
  /**
@@ -65,6 +101,13 @@ export declare function autopilotStatus(dataDir: string, options?: AutopilotOpti
65
101
  * automation off is not a way to lose data.
66
102
  */
67
103
  export declare function disableAutopilot(dataDir: string, options?: AutopilotOptions): Promise<{
104
+ service: ServiceOutcome;
105
+ forgotPhrase: boolean;
106
+ retention: RetentionSettings;
107
+ archivesKept: boolean;
108
+ hooksRemoved: string[];
109
+ hookOutcomes: never[];
110
+ } | {
68
111
  service: ServiceOutcome;
69
112
  forgotPhrase: boolean;
70
113
  retention: {
@@ -74,4 +117,6 @@ export declare function disableAutopilot(dataDir: string, options?: AutopilotOpt
74
117
  deleteAfterDays?: number | null;
75
118
  };
76
119
  archivesKept: boolean;
120
+ hooksRemoved: import("./adapters.js").AgentId[];
121
+ hookOutcomes: import("./adapters.js").HookRemoveOutcome[];
77
122
  }>;
@@ -1,29 +1,81 @@
1
1
  import { resolveSyncRules, applySyncRules, describeSyncRules } from "./sync-rules.js";
2
- import { detectAgents, findTranscripts } from "./adapters.js";
2
+ import { detectAgents, findTranscripts, installAgentHooks, removeOwnedAgentHooks } from "./adapters.js";
3
3
  import { fail } from "./errors.js";
4
4
  import { setupVault } from "./onboarding.js";
5
5
  import { ArchiveQueue } from "./queue.js";
6
6
  import { retentionSettings, setRetentionPolicy } from "./retention.js";
7
7
  import { forgetRecoveryPhrase, rememberRecoveryPhrase, resolveRecoveryPhrase } from "./secrets.js";
8
- import { daemonInvocation, installService, serviceStatus, servicePlan, serviceUnitEnvironment, uninstallService } from "./service.js";
8
+ import { daemonInvocation, installService, reconcileInstalledService, serviceStatus, servicePlan, serviceUnitEnvironment, uninstallService } from "./service.js";
9
9
  import { readConfig } from "./vault.js";
10
10
  import { liveness, readHeartbeat } from "./heartbeat.js";
11
+ import { uiRuntimeStatus } from "./ui-server.js";
12
+ import { readLocalSettings, writeLocalSettings } from "./machine-settings.js";
11
13
  const DEFAULTS = { olderThanDays: 30, graceDays: 7 };
14
+ const policyCanReclaim = (policy) => policy === "archive-and-reclaim" || policy === "manual-approval";
15
+ /**
16
+ * Keep a non-reclaim retention policy and the machine that executes it in sync.
17
+ *
18
+ * Retention timing lives in the vault config, while unattended deletion is a
19
+ * separate, machine-local permission and an installed service also remembers
20
+ * its invocation. Changing only the policy is safe today, but leaves enough
21
+ * stale state for a later policy edit or older service generation to resume
22
+ * deletion without a new machine opt-in. Revoke that permission and rewrite an
23
+ * already-installed service receipt, preserving the schedule and every other
24
+ * local setting. A reclaim-capable policy deliberately grants nothing here.
25
+ */
26
+ export async function reconcileMachineRetentionPolicy(dataDir, policy, options = {}) {
27
+ const local = await readLocalSettings(dataDir);
28
+ if (policyCanReclaim(policy)) {
29
+ return { reclaimPermissionRevoked: false, serviceInstalled: false, serviceReconciled: false };
30
+ }
31
+ const reclaimPermissionRevoked = local.reclaimEnabled;
32
+ if (reclaimPermissionRevoked) {
33
+ await writeLocalSettings(dataDir, { ...local, reclaimEnabled: false });
34
+ }
35
+ const invocation = daemonInvocation(dataDir, {
36
+ reclaim: false,
37
+ cliPath: options.cliPath,
38
+ intervalSeconds: local.schedule.intervalMinutes * 60,
39
+ });
40
+ const serviceOptions = {
41
+ dataDir,
42
+ executable: invocation.executable,
43
+ args: invocation.args,
44
+ home: options.home,
45
+ platform: options.platform,
46
+ exec: options.exec,
47
+ environment: serviceUnitEnvironment(),
48
+ };
49
+ const installed = await serviceStatus(serviceOptions);
50
+ if (!installed.installed) {
51
+ return { reclaimPermissionRevoked, serviceInstalled: false, serviceReconciled: false };
52
+ }
53
+ const serviceReconciled = await reconcileInstalledService(serviceOptions);
54
+ if (!serviceReconciled) {
55
+ fail("internal", "Automatic reclaim is off, but the installed Sealkeep service could not be updated. Run `sealkeep autopilot` to repair it before turning reclaim on again.");
56
+ }
57
+ return { reclaimPermissionRevoked, serviceInstalled: true, serviceReconciled };
58
+ }
12
59
  /**
13
60
  * Turns Sealkeep into something that runs itself.
14
61
  *
15
- * One call sets up the vault, teaches this machine to unlock it, picks a retention
16
- * policy, sweeps up the sessions already on disk, and installs a login service. The
17
- * safety gates are unchanged: reclamation still requires a verified remote copy, an
18
- * idle file, and an elapsed grace period, so enabling it here cannot delete anything
19
- * until a bucket is configured and an upload has been checked.
62
+ * One call sets up the vault, teaches this machine to unlock it, sweeps up the
63
+ * sessions already on disk, and installs a login service. Preservation, indexing,
64
+ * and uploads are automatic. Reclaim is a separate standing permission: it is off
65
+ * on a fresh machine and is enabled only by an explicit option or an already-saved
66
+ * machine choice. Even then, the ordinary remote, search, identity, age, grace and
67
+ * liveness gates remain mandatory.
20
68
  */
21
69
  export async function enableAutopilot(dataDir, options = {}) {
22
70
  const notes = [];
23
71
  const remember = options.remember !== false;
24
- const reclaim = options.reclaim !== false;
25
- const setup = await setupVault(dataDir, options.phrase);
72
+ const setup = await setupVault(dataDir, options.phrase, {
73
+ home: options.home,
74
+ mcpExec: options.mcpExec ?? options.exec
75
+ });
26
76
  const config = await readConfig(dataDir);
77
+ const localSettings = await readLocalSettings(dataDir);
78
+ const reclaim = options.reclaim ?? localSettings.reclaimEnabled;
27
79
  const phrase = setup.recoveryPhrase ?? (await resolveRecoveryPhrase(dataDir, config.vaultId, options.phrase, process.env, options.backend));
28
80
  if (!phrase) {
29
81
  fail("recovery_phrase_missing", "This vault already exists and its phrase is not on this machine. Re-run with --recovery-phrase so the service can unlock it.", { dataDir });
@@ -40,13 +92,45 @@ export async function enableAutopilot(dataDir, options = {}) {
40
92
  else if (!remember) {
41
93
  notes.push("The phrase was not stored. Put SEALKEEP_RECOVERY_PHRASE in the service environment yourself, or sessions will queue up unsealed.");
42
94
  }
43
- const desired = { policy: reclaim ? "archive-and-reclaim" : "sync-only", olderThanDays: options.olderThanDays ?? DEFAULTS.olderThanDays, graceDays: options.graceDays ?? DEFAULTS.graceDays };
95
+ const currentRetention = retentionSettings(config);
96
+ const currentPolicyCanReclaim = policyCanReclaim(currentRetention.policy);
97
+ // Keep an existing policy (including manual approval) across an upgrade. A
98
+ // fresh vault has no saved policy, so its useful non-destructive mode is
99
+ // sync-only: archive/index/upload automatically while originals remain.
100
+ const policy = reclaim
101
+ ? currentPolicyCanReclaim ? currentRetention.policy : "archive-and-reclaim"
102
+ : config.retention?.policy ?? "sync-only";
103
+ const desired = {
104
+ policy,
105
+ olderThanDays: options.olderThanDays ?? config.retention?.olderThanDays ?? DEFAULTS.olderThanDays,
106
+ graceDays: options.graceDays ?? config.retention?.graceDays ?? DEFAULTS.graceDays,
107
+ };
44
108
  // A vault written before archive lifecycle existed has no deleteAfterDays; absent means never.
45
109
  const stored = options.dryRun ? undefined : (await setRetentionPolicy(dataDir, desired)).retention;
46
110
  const retention = retentionSettings(config, { ...desired, ...stored });
111
+ // The native service reads this setting on every pass. Persist the effective
112
+ // choice even when it is the fresh false default, so its behavior, generated
113
+ // unit, status JSON, and the Settings page all describe the same permission.
114
+ if (!options.dryRun) {
115
+ await writeLocalSettings(dataDir, { ...localSettings, reclaimEnabled: reclaim });
116
+ }
47
117
  const installs = await detectAgents(options.home);
48
118
  const detected = installs.filter((install) => install.detected);
49
- const found = (await Promise.all(detected.map((install) => findTranscripts(install.agent, options.home, 500)))).flat();
119
+ const found = (await Promise.all(detected.map((install) => findTranscripts(install.agent, options.home)))).flat();
120
+ // `autopilot` is the explicit one-time request for unattended behavior. A
121
+ // fresh CLI setup that installed only the watcher service used to preserve
122
+ // sessions but never inject their memory into the agent. Install both halves
123
+ // here; an existing hook is reconciled idempotently and custom hooks survive.
124
+ const hookOutcomes = options.dryRun
125
+ ? []
126
+ : await installAgentHooks(detected.map((install) => install.agent), { dataDir, home: options.home });
127
+ const hooks = options.dryRun
128
+ ? detected.map((install) => install.agent)
129
+ : hookOutcomes.filter((outcome) => outcome.installed).map((outcome) => outcome.agent);
130
+ for (const outcome of hookOutcomes) {
131
+ if (!outcome.installed && outcome.reason)
132
+ notes.push(`${outcome.agent} hooks were not installed: ${outcome.reason}`);
133
+ }
50
134
  // What leaves this machine is decided here, before anything is read or sealed.
51
135
  const { rules, source } = await resolveSyncRules(dataDir);
52
136
  const { included, heldBack } = applySyncRules(found, rules);
@@ -68,10 +152,9 @@ export async function enableAutopilot(dataDir, options = {}) {
68
152
  // telling it to run `storage configure` sends a paying customer to set up
69
153
  // storage they are already paying us to run.
70
154
  //
71
- // Resolved before the service is written, because the service definition
72
- // depends on it: a machine with somewhere to upload needs the daemon to have
73
- // uploads enabled, and a service manager starts the daemon with a bare
74
- // environment rather than inheriting the shell that installed it.
155
+ // This remains useful setup/status truth, but it does not decide whether the
156
+ // service is upload-capable. A target connected tomorrow must be picked up by
157
+ // the already-running daemon without reinstalling or restarting it.
75
158
  let managedEmail = null;
76
159
  if (!config.remoteStorage) {
77
160
  try {
@@ -81,15 +164,17 @@ export async function enableAutopilot(dataDir, options = {}) {
81
164
  catch { /* not signed in */ }
82
165
  }
83
166
  const hasRemoteTarget = Boolean(config.remoteStorage) || managedEmail !== null;
84
- const invocation = daemonInvocation(dataDir, { reclaim, cliPath: options.cliPath });
167
+ const invocation = daemonInvocation(dataDir, {
168
+ reclaim,
169
+ cliPath: options.cliPath,
170
+ intervalSeconds: localSettings.schedule.intervalMinutes * 60,
171
+ });
85
172
  const serviceOptions = {
86
173
  dataDir, executable: invocation.executable, args: invocation.args,
87
174
  home: options.home, platform: options.platform, exec: options.exec,
88
- // Installing autopilot against a configured target is the opt-in. Without
89
- // this the daemon takes the "uploads are disabled" branch on every tick,
90
- // and since reclaiming requires a verified remote copy, it also never frees
91
- // a byte — the one thing the user installed it to do.
92
- environment: serviceUnitEnvironment(hasRemoteTarget ? { SEALKEEP_ENABLE_SIGNER: "1" } : {})
175
+ // Automatic service ownership is the upload opt-in. Targets and credentials
176
+ // still fail closed, but connecting them later requires no service rewrite.
177
+ environment: serviceUnitEnvironment()
93
178
  };
94
179
  const service = options.dryRun
95
180
  ? { kind: servicePlan(serviceOptions).kind, installed: false, path: servicePlan(serviceOptions).path, ranCommands: [], note: "dry run: nothing was installed" }
@@ -106,6 +191,7 @@ export async function enableAutopilot(dataDir, options = {}) {
106
191
  retention,
107
192
  service,
108
193
  agents: detected.map((install) => install.agent),
194
+ hooks,
109
195
  found: { sessions: found.length, bytes: found.reduce((total, candidate) => total + candidate.bytes, 0) },
110
196
  queued,
111
197
  reclaimEnabled: reclaim,
@@ -115,16 +201,32 @@ export async function enableAutopilot(dataDir, options = {}) {
115
201
  }
116
202
  export async function autopilotStatus(dataDir, options = {}) {
117
203
  const config = await readConfig(dataDir);
204
+ let hasRemoteTarget = Boolean(config.remoteStorage) || Boolean(config.storageTargets?.length);
205
+ if (!hasRemoteTarget) {
206
+ try {
207
+ const { accountStatus } = await import("./cloud.js");
208
+ hasRemoteTarget = Boolean((await accountStatus(dataDir))?.email);
209
+ }
210
+ catch { /* not signed in to managed storage */ }
211
+ }
118
212
  const invocation = daemonInvocation(dataDir, { cliPath: options.cliPath });
119
213
  const service = await serviceStatus({ dataDir, executable: invocation.executable, args: invocation.args, home: options.home, platform: options.platform, exec: options.exec });
214
+ const [phraseAvailable, queue, ui, localSettings] = await Promise.all([
215
+ resolveRecoveryPhrase(dataDir, config.vaultId, undefined, process.env, options.backend).then((phrase) => phrase !== null),
216
+ new ArchiveQueue(dataDir).stats(),
217
+ uiRuntimeStatus(dataDir),
218
+ readLocalSettings(dataDir),
219
+ ]);
120
220
  return {
121
221
  running: service.present,
122
222
  live: liveness(await readHeartbeat(dataDir)),
123
223
  service,
124
- phraseAvailable: (await resolveRecoveryPhrase(dataDir, config.vaultId, undefined, process.env, options.backend)) !== null,
224
+ phraseAvailable,
225
+ reclaimEnabled: localSettings.reclaimEnabled,
125
226
  retention: retentionSettings(config),
126
- hasRemoteTarget: Boolean(config.remoteStorage),
127
- queue: await new ArchiveQueue(dataDir).stats()
227
+ hasRemoteTarget,
228
+ queue,
229
+ ui,
128
230
  };
129
231
  }
130
232
  /**
@@ -135,6 +237,19 @@ export async function disableAutopilot(dataDir, options = {}) {
135
237
  const config = await readConfig(dataDir);
136
238
  const invocation = daemonInvocation(dataDir, { cliPath: options.cliPath });
137
239
  const service = await uninstallService({ dataDir, executable: invocation.executable, args: invocation.args, home: options.home, platform: options.platform, exec: options.exec });
240
+ if (service.installed) {
241
+ // A failed native stop is not "off". Keep every dependency the still-live
242
+ // daemon needs, including its remembered phrase and hooks, so a retry is
243
+ // safe and the status remains actionable.
244
+ return {
245
+ service,
246
+ forgotPhrase: false,
247
+ retention: retentionSettings(config),
248
+ archivesKept: true,
249
+ hooksRemoved: [],
250
+ hookOutcomes: [],
251
+ };
252
+ }
138
253
  let forgot = false;
139
254
  if (options.remember !== false) {
140
255
  try {
@@ -144,5 +259,8 @@ export async function disableAutopilot(dataDir, options = {}) {
144
259
  catch { /* nothing stored */ }
145
260
  }
146
261
  const retention = (await setRetentionPolicy(dataDir, { policy: "local-only" })).retention;
147
- return { service, forgotPhrase: forgot, retention, archivesKept: true };
262
+ const localSettings = await readLocalSettings(dataDir);
263
+ await writeLocalSettings(dataDir, { ...localSettings, reclaimEnabled: false });
264
+ const hookOutcomes = await removeOwnedAgentHooks({ home: options.home });
265
+ return { service, forgotPhrase: forgot, retention, archivesKept: true, hooksRemoved: hookOutcomes.filter((outcome) => outcome.removed).map((outcome) => outcome.agent), hookOutcomes };
148
266
  }