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
@@ -0,0 +1,51 @@
1
+ import type { AgentId } from "./adapters.js";
2
+ import { type TrashResult } from "./trash.js";
3
+ export type LocalSettings = {
4
+ version: 1;
5
+ deviceLabel: string;
6
+ watchedAgents: AgentId[];
7
+ /** Reserved for a future adapter-aware custom-root model; not exposed by the current UI. */
8
+ watchedPaths: string[];
9
+ exclusions: string[];
10
+ reclaimEnabled: boolean;
11
+ trashStrategy: "auto" | TrashResult["strategy"];
12
+ schedule: {
13
+ intervalMinutes: number;
14
+ };
15
+ /** Sustained aggregate background network limit in megabits/second. null = safe automatic limit. */
16
+ bandwidth: {
17
+ uploadLimitMbps: number | null;
18
+ };
19
+ /** MB the disk guard always leaves free. null = automatic. */
20
+ diskReserveMb: number | null;
21
+ /**
22
+ * How long a verified encrypted archive remains cached on this machine.
23
+ * This never applies to the original Codex/Claude transcript. null keeps
24
+ * every sealed copy locally; 0 lets a verified duplicate leave immediately.
25
+ */
26
+ localArchiveCacheDays: number | null;
27
+ };
28
+ /**
29
+ * True when an automatic watcher must leave this transcript alone.
30
+ *
31
+ * Entries without wildcards name either one file or a whole directory tree.
32
+ * Entries with `*`, `**`, or `?` use conventional path-glob semantics. This
33
+ * matcher is intentionally about transcript storage paths, not project cwd:
34
+ * the live watcher knows the former before it reads any transcript metadata.
35
+ */
36
+ export declare function transcriptPathIsExcluded(path: string, exclusions: readonly string[], home?: string): boolean;
37
+ /** One policy check shared by filesystem events, lifecycle hooks, and workers. */
38
+ export declare function automaticTranscriptIsEnabled(settings: Pick<LocalSettings, "watchedAgents" | "exclusions">, agent: string, path: string, home?: string): boolean;
39
+ /** Safe defaults: no deletion and no exclusion until the person asks. */
40
+ export declare function defaultLocalSettings(): LocalSettings;
41
+ /** Automatic work must not infer "unlimited" from an older settings file.
42
+ * 8 Mbps stays below common 10–20 Mbps uplinks while a large archive still
43
+ * makes steady progress. The local Settings page exposes this default. */
44
+ export declare const DEFAULT_BACKGROUND_UPLOAD_LIMIT_MBPS = 8;
45
+ export declare function backgroundUploadBytesPerSecond(settings: Pick<LocalSettings, "bandwidth">): number;
46
+ export declare const localSettingsPath: (dataDir: string) => string;
47
+ /** Missing/older fields inherit safe defaults; this file is preferences, not cryptographic state. */
48
+ export declare function readLocalSettings(dataDir: string): Promise<LocalSettings>;
49
+ /** Atomic and owner-only: per-machine preferences are never synced. */
50
+ export declare function writeLocalSettings(dataDir: string, settings: LocalSettings): Promise<void>;
51
+ export declare function resolvedTrashStrategy(settings: LocalSettings): TrashResult["strategy"];
@@ -0,0 +1,166 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { randomBytes } from "node:crypto";
3
+ import { homedir, hostname } from "node:os";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { defaultStrategy } from "./trash.js";
6
+ /**
7
+ * Turns a person-entered exclusion into the same absolute, slash-normalised
8
+ * shape used for transcript candidates. `~` is useful in a machine-local
9
+ * setting, and a relative entry is deliberately relative to that machine's
10
+ * home rather than to whichever directory happened to launch the daemon.
11
+ */
12
+ function normaliseExclusion(value, home) {
13
+ const trimmed = value.trim();
14
+ const expanded = trimmed === "~"
15
+ ? home
16
+ : trimmed.startsWith("~/") || trimmed.startsWith("~\\")
17
+ ? join(home, trimmed.slice(2))
18
+ : trimmed;
19
+ return (isAbsolute(expanded) ? resolve(expanded) : resolve(home, expanded)).replaceAll("\\", "/");
20
+ }
21
+ function escapeRegexCharacter(character) {
22
+ return /[\\^$.*+?()[\]{}|]/.test(character) ? `\\${character}` : character;
23
+ }
24
+ /** A small path-only glob: `*` stays in one directory, `**` crosses them. */
25
+ function globRegex(pattern) {
26
+ let source = "^";
27
+ for (let index = 0; index < pattern.length; index += 1) {
28
+ const character = pattern[index];
29
+ if (character === "*" && pattern[index + 1] === "*") {
30
+ index += 1;
31
+ // `foo/**/bar` also matches `foo/bar`; a plain terminal `/**` matches
32
+ // descendants, while the directory itself is handled by the prefix
33
+ // check in transcriptPathIsExcluded.
34
+ if (pattern[index + 1] === "/") {
35
+ index += 1;
36
+ source += "(?:.*/)?";
37
+ }
38
+ else {
39
+ source += ".*";
40
+ }
41
+ }
42
+ else if (character === "*") {
43
+ source += "[^/]*";
44
+ }
45
+ else if (character === "?") {
46
+ source += "[^/]";
47
+ }
48
+ else {
49
+ source += escapeRegexCharacter(character);
50
+ }
51
+ }
52
+ source += "$";
53
+ return new RegExp(source, process.platform === "win32" ? "i" : "");
54
+ }
55
+ /**
56
+ * True when an automatic watcher must leave this transcript alone.
57
+ *
58
+ * Entries without wildcards name either one file or a whole directory tree.
59
+ * Entries with `*`, `**`, or `?` use conventional path-glob semantics. This
60
+ * matcher is intentionally about transcript storage paths, not project cwd:
61
+ * the live watcher knows the former before it reads any transcript metadata.
62
+ */
63
+ export function transcriptPathIsExcluded(path, exclusions, home = process.env.HOME ?? homedir()) {
64
+ const candidate = resolve(path).replaceAll("\\", "/");
65
+ const foldedCandidate = process.platform === "win32" ? candidate.toLowerCase() : candidate;
66
+ for (const raw of exclusions) {
67
+ if (!raw.trim())
68
+ continue;
69
+ const pattern = normaliseExclusion(raw, home).replace(/\/+$/, "");
70
+ if (!pattern)
71
+ continue;
72
+ if (/[*?]/.test(pattern)) {
73
+ if (globRegex(pattern).test(candidate))
74
+ return true;
75
+ // `/private/**` is normally understood to include the directory itself,
76
+ // which lets discovery prune that branch before walking its files.
77
+ if (pattern.endsWith("/**")) {
78
+ const directory = pattern.slice(0, -3);
79
+ const foldedDirectory = process.platform === "win32" ? directory.toLowerCase() : directory;
80
+ if (foldedCandidate === foldedDirectory)
81
+ return true;
82
+ }
83
+ continue;
84
+ }
85
+ const foldedPattern = process.platform === "win32" ? pattern.toLowerCase() : pattern;
86
+ if (foldedCandidate === foldedPattern || foldedCandidate.startsWith(`${foldedPattern}/`))
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+ /** One policy check shared by filesystem events, lifecycle hooks, and workers. */
92
+ export function automaticTranscriptIsEnabled(settings, agent, path, home = process.env.HOME ?? homedir()) {
93
+ return (agent === "codex" || agent === "claude")
94
+ && settings.watchedAgents.includes(agent)
95
+ && !transcriptPathIsExcluded(path, settings.exclusions, home);
96
+ }
97
+ /** Safe defaults: no deletion and no exclusion until the person asks. */
98
+ export function defaultLocalSettings() {
99
+ return {
100
+ version: 1,
101
+ deviceLabel: hostname(),
102
+ watchedAgents: ["codex", "claude"],
103
+ watchedPaths: [],
104
+ exclusions: [],
105
+ reclaimEnabled: false,
106
+ trashStrategy: "auto",
107
+ schedule: { intervalMinutes: 5 },
108
+ bandwidth: { uploadLimitMbps: null },
109
+ diskReserveMb: null,
110
+ // A week keeps recent memory restorable while offline without allowing a
111
+ // second, encrypted copy of the whole history to grow forever on disk.
112
+ localArchiveCacheDays: 7,
113
+ };
114
+ }
115
+ /** Automatic work must not infer "unlimited" from an older settings file.
116
+ * 8 Mbps stays below common 10–20 Mbps uplinks while a large archive still
117
+ * makes steady progress. The local Settings page exposes this default. */
118
+ export const DEFAULT_BACKGROUND_UPLOAD_LIMIT_MBPS = 8;
119
+ export function backgroundUploadBytesPerSecond(settings) {
120
+ const configured = settings.bandwidth.uploadLimitMbps;
121
+ const mbps = typeof configured === "number" && Number.isFinite(configured) && configured > 0
122
+ ? configured
123
+ : DEFAULT_BACKGROUND_UPLOAD_LIMIT_MBPS;
124
+ return mbps * 1_000_000 / 8;
125
+ }
126
+ export const localSettingsPath = (dataDir) => join(dataDir, "local-settings.json");
127
+ /** Missing/older fields inherit safe defaults; this file is preferences, not cryptographic state. */
128
+ export async function readLocalSettings(dataDir) {
129
+ const fallback = defaultLocalSettings();
130
+ try {
131
+ const saved = JSON.parse(await readFile(localSettingsPath(dataDir), "utf8"));
132
+ return {
133
+ ...fallback,
134
+ ...saved,
135
+ watchedAgents: Array.isArray(saved?.watchedAgents)
136
+ ? saved.watchedAgents.filter((agent) => agent === "codex" || agent === "claude")
137
+ : fallback.watchedAgents,
138
+ watchedPaths: Array.isArray(saved?.watchedPaths) ? saved.watchedPaths.filter((path) => typeof path === "string") : [],
139
+ exclusions: Array.isArray(saved?.exclusions) ? saved.exclusions.filter((path) => typeof path === "string") : [],
140
+ schedule: { ...fallback.schedule, ...saved?.schedule },
141
+ bandwidth: { ...fallback.bandwidth, ...saved?.bandwidth },
142
+ localArchiveCacheDays: saved?.localArchiveCacheDays === null
143
+ ? null
144
+ : typeof saved?.localArchiveCacheDays === "number"
145
+ && Number.isFinite(saved.localArchiveCacheDays)
146
+ && saved.localArchiveCacheDays >= 0
147
+ && saved.localArchiveCacheDays <= 3650
148
+ ? saved.localArchiveCacheDays
149
+ : fallback.localArchiveCacheDays,
150
+ };
151
+ }
152
+ catch {
153
+ return fallback;
154
+ }
155
+ }
156
+ /** Atomic and owner-only: per-machine preferences are never synced. */
157
+ export async function writeLocalSettings(dataDir, settings) {
158
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
159
+ const target = localSettingsPath(dataDir);
160
+ const temp = `${target}.${randomBytes(9).toString("hex")}.tmp`;
161
+ await writeFile(temp, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
162
+ await rename(temp, target);
163
+ }
164
+ export function resolvedTrashStrategy(settings) {
165
+ return settings.trashStrategy === "auto" ? defaultStrategy() : settings.trashStrategy;
166
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ChunkStoreClient } from "./chunk-store.js";
2
+ import { type ManagedDeleteContext } from "./cloud.js";
2
3
  /**
3
4
  * Managed chunk folders, Paul's way: no chunk support in the control plane at
4
5
  * all. The client already chunks — every sealed chunk is an ordinary file of
@@ -12,7 +13,7 @@ import { type ChunkStoreClient } from "./chunk-store.js";
12
13
  * the object's cloud reference is `<archiveId>.<name>`:
13
14
  *
14
15
  * f0be…12af/chunk-000000 → vault_ref f0be…12af.chunk-000000
15
- * f0be…12af/envelope.vlmeta → vault_ref f0be…12af.envelope.vlmeta
16
+ * f0be…12af/envelope.skmeta → vault_ref f0be…12af.envelope.skmeta
16
17
  *
17
18
  * Deterministic both ways, so any machine of the vault re-derives every
18
19
  * reference from the archive id and the chunk count — no listing, no state.
@@ -52,4 +53,6 @@ export declare function collapseCloudRows<T extends CloudRowLike>(rows: T[]): (T
52
53
  * `pullCiphertext` hash-checks every read against the digest recorded at
53
54
  * upload before handing it back.
54
55
  */
55
- export declare function managedChunkClient(dataDir: string): ChunkStoreClient;
56
+ export declare function managedChunkClient(dataDir: string, options?: {
57
+ deleteContext?: ManagedDeleteContext;
58
+ }): ChunkStoreClient;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { fail, isVaultlineError } from "./errors.js";
2
+ import { fail, isSealkeepError } from "./errors.js";
3
3
  import { chunkObjectName, ENVELOPE_OBJECT } from "./chunk-store.js";
4
4
  import { completeUpload, deleteCloudArchive, pullCiphertext, putCiphertext, requestUpload, statCiphertext } from "./cloud.js";
5
5
  /**
@@ -15,7 +15,7 @@ import { completeUpload, deleteCloudArchive, pullCiphertext, putCiphertext, requ
15
15
  * the object's cloud reference is `<archiveId>.<name>`:
16
16
  *
17
17
  * f0be…12af/chunk-000000 → vault_ref f0be…12af.chunk-000000
18
- * f0be…12af/envelope.vlmeta → vault_ref f0be…12af.envelope.vlmeta
18
+ * f0be…12af/envelope.skmeta → vault_ref f0be…12af.envelope.skmeta
19
19
  *
20
20
  * Deterministic both ways, so any machine of the vault re-derives every
21
21
  * reference from the archive id and the chunk count — no listing, no state.
@@ -44,7 +44,7 @@ export function managedChunkRefs(archiveId, chunkCount) {
44
44
  };
45
45
  }
46
46
  /** Recognises a cloud row that is one member of a chunk family, by its reference alone. */
47
- const FAMILY_REF = /^(.+)\.(chunk-\d{6}|envelope\.vlmeta)$/;
47
+ const FAMILY_REF = /^(.+)\.(chunk-\d{6}|envelope\.skmeta)$/;
48
48
  /**
49
49
  * Folds chunk-family rows into one synthetic row per archive for listings:
50
50
  * the family's reference is the archive id, its bytes are the chunk sum (the
@@ -78,28 +78,28 @@ export function collapseCloudRows(rows) {
78
78
  * `pullCiphertext` hash-checks every read against the digest recorded at
79
79
  * upload before handing it back.
80
80
  */
81
- export function managedChunkClient(dataDir) {
81
+ export function managedChunkClient(dataDir, options = {}) {
82
82
  return {
83
- async putObject(key, body) {
83
+ async putObject(key, body, writeOptions = {}) {
84
84
  const ref = managedRefForKey(key);
85
85
  const digest = createHash("sha256").update(body).digest("hex");
86
- const lease = await requestUpload(dataDir, { vaultRef: ref, ciphertextSha256: digest, bytes: body.length });
87
- await putCiphertext(lease, body);
88
- await completeUpload(dataDir, lease.lease_id, body.length);
86
+ const lease = await requestUpload(dataDir, { vaultRef: ref, ciphertextSha256: digest, bytes: body.length }, undefined, { signal: writeOptions.signal });
87
+ await putCiphertext(lease, body, writeOptions.signal);
88
+ await completeUpload(dataDir, lease.lease_id, body.length, undefined, { signal: writeOptions.signal });
89
89
  return { checksum: digest };
90
90
  },
91
- async headObject(key) {
92
- return statCiphertext(dataDir, managedRefForKey(key));
91
+ async headObject(key, readOptions = {}) {
92
+ return statCiphertext(dataDir, managedRefForKey(key), undefined, { signal: readOptions.signal });
93
93
  },
94
- async getObject(key) {
95
- return (await pullCiphertext(dataDir, managedRefForKey(key))).ciphertext;
94
+ async getObject(key, readOptions = {}) {
95
+ return (await pullCiphertext(dataDir, managedRefForKey(key), undefined, { signal: readOptions.signal })).ciphertext;
96
96
  },
97
97
  async deleteObject(key) {
98
98
  try {
99
- await deleteCloudArchive(dataDir, managedRefForKey(key));
99
+ await deleteCloudArchive(dataDir, managedRefForKey(key), undefined, { context: options.deleteContext });
100
100
  }
101
101
  catch (error) {
102
- if (isVaultlineError(error) && (error.code === "archive_not_found" || /returned 404|not[ _-]?found/i.test(error.message)))
102
+ if (isSealkeepError(error) && (error.code === "archive_not_found" || /returned 404|not[ _-]?found/i.test(error.message)))
103
103
  return;
104
104
  throw error;
105
105
  }
@@ -1,14 +1,17 @@
1
- import { execFile } from "node:child_process";
2
1
  import { type SetupAgentId } from "./adapters.js";
3
- declare const run: typeof execFile.__promisify__;
2
+ /** Test seam for agent-owned installers (`which`/`where` and `claude mcp add`). */
3
+ export type McpExec = (file: string, args: string[], options?: {
4
+ env?: NodeJS.ProcessEnv;
5
+ }) => Promise<unknown>;
4
6
  /**
5
7
  * Registering Sealkeep's MCP server with the agents on this machine.
6
8
  *
7
9
  * Archiving sessions and being able to *ask about* them are different products,
8
10
  * and only the first one was wired. `src/mcp.ts` has always exposed
9
- * `vaultline_search` and `vaultline_recover`, but nothing installed it and
10
- * nothing told an agent it existed — a door with no sign on it. This is the
11
- * sign.
11
+ * `sealkeep_search` and `sealkeep_recover` (still answering to their original
12
+ * `vaultline_search` / `vaultline_recover` names too, for configs that already
13
+ * point at them), but nothing installed it and nothing told an agent it
14
+ * existed — a door with no sign on it. This is the sign.
12
15
  *
13
16
  * What is deliberately NOT here: any claim that an agent can reach archives it
14
17
  * does not hold locally. The MCP server searches this machine's index. A
@@ -36,17 +39,16 @@ export declare function mcpServerSpec(dataDir: string, executable?: string): {
36
39
  /**
37
40
  * Whether the command we are about to hand another program can actually be run.
38
41
  *
39
- * Registering `vaultline` when it is not on PATH — a checkout, an npx run, a
42
+ * Registering `sealkeep` when it is not on PATH — a checkout, an npx run, a
40
43
  * shell whose PATH the agent does not inherit — writes a server the agent
41
44
  * reports as "Failed to connect: ENOENT" forever, while this command claims
42
45
  * success. Reporting a registration that cannot start is the same false claim
43
46
  * as an archive that was never verified, and it is caught the same way: prove
44
47
  * it before saying it.
45
48
  */
46
- export declare function executableResolves(executable?: string, exec?: typeof execFile.__promisify__): Promise<boolean>;
49
+ export declare function executableResolves(executable?: string, exec?: McpExec): Promise<boolean>;
47
50
  export declare function installMcpServer(dataDir: string, options?: {
48
51
  only?: string;
49
- exec?: typeof run;
52
+ exec?: McpExec;
50
53
  home?: string;
51
54
  }): Promise<McpInstallResult>;
52
- export {};
@@ -4,9 +4,13 @@ import { dirname, join } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { promisify } from "node:util";
6
6
  import { detectSetupAgents } from "./adapters.js";
7
+ import { PRODUCT_SLUG } from "./branding.js";
7
8
  const run = promisify(execFile);
9
+ const execute = (file, args, options) => options?.env
10
+ ? run(file, args, { env: options.env })
11
+ : run(file, args);
8
12
  /** What every agent needs to start the server, in one place so the copies cannot drift. */
9
- export function mcpServerSpec(dataDir, executable = "vaultline") {
13
+ export function mcpServerSpec(dataDir, executable = PRODUCT_SLUG) {
10
14
  return {
11
15
  command: executable,
12
16
  args: ["mcp"],
@@ -16,14 +20,14 @@ export function mcpServerSpec(dataDir, executable = "vaultline") {
16
20
  /**
17
21
  * Whether the command we are about to hand another program can actually be run.
18
22
  *
19
- * Registering `vaultline` when it is not on PATH — a checkout, an npx run, a
23
+ * Registering `sealkeep` when it is not on PATH — a checkout, an npx run, a
20
24
  * shell whose PATH the agent does not inherit — writes a server the agent
21
25
  * reports as "Failed to connect: ENOENT" forever, while this command claims
22
26
  * success. Reporting a registration that cannot start is the same false claim
23
27
  * as an archive that was never verified, and it is caught the same way: prove
24
28
  * it before saying it.
25
29
  */
26
- export async function executableResolves(executable = "vaultline", exec = run) {
30
+ export async function executableResolves(executable = PRODUCT_SLUG, exec = execute) {
27
31
  try {
28
32
  await exec(process.platform === "win32" ? "where" : "which", [executable]);
29
33
  return true;
@@ -39,14 +43,14 @@ export async function executableResolves(executable = "vaultline", exec = run) {
39
43
  * installer then refused as foreign — the client normalises what it stores, so
40
44
  * a hand-written entry never matches what the client would have written.
41
45
  */
42
- async function installForClaude(dataDir, exec = run) {
46
+ async function installForClaude(dataDir, exec = execute, home) {
43
47
  const spec = mcpServerSpec(dataDir);
44
48
  try {
45
49
  await exec("claude", [
46
- "mcp", "add", "--transport", "stdio", "--scope", "user", "vaultline",
50
+ "mcp", "add", "--transport", "stdio", "--scope", "user", PRODUCT_SLUG,
47
51
  "--env", `SEALKEEP_DATA_DIR=${dataDir}`,
48
52
  "--", spec.command, ...spec.args
49
- ]);
53
+ ], home ? { env: { ...process.env, HOME: home, USERPROFILE: home } } : undefined);
50
54
  return { installed: true, detail: "registered with Claude Code — ask it to search your sessions" };
51
55
  }
52
56
  catch (error) {
@@ -58,38 +62,77 @@ async function installForClaude(dataDir, exec = run) {
58
62
  }
59
63
  /**
60
64
  * Codex configures itself in TOML. This still ships no TOML *parser* — but it
61
- * does not need one to APPEND safely: a `[mcp_servers.vaultline]` table at the
65
+ * does not need one to APPEND safely: a `[mcp_servers.sealkeep]` table at the
62
66
  * end of the file is a new top-level table, valid TOML regardless of what
63
67
  * precedes it, as long as that section does not already exist. So the rule is:
68
+ * config file absent → create it exclusively with owner-only permissions;
64
69
  * config file present and section absent → back it up, append, registered;
65
- * anything else → the reviewable fragment, exactly as before. The backup makes
66
- * the edit as reversible as never having made it.
70
+ * section present → leave it alone. The backup makes an edit to an existing
71
+ * config as reversible as never having made it.
67
72
  */
68
- async function installForCodex(dataDir, home) {
73
+ async function writeCodexFragment(dataDir) {
69
74
  const spec = mcpServerSpec(dataDir);
70
75
  const fragmentPath = join(dataDir, "integrations", "codex-mcp.toml");
71
76
  const table = [
72
- "[mcp_servers.vaultline]",
77
+ `[mcp_servers.${PRODUCT_SLUG}]`,
73
78
  `command = ${JSON.stringify(spec.command)}`,
74
79
  `args = ${JSON.stringify(spec.args)}`,
75
80
  "",
76
- "[mcp_servers.vaultline.env]",
81
+ `[mcp_servers.${PRODUCT_SLUG}.env]`,
77
82
  `SEALKEEP_DATA_DIR = ${JSON.stringify(dataDir)}`,
78
83
  ""
79
84
  ].join("\n");
80
85
  await mkdir(dirname(fragmentPath), { recursive: true });
81
- await writeFile(fragmentPath, `# Merge into ~/.codex/config.toml to let Codex search your archived sessions.\n${table}`, { mode: 0o600 });
86
+ await writeFile(fragmentPath, `# Sealkeep MCP registration for Codex.\n${table}`, { mode: 0o600 });
87
+ return { fragmentPath, table };
88
+ }
89
+ async function installForCodex(dataDir, home) {
90
+ const { fragmentPath, table } = await writeCodexFragment(dataDir);
82
91
  const configPath = join(home ?? homedir(), ".codex", "config.toml");
83
- const current = await readFile(configPath, "utf8").catch(() => null);
84
- if (current === null) {
85
- return { installed: false, detail: `Codex has no config.toml yet — merge this once it exists: ${fragmentPath}`, fragmentPath };
92
+ let current;
93
+ try {
94
+ current = await readFile(configPath, "utf8");
86
95
  }
87
- if (current.includes("[mcp_servers.vaultline]")) {
96
+ catch (error) {
97
+ if (error.code !== "ENOENT") {
98
+ const message = error instanceof Error ? error.message.split("\n")[0] : "unknown error";
99
+ return { installed: false, detail: `could not read ~/.codex/config.toml: ${message}`, fragmentPath };
100
+ }
101
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
102
+ try {
103
+ // `wx` is the safety boundary: if Codex (or another setup process) creates
104
+ // the file after our read, never replace what it wrote.
105
+ await writeFile(configPath, table, { mode: 0o600, flag: "wx" });
106
+ return { installed: true, detail: "created ~/.codex/config.toml and registered the local Sealkeep MCP", fragmentPath };
107
+ }
108
+ catch (createError) {
109
+ if (createError.code !== "EEXIST") {
110
+ const message = createError instanceof Error ? createError.message.split("\n")[0] : "unknown error";
111
+ return { installed: false, detail: `could not create ~/.codex/config.toml: ${message}`, fragmentPath };
112
+ }
113
+ try {
114
+ current = await readFile(configPath, "utf8");
115
+ }
116
+ catch (raceError) {
117
+ const message = raceError instanceof Error ? raceError.message.split("\n")[0] : "unknown error";
118
+ return { installed: false, detail: `Codex created config.toml during setup, but Sealkeep could not read it: ${message}`, fragmentPath };
119
+ }
120
+ }
121
+ }
122
+ if (current.includes(`[mcp_servers.${PRODUCT_SLUG}]`)) {
88
123
  return { installed: true, detail: "already registered in ~/.codex/config.toml", fragmentPath };
89
124
  }
90
- const backupPath = `${configPath}.vaultline-backup-${Date.now()}`;
125
+ /**
126
+ * A registration from before the rename names a command that no longer
127
+ * exists, so Codex fails to start it forever. Appending a good table beside
128
+ * it does not help — the dead one still errors on every launch — so the
129
+ * legacy block is REPLACED, not left to rot next to its replacement.
130
+ */
131
+ const legacy = /\[mcp_servers\.vaultline(?:\.env)?\][^[]*/g;
132
+ const cleaned = current.replace(legacy, "").replace(/\n{3,}/g, "\n\n");
133
+ const backupPath = `${configPath}.${PRODUCT_SLUG}-backup-${Date.now()}`;
91
134
  await writeFile(backupPath, current, { mode: 0o600 });
92
- await writeFile(configPath, `${current}${current.endsWith("\n") ? "" : "\n"}\n${table}`, { mode: 0o600 });
135
+ await writeFile(configPath, `${cleaned}${cleaned.endsWith("\n") ? "" : "\n"}\n${table}`, { mode: 0o600 });
93
136
  return { installed: true, detail: `registered in ~/.codex/config.toml (backup kept beside it)`, fragmentPath, backupPath };
94
137
  }
95
138
  export async function installMcpServer(dataDir, options = {}) {
@@ -104,8 +147,8 @@ export async function installMcpServer(dataDir, options = {}) {
104
147
  * there. A fragment is inert until someone merges it deliberately, so
105
148
  * withholding it would only remove information.
106
149
  */
107
- const runnable = await executableResolves("vaultline", options.exec);
108
- const notOnPath = "`vaultline` is not on PATH, so the server could not start. Install it globally (npm i -g vaultline) and run this again.";
150
+ const runnable = await executableResolves(PRODUCT_SLUG, options.exec);
151
+ const notOnPath = "The `sealkeep` executable is not on PATH, so the MCP server could not start. Install it globally (`npm i -g sealkeep`), then reopen Setup.";
109
152
  for (const found of detected) {
110
153
  if (!found.detected)
111
154
  continue;
@@ -113,18 +156,20 @@ export async function installMcpServer(dataDir, options = {}) {
113
156
  continue;
114
157
  if (found.agent === "claude") {
115
158
  agents.push(runnable
116
- ? { agent: found.agent, label: found.label, ...(await installForClaude(dataDir, options.exec)) }
159
+ ? { agent: found.agent, label: found.label, ...(await installForClaude(dataDir, options.exec, options.home)) }
117
160
  : { agent: found.agent, label: found.label, installed: false, detail: `not registered — ${notOnPath}` });
118
161
  }
119
162
  else if (found.agent === "codex") {
120
163
  // A registration that cannot start is worse than none (same rule as
121
- // Claude): only a runnable `vaultline` earns the config append; without
164
+ // Claude): only a runnable `sealkeep` earns the config append; without
122
165
  // it the fragment is written and the advice says why.
123
- const fragment = runnable
124
- ? await installForCodex(dataDir, options.home)
125
- : await installForCodex(dataDir, "/nonexistent-home-so-only-the-fragment-is-written");
126
- agents.push({ ...fragment, agent: found.agent, label: found.label,
127
- detail: runnable ? fragment.detail : `not registered — ${notOnPath}`, installed: runnable ? fragment.installed : false });
166
+ if (runnable) {
167
+ agents.push({ ...(await installForCodex(dataDir, options.home)), agent: found.agent, label: found.label });
168
+ }
169
+ else {
170
+ const { fragmentPath } = await writeCodexFragment(dataDir);
171
+ agents.push({ agent: found.agent, label: found.label, fragmentPath, installed: false, detail: `not registered — ${notOnPath}` });
172
+ }
128
173
  }
129
174
  else {
130
175
  // Detected, but Sealkeep has no adapter for its sessions, so an MCP
@@ -0,0 +1,18 @@
1
+ import type { TeamSpaceBinding } from "./vault.js";
2
+ type Selection = {
3
+ project: string;
4
+ projectKey: string;
5
+ };
6
+ export type WorkspaceSelection = Selection | {
7
+ note: string;
8
+ candidates: Selection[];
9
+ };
10
+ /** Roots are authoritative when supported. No label or last-used-project fallback. */
11
+ export declare function resolveMcpWorkspace(input: {
12
+ roots?: readonly {
13
+ uri: string;
14
+ }[];
15
+ cwd: string;
16
+ bindings: Record<string, TeamSpaceBinding>;
17
+ }): Promise<WorkspaceSelection>;
18
+ export {};
@@ -0,0 +1,50 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { projectFromCwd } from "./adapters.js";
7
+ /** Roots are authoritative when supported. No label or last-used-project fallback. */
8
+ export async function resolveMcpWorkspace(input) {
9
+ const candidates = Object.entries(input.bindings).flatMap(([projectKey, binding]) => /^(g1|p1):[a-f0-9]{64}$/.test(projectKey) && binding.localConfirmed && binding.localProject
10
+ ? [{ project: binding.localProject, projectKey }] : []);
11
+ const hint = () => ({
12
+ note: "Workspace scope could not be resolved to one confirmed project. Supply project and projectKey from a confirmed checkout, or scope: account for an explicitly account-wide search.",
13
+ candidates,
14
+ });
15
+ try {
16
+ const paths = input.roots === undefined ? [input.cwd] : input.roots.map(({ uri }) => {
17
+ const url = new URL(uri);
18
+ if (url.protocol !== "file:" || url.search || url.hash)
19
+ throw new Error("invalid workspace root");
20
+ return fileURLToPath(url);
21
+ });
22
+ if (!paths.length || paths.length > 32)
23
+ return hint();
24
+ const canonical = [...new Set(await Promise.all(paths.map(async (path) => {
25
+ const resolved = await realpath(path);
26
+ if (!(await stat(resolved)).isDirectory())
27
+ throw new Error("workspace is not a folder");
28
+ return resolved;
29
+ })))];
30
+ // Duplicate representations of one root are harmless; multiple folders are
31
+ // not evidence of which one the current tool call concerns.
32
+ if (canonical.length !== 1)
33
+ return hint();
34
+ let current = canonical[0];
35
+ // Never fall through an unconfirmed nested repository into its parent repo.
36
+ const gitRoot = await promisify(execFile)("git", ["-C", current, "rev-parse", "--show-toplevel"], { timeout: 2000, maxBuffer: 64 * 1024 }).then(async ({ stdout }) => realpath(stdout.trim()), () => null);
37
+ for (let depth = 0; depth < 64; depth++) {
38
+ const identity = await projectFromCwd(current);
39
+ const match = candidates.find((candidate) => candidate.projectKey === identity.projectKey);
40
+ if (match)
41
+ return match;
42
+ const parent = dirname(current);
43
+ if (current === gitRoot || parent === current)
44
+ break;
45
+ current = parent;
46
+ }
47
+ }
48
+ catch { /* unavailable/unknown roots must not widen the search */ }
49
+ return hint();
50
+ }